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
Маппинг 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 (