import { useTranslation } from 'react-i18next' import axios from 'axios' import { Alert } from './alert' import { Button } from './button' import { ArrowClockwiseIcon } from '@phosphor-icons/react' /** * Friendly error renderer для TanStack Query errors. Замена паттерна * {@code {String(error)}} который раздавал * raw {@code "AxiosError: Request failed with status code 503"} в UI. * *

Маппинг status → human message: *

* *

Backend code/message парсятся если возвращены: вместо `AxiosError: ...` * показывается {@code body.message} (что backend сам положил). Это работает * только для structured 4xx responses; 5xx обычно plain html error page. * *

Retry кнопка — opt-in через {@code onRetry} prop. Caller передаёт * {@code () => query.refetch()}. */ export type QueryErrorStateProps = { /** TanStack Query error (или любая Error/unknown) */ error: unknown /** Optional retry callback — обычно `() => query.refetch()`. */ onRetry?: () => void /** Optional title override (по умолчанию по status code'у). */ title?: React.ReactNode /** className для Alert wrapper (e.g. mt-4). */ className?: string } type ErrorClassification = { titleKey: string titleFallback: string bodyKey: string bodyFallback: string /** Можно retry'нуть. False для permanent errors (404/403). */ retryable: boolean /** Backend-provided code (если был structured response). */ backendCode?: string /** Backend-provided human message (если был). */ backendMessage?: string } function classify(error: unknown): ErrorClassification { if (axios.isAxiosError(error)) { const status = error.response?.status const body = error.response?.data as | { code?: string; message?: string; error?: string } | undefined const backendCode = body?.code const backendMessage = body?.message ?? body?.error // Network error (no response, e.g. DNS, offline, CORS preflight failed). if (!error.response) { return { titleKey: 'queryError.network.title', titleFallback: 'Нет соединения с сервером', bodyKey: 'queryError.network.body', bodyFallback: 'Проверьте сеть. Когда соединение восстановится — повторите.', retryable: true, } } if (status === 503 || status === 502 || status === 504) { return { titleKey: 'queryError.unavailable.title', titleFallback: 'Сервер временно недоступен', bodyKey: 'queryError.unavailable.body', bodyFallback: 'Возможно идёт обновление или плановое обслуживание. Подождите несколько секунд и повторите.', retryable: true, } } if (status === 500) { return { titleKey: 'queryError.server.title', titleFallback: 'Внутренняя ошибка сервера', bodyKey: 'queryError.server.body', bodyFallback: 'Что-то пошло не так на стороне сервера. Если ошибка повторяется — сообщите администратору.', retryable: true, backendCode, backendMessage, } } if (status === 404) { return { titleKey: 'queryError.notFound.title', titleFallback: 'Не найдено', bodyKey: 'queryError.notFound.body', bodyFallback: 'Запрашиваемые данные не существуют или были удалены.', retryable: false, backendCode, backendMessage, } } if (status === 403) { return { titleKey: 'queryError.forbidden.title', titleFallback: 'Нет доступа', bodyKey: 'queryError.forbidden.body', bodyFallback: 'У вас нет прав на просмотр этих данных.', retryable: false, backendCode, backendMessage, } } if (status === 401) { return { titleKey: 'queryError.unauthorized.title', titleFallback: 'Требуется вход', bodyKey: 'queryError.unauthorized.body', bodyFallback: 'Сессия истекла. Войдите снова чтобы продолжить.', retryable: false, } } if (status && status >= 400 && status < 500) { return { titleKey: 'queryError.client.title', titleFallback: 'Запрос некорректен', bodyKey: 'queryError.client.body', bodyFallback: backendMessage ?? 'Сервер отклонил запрос.', retryable: false, backendCode, backendMessage, } } } // Non-axios error: Error instance or unknown const msg = error instanceof Error ? error.message : typeof error === 'string' ? error : 'unknown error' return { titleKey: 'queryError.generic.title', titleFallback: 'Не удалось загрузить данные', bodyKey: 'queryError.generic.body', bodyFallback: msg, retryable: true, } } export function QueryErrorState({ error, onRetry, title, className, }: QueryErrorStateProps) { const { t } = useTranslation() const c = classify(error) // Backend message приоритетней generic — если backend сказал что-то // понятное в body.message, покажем это. Иначе fallback. const body = c.backendMessage ? c.backendMessage : t(c.bodyKey, { defaultValue: c.bodyFallback }) // Alert.title типизирован как HTML title (string) из-за HTMLAttributes // intersection с custom React.ReactNode. Stringify чтобы TS не ругался — // в practice title всегда plain string label. const titleStr = typeof title === 'string' ? title : String(title ?? t(c.titleKey, { defaultValue: c.titleFallback })) return ( } > {t('queryError.retry', { defaultValue: 'Повторить' })} ) : undefined } >

{body}
{c.backendCode && (
code: {c.backendCode}
)}
) }