From e5a6d0013f1428255dcc9f6c60996f0035a28afd Mon Sep 17 00:00:00 2001 From: serasm Date: Fri, 24 Oct 2025 23:15:03 +0200 Subject: [PATCH 1/5] Refactor the utils.ts file and Utils directory I divided the responsibilities between the files. I renamed the files. --- frontend/src/utils.ts | 73 ------------------- frontend/src/utils/errorsUtils.ts | 17 +++++ frontend/src/utils/patternsUtils.ts | 11 +++ frontend/src/utils/rulesUtils.ts | 41 +++++++++++ .../src/utils/{stats.ts => statsUtils.ts} | 0 frontend/src/utils/{text.ts => textUtils.ts} | 0 frontend/src/utils/translationUtils.ts | 7 ++ 7 files changed, 76 insertions(+), 73 deletions(-) delete mode 100644 frontend/src/utils.ts create mode 100644 frontend/src/utils/errorsUtils.ts create mode 100644 frontend/src/utils/patternsUtils.ts create mode 100644 frontend/src/utils/rulesUtils.ts rename frontend/src/utils/{stats.ts => statsUtils.ts} (100%) rename frontend/src/utils/{text.ts => textUtils.ts} (100%) create mode 100644 frontend/src/utils/translationUtils.ts diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts deleted file mode 100644 index e748a2e..0000000 --- a/frontend/src/utils.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { ApiError } from './client' -import i18n, { i18nPromise } from './i18n' - -let t: (key: string) => string = () => '' - -i18nPromise.then(() => { - t = i18n.t.bind(i18n) -}) - -export const emailPattern = { - value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, - message: t('general.errors.invalidEmail'), -} - -export const namePattern = { - value: /^[A-Za-z\s\u00C0-\u017F]{1,30}$/, - message: t('general.errors.invalidName'), -} - -export const passwordRules = (isRequired = true) => { - const rules: { - minLength: { value: number; message: string } - required?: string - } = { - minLength: { - value: 8, - message: t('general.errors.passwordMinCharacters'), - }, - } - - if (isRequired) { - rules.required = t('general.errors.passwordIsRequired') - } - - return rules -} - -export const confirmPasswordRules = (getValues: () => unknown, isRequired = true) => { - const rules: { - validate: (value: string) => boolean | string - required?: string - } = { - validate: (value: string) => { - const formValues = getValues() as { - password?: string - new_password?: string - } - const password = formValues.password || formValues.new_password - return value === password ? true : t('general.errors.passwordsDoNotMatch') - }, - } - - if (isRequired) { - rules.required = t('general.errors.passwordConfirmationIsRequired') - } - - return rules -} - -export const handleError = ( - err: ApiError, - showToast: (title: string, message: string, type: string) => void, -) => { - const errDetail = (err.body as { detail?: string | { msg: string }[] })?.detail - let errorMessage = t('general.errors.default') - - if (typeof errDetail === 'string') { - errorMessage = errDetail - } else if (Array.isArray(errDetail) && errDetail.length > 0) { - errorMessage = errDetail[0].msg - } - showToast(t('general.errors.error'), errorMessage, 'error') -} diff --git a/frontend/src/utils/errorsUtils.ts b/frontend/src/utils/errorsUtils.ts new file mode 100644 index 0000000..8ad5a31 --- /dev/null +++ b/frontend/src/utils/errorsUtils.ts @@ -0,0 +1,17 @@ +import type { ApiError } from '@/client' +import { translate } from '@/utils/translationUtils' + +export const handleError = ( + err: ApiError, + showToast: (title: string, message: string, type: string) => void, +) => { + const errDetail = (err.body as { detail?: string | { msg: string }[] })?.detail + let errorMessage = translate('general.errors.default') + + if (typeof errDetail === 'string') { + errorMessage = errDetail + } else if (Array.isArray(errDetail) && errDetail.length > 0) { + errorMessage = errDetail[0].msg + } + showToast(translate('general.errors.error'), errorMessage, 'error') +} diff --git a/frontend/src/utils/patternsUtils.ts b/frontend/src/utils/patternsUtils.ts new file mode 100644 index 0000000..87b752d --- /dev/null +++ b/frontend/src/utils/patternsUtils.ts @@ -0,0 +1,11 @@ +import { translate } from '@/utils/translationUtils' + +export const emailPattern = { + value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, + message: translate('general.errors.invalidEmail'), +} + +export const namePattern = { + value: /^[A-Za-z\s\u00C0-\u017F]{1,30}$/, + message: translate('general.errors.invalidName'), +} diff --git a/frontend/src/utils/rulesUtils.ts b/frontend/src/utils/rulesUtils.ts new file mode 100644 index 0000000..c44764e --- /dev/null +++ b/frontend/src/utils/rulesUtils.ts @@ -0,0 +1,41 @@ +import { translate } from '@/utils/translationUtils' + +export const passwordRules = (isRequired = true) => { + const rules: { + minLength: { value: number; message: string } + required?: string + } = { + minLength: { + value: 8, + message: translate('general.errors.passwordMinCharacters'), + }, + } + + if (isRequired) { + rules.required = translate('general.errors.passwordIsRequired') + } + + return rules +} + +export const confirmPasswordRules = (getValues: () => unknown, isRequired = true) => { + const rules: { + validate: (value: string) => boolean | string + required?: string + } = { + validate: (value: string) => { + const formValues = getValues() as { + password?: string + new_password?: string + } + const password = formValues.password || formValues.new_password + return value === password ? true : translate('general.errors.passwordsDoNotMatch') + }, + } + + if (isRequired) { + rules.required = translate('general.errors.passwordConfirmationIsRequired') + } + + return rules +} diff --git a/frontend/src/utils/stats.ts b/frontend/src/utils/statsUtils.ts similarity index 100% rename from frontend/src/utils/stats.ts rename to frontend/src/utils/statsUtils.ts diff --git a/frontend/src/utils/text.ts b/frontend/src/utils/textUtils.ts similarity index 100% rename from frontend/src/utils/text.ts rename to frontend/src/utils/textUtils.ts diff --git a/frontend/src/utils/translationUtils.ts b/frontend/src/utils/translationUtils.ts new file mode 100644 index 0000000..c6f38dc --- /dev/null +++ b/frontend/src/utils/translationUtils.ts @@ -0,0 +1,7 @@ +import i18n, { i18nPromise } from '@/i18n' + +export let translate: (key: string) => string = () => '' + +i18nPromise.then(() => { + translate = i18n.t.bind(i18n) +}) From 865c64254b61517402183954dff01f5d6f01e39d Mon Sep 17 00:00:00 2001 From: serasm Date: Fri, 24 Oct 2025 23:22:28 +0200 Subject: [PATCH 2/5] Changes connected to Utils refactor. Refactoring the Utils directory and the utils.ts file resulted in changes to imports. I had to change the paths. --- frontend/src/components/cards/CardIListtem.tsx | 2 +- frontend/src/components/commonUI/TextCounter.tsx | 2 +- frontend/src/components/stats/StatsSummaryGrid.tsx | 2 +- frontend/src/hooks/useTextCounter.tsx | 2 +- frontend/src/routes/_publicLayout/login.tsx | 2 +- frontend/src/routes/_publicLayout/signup.tsx | 3 ++- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/cards/CardIListtem.tsx b/frontend/src/components/cards/CardIListtem.tsx index f56cac2..443e3f1 100644 --- a/frontend/src/components/cards/CardIListtem.tsx +++ b/frontend/src/components/cards/CardIListtem.tsx @@ -1,5 +1,5 @@ import type { Card } from '@/client/types.gen' -import { stripHtml } from '@/utils/text' +import { stripHtml } from '@/utils/textUtils' import { Box, HStack, IconButton, Text } from '@chakra-ui/react' import { Link } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' diff --git a/frontend/src/components/commonUI/TextCounter.tsx b/frontend/src/components/commonUI/TextCounter.tsx index 047492d..8289a21 100644 --- a/frontend/src/components/commonUI/TextCounter.tsx +++ b/frontend/src/components/commonUI/TextCounter.tsx @@ -1,4 +1,4 @@ -import { MAX_CHARACTERS, WARNING_THRESHOLD } from '@/utils/text' +import { MAX_CHARACTERS, WARNING_THRESHOLD } from '@/utils/textUtils' import { Text } from '@chakra-ui/react' interface TextCounterProps { diff --git a/frontend/src/components/stats/StatsSummaryGrid.tsx b/frontend/src/components/stats/StatsSummaryGrid.tsx index 58961e8..9266a89 100644 --- a/frontend/src/components/stats/StatsSummaryGrid.tsx +++ b/frontend/src/components/stats/StatsSummaryGrid.tsx @@ -2,7 +2,7 @@ import { Box, SimpleGrid, Text } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' import type { CollectionBasicInfo, PracticeSessionStats } from '@/client' -import { calculateAverageAccuracy, calculateLearningTrend } from '@/utils/stats' +import { calculateAverageAccuracy, calculateLearningTrend } from '@/utils/statsUtils' interface StatCardProps { label: string diff --git a/frontend/src/hooks/useTextCounter.tsx b/frontend/src/hooks/useTextCounter.tsx index 3135cdc..52fbed1 100644 --- a/frontend/src/hooks/useTextCounter.tsx +++ b/frontend/src/hooks/useTextCounter.tsx @@ -1,5 +1,5 @@ import { toaster } from '@/components/ui/toaster' -import { MAX_CHARACTERS } from '@/utils/text' +import { MAX_CHARACTERS } from '@/utils/textUtils' import type { Editor } from '@tiptap/react' import { useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' diff --git a/frontend/src/routes/_publicLayout/login.tsx b/frontend/src/routes/_publicLayout/login.tsx index d9cfbc3..4122394 100644 --- a/frontend/src/routes/_publicLayout/login.tsx +++ b/frontend/src/routes/_publicLayout/login.tsx @@ -1,5 +1,6 @@ import Logo from '@/assets/Logo.svg' import useAuth from '@/hooks/useAuth' +import { emailPattern } from '@/utils/patternsUtils' import { Container, Field, Fieldset, Image, Text } from '@chakra-ui/react' import { Link, createFileRoute, redirect } from '@tanstack/react-router' import { type SubmitHandler, useForm } from 'react-hook-form' @@ -7,7 +8,6 @@ import { useTranslation } from 'react-i18next' import type { Body_login_login_access_token as AccessToken } from '../../client' import { DefaultButton } from '../../components/commonUI/Button' import { DefaultInput } from '../../components/commonUI/Input' -import { emailPattern } from '../../utils' export const Route = createFileRoute('/_publicLayout/login')({ component: Login, diff --git a/frontend/src/routes/_publicLayout/signup.tsx b/frontend/src/routes/_publicLayout/signup.tsx index d7671b9..746a024 100644 --- a/frontend/src/routes/_publicLayout/signup.tsx +++ b/frontend/src/routes/_publicLayout/signup.tsx @@ -1,5 +1,7 @@ import Logo from '@/assets/Logo.svg' import useAuth from '@/hooks/useAuth' +import { emailPattern } from '@/utils/patternsUtils' +import { confirmPasswordRules, passwordRules } from '@/utils/rulesUtils' import { Button, Container, Field, Fieldset, Image, Text } from '@chakra-ui/react' import { Link, createFileRoute, redirect } from '@tanstack/react-router' import { type SubmitHandler, useForm } from 'react-hook-form' @@ -7,7 +9,6 @@ import { useTranslation } from 'react-i18next' import type { UserRegister } from '../../client' import { DefaultInput } from '../../components/commonUI/Input' import PasswordInput from '../../components/commonUI/PasswordInput' -import { confirmPasswordRules, emailPattern, passwordRules } from '../../utils' export const Route = createFileRoute('/_publicLayout/signup')({ component: SignUp, From b128f5eb872f562cc79c4e13c27ff45c84b11a05 Mon Sep 17 00:00:00 2001 From: serasm Date: Fri, 24 Oct 2025 23:27:57 +0200 Subject: [PATCH 3/5] Moved useAuthContext to Context directory I created a new Contexts directory because it didn't exist. I moved one file that should be in it to that directory. --- frontend/src/{hooks => contexts}/useAuthContext.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename frontend/src/{hooks => contexts}/useAuthContext.tsx (100%) diff --git a/frontend/src/hooks/useAuthContext.tsx b/frontend/src/contexts/useAuthContext.tsx similarity index 100% rename from frontend/src/hooks/useAuthContext.tsx rename to frontend/src/contexts/useAuthContext.tsx From 497a04b2c72d176b9a0e85b01b4155899b847932 Mon Sep 17 00:00:00 2001 From: serasm Date: Fri, 24 Oct 2025 23:30:30 +0200 Subject: [PATCH 4/5] Changes conneted to moved useAuthContext file. The change in the location of the useAuthContext file also forced a change in the import paths. --- frontend/src/components/commonUI/GuestModeNotice.tsx | 2 +- frontend/src/components/commonUI/Navbar.tsx | 2 +- frontend/src/hooks/useAuth.ts | 2 +- frontend/src/main.tsx | 2 +- frontend/src/routes/_publicLayout/index.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/commonUI/GuestModeNotice.tsx b/frontend/src/components/commonUI/GuestModeNotice.tsx index b30f114..195edce 100644 --- a/frontend/src/components/commonUI/GuestModeNotice.tsx +++ b/frontend/src/components/commonUI/GuestModeNotice.tsx @@ -1,4 +1,4 @@ -import { useAuthContext } from '@/hooks/useAuthContext' +import { useAuthContext } from '@/contexts/useAuthContext' import { HStack, Text } from '@chakra-ui/react' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' diff --git a/frontend/src/components/commonUI/Navbar.tsx b/frontend/src/components/commonUI/Navbar.tsx index 836e4d6..4ee26ff 100644 --- a/frontend/src/components/commonUI/Navbar.tsx +++ b/frontend/src/components/commonUI/Navbar.tsx @@ -1,5 +1,5 @@ import Logo from '@/assets/Logo.svg' -import { useAuthContext } from '@/hooks/useAuthContext' +import { useAuthContext } from '@/contexts/useAuthContext' import { Flex, IconButton, Image } from '@chakra-ui/react' import { Link } from '@tanstack/react-router' import { useState } from 'react' diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 6192eba..70db273 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -1,8 +1,8 @@ +import { useAuthContext } from '@/contexts/useAuthContext' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useAuthContext } from './useAuthContext' import { toaster } from '@/components/ui/toaster' import { AxiosError } from 'axios' diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5b0e7bd..d0789c3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import './i18n' import { ColorModeProvider } from '@/components/ui/color-mode' +import { AuthProvider } from '@/contexts/useAuthContext' import { ChakraProvider } from '@chakra-ui/react' import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query' import { RouterProvider, createRouter } from '@tanstack/react-router' @@ -7,7 +8,6 @@ import { StrictMode } from 'react' import ReactDOM from 'react-dom/client' import { ApiError, OpenAPI } from './client' import AnalyticsConsent from './components/commonUI/AnalyticsConsent' -import { AuthProvider } from './hooks/useAuthContext' import { routeTree } from './routeTree.gen' import { system } from './theme' diff --git a/frontend/src/routes/_publicLayout/index.tsx b/frontend/src/routes/_publicLayout/index.tsx index 1073ae0..5634cd8 100644 --- a/frontend/src/routes/_publicLayout/index.tsx +++ b/frontend/src/routes/_publicLayout/index.tsx @@ -1,7 +1,7 @@ import { BlueButton, DefaultButton } from '@/components/commonUI/Button' import { Footer } from '@/components/commonUI/Footer' import { useColorMode } from '@/components/ui/color-mode' -import { useAuthContext } from '@/hooks/useAuthContext' +import { useAuthContext } from '@/contexts/useAuthContext' import { Container, Heading, Image, Stack, Text, VStack } from '@chakra-ui/react' import { Link, createFileRoute, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' From 05297ffebea1039542fb3d10c1248af4330708f7 Mon Sep 17 00:00:00 2001 From: serasm Date: Sat, 25 Oct 2025 17:56:52 +0200 Subject: [PATCH 5/5] Refactored error handling Changes to the errorsUtils, added mapper. Changes to useAuth to implement the errorsUtils methods. --- frontend/src/hooks/useAuth.ts | 62 +++++++++------------- frontend/src/utils/errorsUtils.ts | 87 +++++++++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 41 deletions(-) diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 70db273..ce111e2 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -4,8 +4,10 @@ import { useNavigate } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import type { ApiRequestOptions } from '@/client/core/ApiRequestOptions' import { toaster } from '@/components/ui/toaster' -import { AxiosError } from 'axios' +import { type ErrorResponse, handleError, mapToApiError } from '@/utils/errorsUtils' +import type { AxiosError } from 'axios' import { type Body_login_login_access_token as AccessToken, LoginService, @@ -14,13 +16,6 @@ import { UsersService, } from '../client' -interface ErrorResponse { - body: { - detail?: string - } - status?: number -} - const useAuth = () => { const { t } = useTranslation() const [error, setError] = useState(null) @@ -45,22 +40,19 @@ const useAuth = () => { }) }, onError: (err: Error | AxiosError | ErrorResponse) => { - const errDetail = - err instanceof AxiosError - ? err.message - : 'body' in err && typeof err.body === 'object' && err.body - ? String(err.body.detail) || t('general.errors.somethingWentWrong') - : t('general.errors.somethingWentWrong') - toaster.create({ - title: t('general.errors.errorCreatingAccount'), - description: errDetail, - type: 'error', + const request: ApiRequestOptions = { + method: 'POST', + url: '/signup', + } + const apiErrorDto = mapToApiError(err, request) + const message = handleError(apiErrorDto, { + toastTitle: t('general.errors.errorCreatingAccount'), }) - const status = (err as AxiosError).status ?? (err as ErrorResponse).status - if (status === 409) { + + if (apiErrorDto.status === 409) { setError(t('general.errors.emailAlreadyInUse') || t('general.errors.somethingWentWrong')) } else { - setError(errDetail) + setError(message) } }, onSettled: () => { @@ -81,22 +73,20 @@ const useAuth = () => { navigate({ to: '/collections' }) }, onError: (err: Error | AxiosError | ErrorResponse) => { - const errDetail = - err instanceof AxiosError - ? err.message - : 'body' in err && typeof err.body === 'object' && err.body - ? String(err.body.detail) || t('general.errors.somethingWentWrong') - : t('general.errors.somethingWentWrong') - - const finalError = Array.isArray(errDetail) - ? t('general.errors.invalidCredentials') - : errDetail - - toaster.create({ - title: t('general.errors.loginFailed'), - description: finalError, - type: 'error', + const request: ApiRequestOptions = { + method: 'POST', + url: '/login', + } + const apiErrorDto = mapToApiError(err, request) + const message = handleError(apiErrorDto, { + toastTitle: t('general.errors.loginFailed'), + fallbackMessage: t('general.errors.somethingWentWrong'), }) + + let finalError = message + if (apiErrorDto.status === 401) { + finalError = t('general.errors.invalidCredentials') + } setError(finalError) }, }) diff --git a/frontend/src/utils/errorsUtils.ts b/frontend/src/utils/errorsUtils.ts index 8ad5a31..4d8a52b 100644 --- a/frontend/src/utils/errorsUtils.ts +++ b/frontend/src/utils/errorsUtils.ts @@ -1,17 +1,94 @@ -import type { ApiError } from '@/client' +import { ApiError } from '@/client' +import type { ApiRequestOptions } from '@/client/core/ApiRequestOptions' +import type { ApiResult } from '@/client/core/ApiResult' +import { toaster } from '@/components/ui/toaster' import { translate } from '@/utils/translationUtils' +import type { AxiosError } from 'axios' + +type ToastType = 'error' | 'success' | 'info' | 'warning' export const handleError = ( err: ApiError, - showToast: (title: string, message: string, type: string) => void, -) => { + options?: { + toastTitle?: string + toastType?: ToastType + fallbackMessage?: string + silent?: boolean + }, +): string => { const errDetail = (err.body as { detail?: string | { msg: string }[] })?.detail - let errorMessage = translate('general.errors.default') + let errorMessage = options?.fallbackMessage || translate('general.errors.default') if (typeof errDetail === 'string') { errorMessage = errDetail } else if (Array.isArray(errDetail) && errDetail.length > 0) { errorMessage = errDetail[0].msg } - showToast(translate('general.errors.error'), errorMessage, 'error') + + if (!options?.silent) { + toaster.create({ + title: options?.toastTitle || translate('general.errors.error'), + description: errorMessage, + type: options?.toastType || 'error', + }) + } + + return errorMessage +} + +export interface ErrorResponse { + body: { + detail?: string + } + status?: number +} + +export function mapToApiError( + err: Error | AxiosError | ErrorResponse, + request: ApiRequestOptions, +): ApiError { + let url = '' + let status = 0 + let statusText = '' + let body: unknown = {} + let message = 'Unexpected error' + + switch (true) { + case 'isAxiosError' in err && (err as AxiosError).isAxiosError: { + const axiosErr = err as AxiosError + url = axiosErr.config?.url ?? '' + status = axiosErr.response?.status ?? 404 + statusText = axiosErr.response?.statusText ?? '' + body = axiosErr.response?.data ?? {} + message = axiosErr.message + break + } + case 'body' in err: { + const errorResponse = err as ErrorResponse + url = request.url + status = errorResponse.status ?? 404 + statusText = '' + body = errorResponse.body + message = errorResponse.body?.detail ?? 'Unknown error' + break + } + default: { + url = request.url + status = 404 + statusText = '' + body = {} + message = err.message + break + } + } + + const response: ApiResult = { + url, + status, + statusText, + body, + ok: status === 200, + } + + return new ApiError(request, response, message) }