diff --git a/assets/client/components/account/AccountBalance.tsx b/assets/client/components/account/AccountBalance.tsx index ea64f9a..e11c4de 100644 --- a/assets/client/components/account/AccountBalance.tsx +++ b/assets/client/components/account/AccountBalance.tsx @@ -1,9 +1,8 @@ import AccountLayout from '@/client/layouts/AccountLayout'; import { useTranslation } from 'react-i18next'; -import useApi from '@/client/hooks/useApi'; import { Account } from '@/client/interfaces/Account'; -import { useParams } from 'react-router-dom'; -import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useEffect, useRef } from 'react'; import Loading from '@/client/components/Loading'; import useFunnyImage from '@/client/hooks/useFunnyImage'; import { formatCentsToEuro } from '@/client/utils/currency'; @@ -14,43 +13,74 @@ import { } from '@/client/utils/date'; import { Locale } from '@/client/interfaces/Locale'; import { pathToRoute } from '@/client/utils/pathToRoute'; +import { useAccountStore } from '@/client/store/useAccountStore'; -export default function AccountBalance() { +interface Props { + account: Account; +} + +function AccountBalanceView({ account }: Props) { const { t, i18n } = useTranslation(); - const { id } = useParams(); - const { - data: account, - isLoading, - fetchData, - } = useApi(`accounts/${id}`); - const [balance, setBalance] = useState(null); + const { imagePath } = useFunnyImage(account?.balance); - const [nextPayday, setNextPayday] = useState(null); - const [daysUntilNextPayday, setDaysUntilNextPayday] = useState( - null + + const nextPayday = account.nextPayday + ? getFormattedNextPayday(account.nextPayday, i18n.language as Locale) + : null; + const daysUntilNextPayday = account.nextPayday + ? getDaysUntilNextPayday(account.nextPayday) + : null; + + return ( +
+ {account.balance && ( +
+ {formatCentsToEuro(account.balance)} +
+ )} + +
+
+ funny image +
+
+
+
+ {t('OVERVIEW.PAYDAY_WEEKDAY_LABEL')}  + + {nextPayday ?? t('OVERVIEW.PAYDAY_WEEKDAY_UNKNOWN')} + +
+
+ {t('OVERVIEW.PAYDAY_COUNTER_LABEL')}  + + {daysUntilNextPayday ?? t('OVERVIEW.PAYDAY_COUNTER_UNKNOWN')} + +
+
+
); +} - useEffect(() => { - fetchData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); +export default function AccountBalance() { + const { t } = useTranslation(); + const { id } = useParams(); + const navigate = useNavigate(); + const { account, isLoading, fetchData } = useAccountStore(); + + const fetchDataRef = useRef(fetchData); useEffect(() => { - if (!account) { + if (!id) { return; } + fetchDataRef.current(id); + }, [id]); - if (account.balance) { - setBalance(formatCentsToEuro(account.balance)); - } - - if (account.nextPayday) { - setNextPayday( - getFormattedNextPayday(account.nextPayday!, i18n.language as Locale) - ); - setDaysUntilNextPayday(getDaysUntilNextPayday(account.nextPayday!)); - } - }, [account, i18n.language]); + if (!id) { + navigate(pathToRoute('dashboard')); + return; + } return (
- {isLoading ? ( - - ) : ( -
- {balance &&
{balance}
} - -
-
- funny image -
-
-
-
- {t('OVERVIEW.PAYDAY_WEEKDAY_LABEL')}  - - {nextPayday ?? t('OVERVIEW.PAYDAY_WEEKDAY_UNKNOWN')} - -
-
- {t('OVERVIEW.PAYDAY_COUNTER_LABEL')}  - - {daysUntilNextPayday ?? t('OVERVIEW.PAYDAY_COUNTER_UNKNOWN')} - -
-
-
+ {isLoading && } + {!isLoading && id && account && ( + )}
diff --git a/assets/client/components/account/AccountDashboard.tsx b/assets/client/components/account/AccountDashboard.tsx index c86dde8..3f7da0f 100644 --- a/assets/client/components/account/AccountDashboard.tsx +++ b/assets/client/components/account/AccountDashboard.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; import AccountLayout from '@/client/layouts/AccountLayout'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import './AccountDashboard.scss'; import { pathToRoute } from '@/client/utils/pathToRoute'; @@ -10,49 +10,65 @@ interface Section { url: string; } +interface Props { + sections: Section[]; +} + /** * This page shows the details of an account and is the entry point for any account holder * - Does not show a logout or back button */ +function AccountDashboardView({ sections }: Props) { + return ( +
+
+ {sections.map((section, index) => ( + + ))} +
+
+ ); +} + export default function AccountDashboard() { - const { t } = useTranslation(); const { id } = useParams(); + const { t } = useTranslation(); + const navigate = useNavigate(); const sections: Section[] = [ { - name: 'DASHBOARD.SECTION_NAME.OVERVIEW', + name: t('DASHBOARD.SECTION_NAME.OVERVIEW'), image: './assets/images/overview.png', url: pathToRoute('accounts_balance', { id }), }, { - name: 'DASHBOARD.SECTION_NAME.HISTORY', + name: t('DASHBOARD.SECTION_NAME.HISTORY'), image: './assets/images/history.png', url: pathToRoute('accounts_history', { id }), }, { - name: 'DASHBOARD.SECTION_NAME.PLAN', + name: t('DASHBOARD.SECTION_NAME.PLAN'), image: './assets/images/plan.png', url: pathToRoute('accounts_plan', { id }), }, ]; + if (!id) { + navigate(pathToRoute('dashboard')); + return; + } + return ( -
-
- {sections.map((section, index) => ( - - ))} -
-
+
); } diff --git a/assets/client/components/account/AccountHistory.tsx b/assets/client/components/account/AccountHistory.tsx index c0ae449..e87132a 100644 --- a/assets/client/components/account/AccountHistory.tsx +++ b/assets/client/components/account/AccountHistory.tsx @@ -1,6 +1,6 @@ import AccountLayout from '@/client/layouts/AccountLayout'; import { useTranslation } from 'react-i18next'; -import { redirect, useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { useEffect, useRef, useState } from 'react'; import Loading from '@/client/components/Loading'; import { pathToRoute } from '@/client/utils/pathToRoute'; @@ -9,52 +9,28 @@ import { Transaction } from '@/client/interfaces/Transaction'; import { formatCentsToEuro } from '@/client/utils/currency'; import './AccountHistory.scss'; -export default function AccountHistory() { +interface Props { + transactions: Transaction[]; +} + +function AccountHistoryView({ transactions }: Props) { const { t } = useTranslation(); - const { id } = useParams(); - const { - data: transactionData, - isLoading, - getTransactions, - } = useTransactions(); - - const [earnings, setEarnings] = useState([]); - const [expenses, setExpenses] = useState([]); - const [maxLength, setMaxLength] = useState(0); + const [showMoreButton, setShowMoreButton] = useState(false); const [displayedItems, setDisplayedItems] = useState(5); - const getTransactionsRef = useRef(getTransactions); - - // useEffect(() => { - // getTransactionsRef.current = getTransactions; - // }); + const earnings = transactions.filter((i) => i.isEarning); + const expenses = transactions.filter((i) => i.isExpense); + const max = Math.max(earnings.length, expenses.length); useEffect(() => { - if (!id) { - return; - } - getTransactionsRef.current(id); - }, [id]); - - useEffect(() => { - if (!transactionData) { - return; - } - - const earns = transactionData.filter((i) => i.isEarning); - const exps = transactionData.filter((i) => i.isExpense); - const max = Math.max(earns.length, exps.length); - setEarnings(earns); - setExpenses(exps); - setMaxLength(max); setShowMoreButton(displayedItems < max); setDisplayedItems(Math.min(max, displayedItems)); - }, [displayedItems, transactionData]); + }, [displayedItems, max]); useEffect(() => { - setShowMoreButton(displayedItems < maxLength); - }, [displayedItems, maxLength]); + setShowMoreButton(displayedItems < max); + }, [displayedItems, max]); function cssClasses(t: Transaction | undefined) { if (!t) { @@ -72,9 +48,84 @@ export default function AccountHistory() { setDisplayedItems(displayedItems + 5); } + return ( +
+ + + + + + + + + {[...Array(displayedItems)].map((_, index) => ( + + + + + ))} + +
+ Income + + Expenses +
+ {earnings[index]?.value ? ( + <> + + +{formatCentsToEuro(earnings[index].value!)} + + + {earnings[index].title} + + + ) : null} + + {expenses[index]?.value ? ( + <> + + {formatCentsToEuro(expenses[index].value!)} + + + {expenses[index].title} + + + ) : null} +
+ + {showMoreButton && ( +
+ +
+ )} +
+ ); +} + +export default function AccountHistory() { + const { t } = useTranslation(); + const { id } = useParams(); + const navigate = useNavigate(); + const { data: transactions, isLoading, getTransactions } = useTransactions(); + + const fetchTransactions = useRef(getTransactions); + + useEffect(() => { + if (!id) { + return; + } + fetchTransactions.current(id); + }, [id]); + if (!id) { - redirect(pathToRoute('dashboard')); - return null; + navigate(pathToRoute('dashboard')); + return; } return ( @@ -84,65 +135,9 @@ export default function AccountHistory() { >
{isLoading && } -
- - - - - - - - - {[...Array(displayedItems)].map((_, index) => ( - - - - - ))} - -
- Income - - Expenses -
- {earnings[index]?.value ? ( - <> - - +{formatCentsToEuro(earnings[index].value!)} - - - {earnings[index].title} - - - ) : null} - - {expenses[index]?.value ? ( - <> - - {formatCentsToEuro(expenses[index].value!)} - - - {expenses[index].title} - - - ) : null} -
- - {showMoreButton && ( -
- -
- )} -
+ {!isLoading && transactions && ( + + )}
); diff --git a/assets/client/components/account/plan/PlanPage.tsx b/assets/client/components/account/plan/PlanPage.tsx index b6cf209..d4d561c 100644 --- a/assets/client/components/account/plan/PlanPage.tsx +++ b/assets/client/components/account/plan/PlanPage.tsx @@ -10,7 +10,7 @@ import clsx from 'clsx'; import PlanEarnings from '@/client/components/account/plan/PlanEarnings'; import PlanExpenses from '@/client/components/account/plan/PlanExpenses'; import './PlanPage.scss'; -import { useAccountStore } from '@/client/config/accountStore'; +import { useAccountStore } from '@/client/store/useAccountStore'; export default function PlanPage() { const { t, i18n } = useTranslation(); @@ -32,14 +32,13 @@ export default function PlanPage() { } if (account.balance) { - accountStore.setBalance(account.balance); setFormattedBalance(formatCentsToEuro(account.balance)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [account, i18n.language]); function planExpense(costInCents: number) { - const balance = accountStore.balance ?? 0; + const balance = accountStore.account?.balance ?? 0; const newBalance = balance - costInCents; setTempBalance( @@ -56,7 +55,7 @@ export default function PlanPage() { return; } - const balance = accountStore.balance ?? 0; + const balance = accountStore.account?.balance ?? 0; const newBalance = balance + earnInCents; setTempBalance( diff --git a/assets/client/hooks/useApi.ts b/assets/client/hooks/useApi.ts index 992072c..af45c33 100644 --- a/assets/client/hooks/useApi.ts +++ b/assets/client/hooks/useApi.ts @@ -1,6 +1,7 @@ -import axios, { AxiosError, AxiosRequestConfig } from 'axios'; +import axios, { AxiosError } from 'axios'; import { useCallback, useState } from 'react'; -import { ENTRYPOINT } from '@/client/config/config'; +import { buildEndpointPath, buildRequestHeaders } from '../services/ApiService'; +import { ENTRYPOINT } from '../config/config'; interface UseApiOptions { method?: 'GET' | 'PUT' | 'POST' | 'DELETE'; @@ -9,28 +10,6 @@ interface UseApiOptions { hydrated?: boolean; } -const buildEndpointPath = (resource: string, options: UseApiOptions) => { - let endpointPath = `${ENTRYPOINT}${resource}`; - if (!options.hydrated) { - endpointPath += '.json'; - } - - if (options.query) { - const params = new URLSearchParams(options.query); - endpointPath += '?' + params.toString(); - } - - return endpointPath; -}; - -const buildRequestConfig = (options: UseApiOptions): AxiosRequestConfig => ({ - headers: { - 'Content-Type': options.hydrated - ? 'application/ld+json' - : 'application/json', - }, -}); - const useApi = ( resource: string, initialOptions: UseApiOptions = {} @@ -52,15 +31,14 @@ const useApi = ( }; try { - const endpointPath = buildEndpointPath(resource, options); - const requestConfig: AxiosRequestConfig = - buildRequestConfig(options); + const endpointPath = + ENTRYPOINT + buildEndpointPath(resource, options); const response = await axios({ url: endpointPath, method: options.method, data: options.body, - ...requestConfig, + headers: buildRequestHeaders(), }); if (response?.data) { diff --git a/assets/client/interfaces/Message.d.ts b/assets/client/interfaces/Message.d.ts index 25b002a..2a5f6e9 100644 --- a/assets/client/interfaces/Message.d.ts +++ b/assets/client/interfaces/Message.d.ts @@ -4,3 +4,7 @@ export interface Message { icon?: string; type: MessageType; } + +export interface ErrorMessage extends Message { + type: 'error'; +} diff --git a/assets/client/services/ApiService.ts b/assets/client/services/ApiService.ts new file mode 100644 index 0000000..a530986 --- /dev/null +++ b/assets/client/services/ApiService.ts @@ -0,0 +1,22 @@ +import { RawAxiosRequestHeaders } from 'axios'; + +export const buildEndpointPath = ( + resource: string, + options: { hydrated?: boolean; query?: string } = {} +) => { + let endpointPath = resource; + if (!options.hydrated) { + endpointPath += '.json'; + } + + if (options.query) { + const params = new URLSearchParams(options.query); + endpointPath += '?' + params.toString(); + } + + return endpointPath; +}; + +export const buildRequestHeaders = (): RawAxiosRequestHeaders => ({ + 'Content-Type': 'application/json', +}); diff --git a/assets/client/store/useAccountStore.ts b/assets/client/store/useAccountStore.ts index 2de51f5..9177515 100644 --- a/assets/client/store/useAccountStore.ts +++ b/assets/client/store/useAccountStore.ts @@ -1,15 +1,84 @@ import { create } from 'zustand'; +import { Account } from '../interfaces/Account'; +import { ErrorMessage } from '../interfaces/Message'; +import { buildEndpointPath, buildRequestHeaders } from '../services/ApiService'; +import { resourceUrlForAccount } from '../utils/resource.factory'; +import axios, { AxiosError, CancelTokenSource } from 'axios'; +import { createErrorMessage } from '@/client/utils/MessageFactory'; interface AccountStoreState { - balance: number | null; - setBalance: (v: number) => void; + isLoading: boolean; + account: Account | null; + error: ErrorMessage | null; + + setAccount: (a: Account) => void; + fetchData: (id: string) => Promise; + lastFetch: number | null; + cancelTokenSource: CancelTokenSource | undefined; } -export const useAccountStore = create((set) => ({ +const CACHE_DURATION_MS = 60 * 1000; + +export const useAccountStore = create((set, get) => ({ balance: null, - setBalance: (value: number) => - set((state) => ({ - ...state, - balance: value, - })), + isLoading: false, + account: null, + error: null, + lastFetch: null, + cancelTokenSource: undefined, + + fetchData: async (id: string) => { + const now = Date.now(); + const currentState = get(); + + // Cancel previous request + if (currentState.cancelTokenSource) { + currentState.cancelTokenSource.cancel('New request initiated.'); + } + + // Cancel current request if cache duration has not passed yet + if ( + currentState.lastFetch && + now - currentState.lastFetch < CACHE_DURATION_MS + ) { + return; + } + + const endpointPath = buildEndpointPath(resourceUrlForAccount(id)); + const cancelTokenSource = axios.CancelToken.source(); + set({ isLoading: true, error: null, cancelTokenSource }); + + axios + .get(endpointPath, { + cancelToken: cancelTokenSource.token, + headers: buildRequestHeaders(), + }) + .then((response) => { + set({ + account: response.data, + lastFetch: Date.now(), + }); + }) + .catch((error) => { + if (!axios.isCancel(error)) { + const errorMessage = + error instanceof Error || error instanceof AxiosError + ? error.message + : 'An error occured while fetching the account.'; + set({ + error: createErrorMessage(errorMessage), + }); + } + }) + .finally(() => { + set({ + isLoading: false, + cancelTokenSource: undefined, + }); + }); + }, + setAccount: (value: Account) => + set({ + account: value, + }), })); diff --git a/assets/client/utils/MessageFactory.ts b/assets/client/utils/MessageFactory.ts index d0f8a50..0ddfc1a 100644 --- a/assets/client/utils/MessageFactory.ts +++ b/assets/client/utils/MessageFactory.ts @@ -1,4 +1,4 @@ -import { Message } from '@/client/interfaces/Message'; +import { ErrorMessage, Message } from '@/client/interfaces/Message'; export function createSuccessMessage(message: string, icon = '✔'): Message { return { @@ -16,7 +16,7 @@ export function createInfoMessage(message: string, icon = 'ℹ'): Message { }; } -export function createErrorMessage(message: string, icon = '⚠'): Message { +export function createErrorMessage(message: string, icon = '⚠'): ErrorMessage { return { message, icon,