Restyle toasts and centralize friendly error handling.

Replace the default react-toastify look with project-styled toast cards
(icon chip, rounded shell, RTL-aware container, type-colored progress
bar) via a new notify helper and globals.css overrides.

Add a central error layer (src/lib/errors.ts): classify any caught error
by HTTP status / network condition, log the full technical detail
(including the raw backend message) to the console only, and surface a
friendly, localized message to the user. Raw backend messages are no
longer shown. All ~190 toast call sites across 22 files move to notify,
routing backend errors through notify.error(err, fallback); dead
apiErrorMessage/formatApiError helpers removed. Adds an `errors` section
to the fa/en dictionaries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-13 11:45:26 +03:30
parent 97cd5e989a
commit 91a66d5645
29 changed files with 501 additions and 250 deletions
+116
View File
@@ -0,0 +1,116 @@
import axios from 'axios';
import type { Dictionary } from '@/i18n/dictionaries/fa';
/** Coarse buckets we can map to a friendly, localized message. */
export type ApiErrorKind =
| 'network'
| 'timeout'
| 'unauthorized'
| 'forbidden'
| 'notFound'
| 'conflict'
| 'validation'
| 'rateLimit'
| 'server'
| 'generic';
export interface ClassifiedError {
kind: ApiErrorKind;
/** HTTP status when the request reached the server, otherwise undefined. */
status?: number;
/** Raw backend message(s) — for logging only, never shown to the user. */
backendMessage?: string;
}
/** Flattens NestJS-style `message: string | string[]` into one string. */
function readBackendMessage(data: unknown): string | undefined {
if (!data || typeof data !== 'object') return undefined;
const msg = (data as { message?: unknown; error?: unknown }).message ?? (data as { error?: unknown }).error;
if (Array.isArray(msg)) return msg.filter(Boolean).join(' · ');
if (typeof msg === 'string' && msg.trim()) return msg.trim();
return undefined;
}
/**
* Maps any thrown value (axios error, Error, unknown) to a coarse {@link ApiErrorKind}.
* Pure — does not log or surface anything. Backend text is captured for logs only.
*/
export function classifyApiError(err: unknown): ClassifiedError {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
const backendMessage = readBackendMessage(err.response?.data);
if (!err.response) {
const kind: ApiErrorKind = err.code === 'ECONNABORTED' ? 'timeout' : 'network';
return { kind, backendMessage };
}
let kind: ApiErrorKind = 'generic';
if (status === 401) kind = 'unauthorized';
else if (status === 403) kind = 'forbidden';
else if (status === 404) kind = 'notFound';
else if (status === 409) kind = 'conflict';
else if (status === 422 || status === 400) kind = 'validation';
else if (status === 429) kind = 'rateLimit';
else if (status !== undefined && status >= 500) kind = 'server';
return { kind, status, backendMessage };
}
return { kind: 'generic' };
}
/**
* Logs the full technical detail of an error to the console (dev tools only).
* This is the single place raw backend text is allowed to appear.
*/
export function logApiError(context: string, err: unknown, classified?: ClassifiedError): void {
const c = classified ?? classifyApiError(err);
// eslint-disable-next-line no-console
console.error(
`[${context}] ${c.kind}${c.status ? ` (${c.status})` : ''}`,
c.backendMessage ? `${c.backendMessage}` : '',
err,
);
}
/**
* Resolves a user-facing, localized message for an error. The raw backend
* message is never returned; callers may pass a context-specific `fallback`
* (already localized) that wins over the generic per-kind copy.
*/
export function friendlyErrorMessage(
err: unknown,
dict: Dictionary,
fallback?: string,
): string {
const { kind } = classifyApiError(err);
const e = dict.errors;
// Infrastructure-level problems are never described better by a page-specific
// fallback, so their copy always wins.
if (kind === 'network') return e.network;
if (kind === 'timeout') return e.timeout;
if (kind === 'rateLimit') return e.rateLimit;
if (kind === 'server') return e.server;
// For request-shaped problems (401/403/404/409/422), the caller's contextual
// message is usually the most helpful (e.g. "Invalid email or password" on the
// login form rather than a generic "session expired").
if (fallback) return fallback;
switch (kind) {
case 'unauthorized':
return e.unauthorized;
case 'forbidden':
return e.forbidden;
case 'notFound':
return e.notFound;
case 'conflict':
return e.conflict;
case 'validation':
return e.validation;
default:
return e.generic;
}
}
+70
View File
@@ -0,0 +1,70 @@
'use client';
import { toast, type ToastOptions } from 'react-toastify';
import { CheckCircle2, XCircle, AlertTriangle, Info } from 'lucide-react';
import type { ReactNode } from 'react';
import type { Dictionary } from '@/i18n/dictionaries/fa';
import { classifyApiError, friendlyErrorMessage, logApiError } from '@/lib/errors';
type Variant = 'success' | 'error' | 'warning' | 'info';
const VARIANTS: Record<Variant, { icon: ReactNode; ring: string }> = {
success: { icon: <CheckCircle2 className="w-5 h-5 text-emerald-500" />, ring: 'bg-emerald-50' },
error: { icon: <XCircle className="w-5 h-5 text-red-500" />, ring: 'bg-red-50' },
warning: { icon: <AlertTriangle className="w-5 h-5 text-amber-500" />, ring: 'bg-amber-50' },
info: { icon: <Info className="w-5 h-5 text-primary-500" />, ring: 'bg-primary-50' },
};
/**
* The active dictionary, registered by I18nProvider. Lets the standalone
* `notify.error` resolve a localized friendly message without every call site
* having to thread the dictionary through a hook.
*/
let activeDict: Dictionary | null = null;
export function setNotifyDict(dict: Dictionary) {
activeDict = dict;
}
/** Custom toast body styled to match the dashboard (card, icon chip, project font). */
function ToastBody({ variant, message }: { variant: Variant; message: ReactNode }) {
const v = VARIANTS[variant];
return (
<div className="flex items-center gap-3">
<span className={`shrink-0 flex items-center justify-center w-9 h-9 rounded-xl ${v.ring}`}>
{v.icon}
</span>
<p className="text-sm font-medium text-gray-800 leading-snug">{message}</p>
</div>
);
}
function show(variant: Variant, message: ReactNode, options?: ToastOptions) {
return toast(<ToastBody variant={variant} message={message} />, { type: variant, ...options });
}
/**
* Project-styled toast helpers. The visual shell (rounded card, shadow, RTL,
* progress bar color) lives in globals.css under the `.Toastify__*` overrides.
*
* `error()` is the important one for error handling: pass the raw caught error
* and an optional localized `fallback`. The technical detail is logged to the
* console only; the user sees a friendly, localized message — the raw backend
* message is never surfaced. Passing a string shows it directly (for the rare
* case where the caller already has a final, user-ready string).
*/
export const notify = {
success: (message: ReactNode, options?: ToastOptions) => show('success', message, options),
warning: (message: ReactNode, options?: ToastOptions) => show('warning', message, options),
info: (message: ReactNode, options?: ToastOptions) => show('info', message, options),
error: (errOrMessage: unknown, fallback?: string, context = 'request') => {
if (typeof errOrMessage === 'string') {
return show('error', errOrMessage);
}
const classified = classifyApiError(errOrMessage);
logApiError(context, errOrMessage, classified);
const message = activeDict
? friendlyErrorMessage(errOrMessage, activeDict, fallback)
: fallback ?? 'Something went wrong. Please try again.';
return show('error', message);
},
};
+4 -4
View File
@@ -3,7 +3,7 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import { notify } from '@/lib/notify';
type DeleteResponse = { resourceCredit?: unknown };
@@ -34,14 +34,14 @@ export function useApplicationDelete(options?: {
if (options?.onSuccess) {
options.onSuccess(data, id);
} else if (data?.resourceCredit && options?.successWithCreditMessage) {
toast.success(options.successWithCreditMessage);
notify.success(options.successWithCreditMessage);
} else {
toast.success(options?.successMessage ?? 'Deleted successfully');
notify.success(options?.successMessage ?? 'Deleted successfully');
}
},
onError: () => {
if (options?.onError) options.onError();
else toast.error('Failed to delete');
else notify.error('Failed to delete');
},
});