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/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/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/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 diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 6192eba..ce111e2 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -1,11 +1,13 @@ +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 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/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/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' 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, 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..4d8a52b --- /dev/null +++ b/frontend/src/utils/errorsUtils.ts @@ -0,0 +1,94 @@ +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, + options?: { + toastTitle?: string + toastType?: ToastType + fallbackMessage?: string + silent?: boolean + }, +): string => { + const errDetail = (err.body as { detail?: string | { msg: string }[] })?.detail + 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 + } + + 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) +} 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) +})