From 42082faf0d8f97a5a1d442ad836e162e09b96c93 Mon Sep 17 00:00:00 2001 From: inmake Date: Tue, 13 Jan 2026 16:16:48 +0500 Subject: [PATCH] Enhance API utility with user region detection and error handling. Integrate region headers into API requests across components, improving localization and error management. Update package dependencies for better compatibility. --- bun.lock | 10 +- package.json | 2 + src/App.tsx | 61 +++--- src/CalendarPage.tsx | 17 +- src/HistoryPage.tsx | 26 ++- src/PersonalAreaLoginPage.tsx | 18 +- src/components/modals/AFKTimerModal.tsx | 4 +- src/hooks/useLanguageDetection.ts | 9 +- src/i18n.ts | 22 ++ src/pages/StreamPage.tsx | 27 ++- src/types/ErrorTypes.ts | 23 +++ src/utils/api.ts | 143 ++++++++++++- src/utils/errorHandler.ts | 261 ++++++++++++++++++++++++ 13 files changed, 570 insertions(+), 53 deletions(-) create mode 100644 src/types/ErrorTypes.ts create mode 100644 src/utils/errorHandler.ts diff --git a/bun.lock b/bun.lock index c5dfbf9..33695e4 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,8 @@ "@epicgames-ps/lib-pixelstreamingfrontend-ue5.3": "^1.0.8", "@uidotdev/usehooks": "^2.4.1", "ahooks": "^3.7.10", + "baseline-browser-mapping": "^2.9.14", + "caniuse-lite": "^1.0.30001764", "date-fns": "^2.30.0", "i18next": "^23.8.2", "i18next-browser-languagedetector": "^8.2.0", @@ -253,6 +255,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.14", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -265,7 +269,7 @@ "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], + "caniuse-lite": ["caniuse-lite@1.0.30001764", "", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -781,6 +785,10 @@ "@typescript-eslint/utils/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + "autoprefixer/caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], + + "browserslist/caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], diff --git a/package.json b/package.json index 530f2a3..790b8af 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "@epicgames-ps/lib-pixelstreamingfrontend-ue5.3": "^1.0.8", "@uidotdev/usehooks": "^2.4.1", "ahooks": "^3.7.10", + "baseline-browser-mapping": "^2.9.14", + "caniuse-lite": "^1.0.30001764", "date-fns": "^2.30.0", "i18next": "^23.8.2", "i18next-browser-languagedetector": "^8.2.0", diff --git a/src/App.tsx b/src/App.tsx index 0d48b47..ee8d6e3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,8 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { Bounce, ToastContainer, toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import InfoIcon from "./components/icons/InfoIcon"; +import { detectUserRegion, getRegionHeaders } from "./utils/api"; +import { handleApiError, isErrorResponse } from "./utils/errorHandler"; function App() { const navigate = useNavigate(); @@ -36,6 +38,7 @@ function App() { const type = searchParams.get("type") || "demo"; const endAt = searchParams.get("endAt"); const [streamUrl, setStreamUrl] = useState(); + const [regionDetected, setRegionDetected] = useState(false); function toastError(text: string) { toast.error(text, { @@ -54,59 +57,39 @@ function App() { async function startStream(build: string) { - // const { countryCode, error }: { countryCode: string; error: string } = - // await api.get("getCountryCode").json(); - - // if (!countryCode) { - // toastError("Неизвестная ошибка при получении кода страны"); - // return; - // } - - // if (error) { - // toastError(error); - // return; - // } - let location = "a1"; if (searchParams.has("location")) { location = searchParams.get("location") as string; } - // else if (countryCode !== "RU") { - // location = "a2"; - // } - - // console.log("location", location); - - // setLoading(true); try { const response: any = await ky .get( `${ import.meta.env.VITE_COORD_URL - }/start?location=${location}&build=${build}&type=${type}&endAt=${endAt}` + }/start?location=${location}&build=${build}&type=${type}&endAt=${endAt}`, + { headers: getRegionHeaders() } ) .json(); + // Проверяем, является ли ответ ошибкой + if (isErrorResponse(response)) { + handleApiError(response, t, navigate); + return; + } + if (response.stream) { setStreamUrl(`/stream/${response.stream}`); - - // setInterval(() => { - // setCountdownTimer((prev) => prev - 1); - // }, 1000); } else if (response.error) { toastError(response.error); - // setLoading(false); } else { toastError(t("errors.unknownError")); - // setLoading(false); } } catch (error) { if (error instanceof Error) { - toastError(`Неизвестная ошибка 2: ${error.message}`); + toastError(t("errors.networkError") + `: ${error.message}`); } - // setLoading(false); } } @@ -121,11 +104,27 @@ function App() { navigate(streamUrl); }, [streamUrl]); + // Определяем регион пользователя при первой загрузке useEffect(() => { - if (build) { + async function initializeRegion() { + try { + await detectUserRegion(); + setRegionDetected(true); + } catch (error) { + console.error("Failed to detect user region:", error); + // Даже при ошибке продолжаем работу с дефолтным регионом + setRegionDetected(true); + } + } + + void initializeRegion(); + }, []); + + useEffect(() => { + if (build && regionDetected) { void startStream(build); } - }, []); + }, [regionDetected]); useEffect(() => { document.title = t("title"); diff --git a/src/CalendarPage.tsx b/src/CalendarPage.tsx index eba826e..ac319b3 100644 --- a/src/CalendarPage.tsx +++ b/src/CalendarPage.tsx @@ -18,11 +18,16 @@ import { } from "date-fns"; import ru from "date-fns/locale/ru"; import ky from "ky"; -import { useParams } from "react-router-dom"; +import { getRegionHeaders } from "./utils/api"; +import { handleApiError, isErrorResponse } from "./utils/errorHandler"; +import { useParams, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import InputMask from "react-input-mask"; function CalendarPage() { const params = useParams(); + const { t } = useTranslation(); + const navigate = useNavigate(); const [step, setStep] = useState(1); const [date, setDate] = useState(new Date()); const [name, setName] = useState(""); @@ -42,7 +47,8 @@ function CalendarPage() { async function getScheduledSessions(buildId: string, date: Date) { const result: any[] = await ky .get( - `https://crm.stream.graff.tech/api/scheduledSessions/${buildId}?date=${date.toISOString()}` + `https://crm.stream.graff.tech/api/scheduledSessions/${buildId}?date=${date.toISOString()}`, + { headers: getRegionHeaders() } ) .json(); @@ -78,9 +84,16 @@ function CalendarPage() { title, startAt, }, + headers: getRegionHeaders(), }) .json(); + // Проверяем, является ли ответ ошибкой + if (isErrorResponse(result)) { + handleApiError(result, t, navigate); + return; + } + if (!result.userInviteLink) { alert(result.error); return; diff --git a/src/HistoryPage.tsx b/src/HistoryPage.tsx index 9ff09e8..ce6f2f4 100644 --- a/src/HistoryPage.tsx +++ b/src/HistoryPage.tsx @@ -1,17 +1,35 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import ky from "ky"; +import { getRegionHeaders } from "./utils/api"; +import { handleApiError, isErrorResponse } from "./utils/errorHandler"; import { useEffect, useState } from "react"; import { parseUserAgent } from "react-device-detect"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; function HistoryPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); const [history, setHistory] = useState([]); async function getHistory() { - const result: any = await ky - .get(`${import.meta.env.VITE_COORD_URL}/session_history`) - .json(); + try { + const result: any = await ky + .get(`${import.meta.env.VITE_COORD_URL}/session_history`, { + headers: getRegionHeaders(), + }) + .json(); - setHistory(result); + // Проверяем, является ли ответ ошибкой + if (isErrorResponse(result)) { + handleApiError(result, t, navigate); + return; + } + + setHistory(result); + } catch (error) { + console.error("Failed to fetch history:", error); + } } useEffect(() => { diff --git a/src/PersonalAreaLoginPage.tsx b/src/PersonalAreaLoginPage.tsx index 09c3dae..5462778 100644 --- a/src/PersonalAreaLoginPage.tsx +++ b/src/PersonalAreaLoginPage.tsx @@ -1,8 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import ky from "ky"; +import { getRegionHeaders } from "./utils/api"; +import { handleApiError, isErrorResponse } from "./utils/errorHandler"; import useAuthStore from "./stores/useAuthStore"; import { FormEvent, useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; type User = { id: string; @@ -17,6 +20,7 @@ interface IResult { function PersonalAreaLoginPage() { const { t } = useTranslation(); + const navigate = useNavigate(); const [setAccessToken, setUser] = useAuthStore((state) => [ state.setAccessToken, state.setUser, @@ -38,14 +42,26 @@ function PersonalAreaLoginPage() { const result: IResult = await ky .post(import.meta.env.VITE_COORD_URL + "/login", { json: { username, password }, + headers: getRegionHeaders(), }) .json(); setIsLoading(false); + // Проверяем, является ли ответ ошибкой с errorCode + if (isErrorResponse(result)) { + handleApiError(result, t, navigate, { + callback: (_, errorMessage) => { + passwordRef.current?.focus(); + setPassword(""); + setError(errorMessage); + }, + }); + return; + } + if (result.error) { passwordRef.current?.focus(); - setPassword(""); setError(t("errors.invalidCredentials")); return; diff --git a/src/components/modals/AFKTimerModal.tsx b/src/components/modals/AFKTimerModal.tsx index 0d748bb..bb904b4 100644 --- a/src/components/modals/AFKTimerModal.tsx +++ b/src/components/modals/AFKTimerModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import useModalStore from "../../stores/useModalStore"; import ky from "ky"; +import { getRegionHeaders } from "../../utils/api"; import { useParams } from "react-router-dom"; function AFKTimerModal() { @@ -13,7 +14,8 @@ function AFKTimerModal() { async function endSession() { await ky.post( - `${import.meta.env.VITE_COORD_URL}/active_sessions/${params.id}/end` + `${import.meta.env.VITE_COORD_URL}/active_sessions/${params.id}/end`, + { headers: getRegionHeaders() } ); } diff --git a/src/hooks/useLanguageDetection.ts b/src/hooks/useLanguageDetection.ts index 74d6bed..fe8ad00 100644 --- a/src/hooks/useLanguageDetection.ts +++ b/src/hooks/useLanguageDetection.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import api from "../utils/api"; +import { detectUserRegion } from "../utils/api"; export function useLanguageDetection() { const { i18n } = useTranslation(); @@ -8,12 +8,11 @@ export function useLanguageDetection() { useEffect(() => { async function detectLanguage() { try { - const { countryCode, error }: { countryCode: string; error: string } = - await api.get("getCountryCode").json(); + const countryCode = await detectUserRegion(); - if (!error && countryCode && countryCode !== "RU") { + if (countryCode && countryCode !== "RU") { await i18n.changeLanguage("en"); - } else if (!error && countryCode === "RU") { + } else if (countryCode === "RU") { await i18n.changeLanguage("ru"); } } catch (error) { diff --git a/src/i18n.ts b/src/i18n.ts index 553df44..975f343 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -170,6 +170,17 @@ const resources = { invalidCredentials: "Неверное имя пользователя или пароль", failedToFetchData: "Не удалось получить данные", noConnection: "Нет соединения с сервером, попробуйте позже", + networkError: "Ошибка сети", + // Error codes from server + INTERNAL_ERROR: "Внутренняя ошибка сервера", + INVALID_OBJECT_ID: "Некорректный идентификатор объекта", + SESSION_NOT_FOUND: "Сессия не найдена. Возвращаемся на главную страницу...", + SESSION_FETCH_ERROR: "Ошибка при получении данных сессии", + IP_ADDRESS_ERROR: "Не удалось определить IP-адрес", + COUNTRY_CODE_FETCH_ERROR: "Ошибка при определении страны", + EMAIL_REQUIRED: "Необходимо указать email", + LINK_REQUIRED: "Необходимо указать ссылку", + EMAIL_SEND_ERROR: "Не удалось отправить письмо", }, userActions: { transferControl: "Передать управление", @@ -387,6 +398,17 @@ const resources = { invalidCredentials: "Invalid username or password", failedToFetchData: "Failed to fetch data", noConnection: "No connection to server, please try again later", + networkError: "Network error", + // Error codes from server + INTERNAL_ERROR: "Internal server error", + INVALID_OBJECT_ID: "Invalid object ID", + SESSION_NOT_FOUND: "Session not found. Returning to home page...", + SESSION_FETCH_ERROR: "Error fetching session data", + IP_ADDRESS_ERROR: "Unable to determine IP address", + COUNTRY_CODE_FETCH_ERROR: "Error determining country", + EMAIL_REQUIRED: "Email is required", + LINK_REQUIRED: "Link is required", + EMAIL_SEND_ERROR: "Failed to send email", }, userActions: { transferControl: "Transfer control", diff --git a/src/pages/StreamPage.tsx b/src/pages/StreamPage.tsx index 2e8425b..1155129 100644 --- a/src/pages/StreamPage.tsx +++ b/src/pages/StreamPage.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { PixelStreamingWrapper2 } from "../components/PixelStreamingWrapper2"; import api from "../utils/api"; +import { isErrorResponse } from "../utils/errorHandler"; import { useParams, useSearchParams } from "react-router-dom"; import useStateRef from "react-usestateref"; import Peer from "peerjs"; @@ -322,15 +323,27 @@ function StreamPage() { } async function getActiveSession() { - const activeSession: any = await api - .get(`activeSessions/${params.id}`) - .json(); + try { + const activeSession: any = await api + .get(`activeSessions/${params.id}`) + .json(); - if (activeSession?.endAt) { - setEndAt(activeSession.endAt); + // Проверяем, является ли ответ ошибкой + if (isErrorResponse(activeSession)) { + // Для SESSION_NOT_FOUND не показываем toast и не делаем редирект + // Просто возвращаем null, чтобы показать сообщение о завершении + return null; + } + + if (activeSession?.endAt) { + setEndAt(activeSession.endAt); + } + + return activeSession; + } catch (error) { + console.error("Failed to fetch active session:", error); + return null; } - - return activeSession; } async function checkSessionStatus() { diff --git a/src/types/ErrorTypes.ts b/src/types/ErrorTypes.ts new file mode 100644 index 0000000..97b8f65 --- /dev/null +++ b/src/types/ErrorTypes.ts @@ -0,0 +1,23 @@ +export enum ErrorCode { + // General errors + INTERNAL_ERROR = "INTERNAL_ERROR", + + // Active Session errors + INVALID_OBJECT_ID = "INVALID_OBJECT_ID", + SESSION_NOT_FOUND = "SESSION_NOT_FOUND", + SESSION_FETCH_ERROR = "SESSION_FETCH_ERROR", + + // Country Code errors + IP_ADDRESS_ERROR = "IP_ADDRESS_ERROR", + COUNTRY_CODE_FETCH_ERROR = "COUNTRY_CODE_FETCH_ERROR", + + // Email/Invite errors + EMAIL_REQUIRED = "EMAIL_REQUIRED", + LINK_REQUIRED = "LINK_REQUIRED", + EMAIL_SEND_ERROR = "EMAIL_SEND_ERROR", +} + +export interface ErrorResponse { + error: string; + errorCode: ErrorCode; +} diff --git a/src/utils/api.ts b/src/utils/api.ts index 74ccfa8..f693ee0 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -1,7 +1,148 @@ import ky from "ky"; -const api = ky.extend({ +/** + * API Utility с автоматическим управлением регионом пользователя + * + * Как это работает: + * 1. При первом запросе определяется регион пользователя через detectUserRegion() + * 2. Регион сохраняется в state (userRegion) + * 3. Все последующие запросы через api автоматически включают заголовок X-User-Region + * 4. Сервер возвращает ошибки на нужном языке в зависимости от региона + * 5. Ошибки с сервера содержат errorCode и обрабатываются через errorHandler + * + * Пример использования: + * + * // В App.tsx или главном компоненте при инициализации: + * useEffect(() => { + * async function init() { + * try { + * const region = await detectUserRegion(); + * console.log('User region:', region); // RU, EN, NL и т.д. + * } catch (error) { + * console.error('Failed to detect region:', error); + * } + * } + * init(); + * }, []); + * + * // Во всех остальных местах просто используйте api с обработкой ошибок: + * import api from './utils/api'; + * import { handleApiError, isErrorResponse } from './utils/errorHandler'; + * import { useTranslation } from 'react-i18next'; + * + * const { t } = useTranslation(); + * const response = await api.get('endpoint').json(); + * + * // Проверяем и обрабатываем ошибки + * if (isErrorResponse(response)) { + * handleApiError(response, t); + * return; + * } + * + * // Работаем с успешным ответом + * console.log(response); + * + * // Или используйте safeApiCall для автоматической обработки: + * import { safeApiCall } from './utils/errorHandler'; + * + * const data = await safeApiCall( + * () => api.get('endpoint').json(), + * t, + * (response) => console.log('Success:', response) + * ); + */ + +// Хранилище для региона пользователя +let userRegion: string | null = null; + +// Базовый API клиент +const baseApi = ky.extend({ prefixUrl: import.meta.env.VITE_API_URL, + hooks: { + beforeRequest: [ + (request) => { + // Добавляем регион в заголовки, если он уже определен + if (userRegion) { + request.headers.set("X-User-Region", userRegion); + } + }, + ], + }, }); +/** + * Получает регион пользователя с сервера + * @returns Код региона (RU, EN, NL и т.д.) + */ +export async function detectUserRegion(): Promise { + try { + const response = await baseApi.get("getCountryCode").json<{ + countryCode: string; + error?: string; + }>(); + + if (response.error) { + throw new Error(response.error); + } + + if (response.countryCode) { + // Сохраняем регион в state + userRegion = response.countryCode; + return response.countryCode; + } + + throw new Error("Country code not received"); + } catch (error) { + // В случае ошибки используем дефолтное значение + userRegion = "EN"; + throw error; + } +} + +/** + * Получает текущий регион пользователя + * @returns Код региона или null, если еще не определен + */ +export function getUserRegion(): string | null { + return userRegion; +} + +/** + * Устанавливает регион пользователя вручную + * @param region - Код региона + */ +export function setUserRegion(region: string): void { + userRegion = region; +} + +/** + * Сбрасывает регион пользователя + */ +export function resetUserRegion(): void { + userRegion = null; +} + +/** + * Создает объект заголовков с регионом пользователя для внешних запросов + * Используйте эту функцию для запросов, которые не используют базовый api клиент + * (например, для VITE_COORD_URL) + * + * @returns Объект с заголовками, включая X-User-Region если регион определен + * + * @example + * const headers = getRegionHeaders(); + * await ky.get(url, { headers }).json(); + */ +export function getRegionHeaders(): Record { + const headers: Record = {}; + + if (userRegion) { + headers["X-User-Region"] = userRegion; + } + + return headers; +} + +const api = baseApi; + export default api; diff --git a/src/utils/errorHandler.ts b/src/utils/errorHandler.ts new file mode 100644 index 0000000..c35c036 --- /dev/null +++ b/src/utils/errorHandler.ts @@ -0,0 +1,261 @@ +import { TFunction } from "i18next"; +import { toast, Bounce } from "react-toastify"; +import { ErrorCode, ErrorResponse } from "../types/ErrorTypes"; + +/** + * Типы действий при ошибке + */ +export enum ErrorAction { + TOAST = "TOAST", // Показать toast уведомление + REDIRECT = "REDIRECT", // Перенаправить на другую страницу + MODAL = "MODAL", // Показать модальное окно (для будущего использования) + CALLBACK = "CALLBACK", // Вызвать кастомный callback +} + +/** + * Конфигурация обработки ошибки + */ +interface ErrorHandlerConfig { + action: ErrorAction; + redirectUrl?: string; + callback?: (errorCode: ErrorCode, errorMessage: string) => void; + toastType?: "error" | "warning" | "info"; + navigate?: (path: string) => void; +} + +/** + * Маппинг кодов ошибок на действия + */ +const errorActionMap: Record = { + // General errors - показываем toast + [ErrorCode.INTERNAL_ERROR]: { + action: ErrorAction.TOAST, + toastType: "error", + }, + + // Active Session errors + [ErrorCode.INVALID_OBJECT_ID]: { + action: ErrorAction.TOAST, + toastType: "error", + }, + [ErrorCode.SESSION_NOT_FOUND]: { + action: ErrorAction.CALLBACK, + // Не показываем toast и не делаем редирект + // Компонент сам решит, что делать с этой ошибкой + }, + [ErrorCode.SESSION_FETCH_ERROR]: { + action: ErrorAction.TOAST, + toastType: "error", + }, + + // Country Code errors + [ErrorCode.IP_ADDRESS_ERROR]: { + action: ErrorAction.TOAST, + toastType: "warning", + }, + [ErrorCode.COUNTRY_CODE_FETCH_ERROR]: { + action: ErrorAction.TOAST, + toastType: "warning", + }, + + // Email/Invite errors + [ErrorCode.EMAIL_REQUIRED]: { + action: ErrorAction.TOAST, + toastType: "error", + }, + [ErrorCode.LINK_REQUIRED]: { + action: ErrorAction.TOAST, + toastType: "error", + }, + [ErrorCode.EMAIL_SEND_ERROR]: { + action: ErrorAction.TOAST, + toastType: "error", + }, +}; + +/** + * Показывает toast уведомление + */ +function showToast( + message: string, + type: "error" | "warning" | "info" = "error" +) { + const toastConfig = { + position: "top-center" as const, + autoClose: 5000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + theme: "light" as const, + transition: Bounce, + }; + + switch (type) { + case "error": + toast.error(message, toastConfig); + break; + case "warning": + toast.warning(message, toastConfig); + break; + case "info": + toast.info(message, toastConfig); + break; + } +} + +/** + * Основная функция обработки ошибок + * + * @param response - Ответ от сервера с ошибкой + * @param t - Функция перевода из i18next (необязательно, если сообщение уже переведено) + * @param navigate - Функция навигации из useNavigate (для редиректов) + * @param customConfig - Кастомная конфигурация обработки (переопределяет дефолтную) + * + * @example + * // Базовое использование + * const { t } = useTranslation(); + * const navigate = useNavigate(); + * const response = await api.get('endpoint').json(); + * if (response.errorCode) { + * handleApiError(response, t, navigate); + * } + * + * @example + * // С кастомной обработкой + * handleApiError(response, t, navigate, { + * action: ErrorAction.CALLBACK, + * callback: (code, message) => { + * console.log('Custom error handling', code, message); + * } + * }); + */ +export function handleApiError( + response: ErrorResponse | { error: string; errorCode?: ErrorCode }, + t?: TFunction, + navigate?: (path: string) => void, + customConfig?: Partial +): void { + const errorCode = response.errorCode; + const errorMessage = response.error; + + // Если нет кода ошибки, просто показываем сообщение + if (!errorCode) { + showToast(errorMessage, "error"); + return; + } + + // Получаем конфигурацию для данного кода ошибки + const defaultConfig = errorActionMap[errorCode]; + const config = { ...defaultConfig, ...customConfig, navigate }; + + // Переводим сообщение, если передана функция перевода и есть ключ + const translatedMessage = t && errorCode + ? t(`errors.${errorCode}`, { defaultValue: errorMessage }) + : errorMessage; + + // Выполняем действие в зависимости от конфигурации + switch (config.action) { + case ErrorAction.TOAST: + showToast(translatedMessage, config.toastType || "error"); + break; + + case ErrorAction.REDIRECT: + if (config.redirectUrl) { + showToast(translatedMessage, "error"); + const redirectUrl = config.redirectUrl; + setTimeout(() => { + // Используем React Router navigate если передан, иначе window.location + if (config.navigate) { + config.navigate(redirectUrl); + } else { + window.location.href = redirectUrl; + } + }, 2000); + } + break; + + case ErrorAction.CALLBACK: + if (config.callback) { + config.callback(errorCode, translatedMessage); + } + break; + + case ErrorAction.MODAL: + // Для будущей реализации модальных окон + console.warn("MODAL action not implemented yet, showing toast instead"); + showToast(translatedMessage, config.toastType || "error"); + break; + + default: + showToast(translatedMessage, "error"); + } + + // Логируем ошибку для отладки + console.error(`API Error [${errorCode}]:`, translatedMessage); +} + +/** + * Проверяет, является ли ответ ошибкой + */ +export function isErrorResponse( + response: unknown +): response is ErrorResponse { + return ( + typeof response === "object" && + response !== null && + "errorCode" in response + ); +} + +/** + * Обертка для безопасного выполнения API запросов с автоматической обработкой ошибок + * + * @example + * const navigate = useNavigate(); + * const result = await safeApiCall( + * () => api.get('endpoint').json(), + * t, + * navigate, + * (data) => { + * console.log('Success:', data); + * } + * ); + */ +export async function safeApiCall( + apiCall: () => Promise, + t?: TFunction, + navigate?: (path: string) => void, + onSuccess?: (data: T) => void, + onError?: (error: ErrorResponse) => void +): Promise { + try { + const response = await apiCall(); + + // Проверяем, является ли ответ ошибкой + if (isErrorResponse(response)) { + handleApiError(response, t, navigate); + if (onError) { + onError(response); + } + return null; + } + + // Вызываем callback успеха, если он передан + if (onSuccess) { + onSuccess(response); + } + + return response; + } catch (error) { + // Обрабатываем сетевые ошибки + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + showToast( + t ? t("errors.networkError", { defaultValue: errorMessage }) : errorMessage, + "error" + ); + console.error("API Call Error:", error); + return null; + } +}