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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user