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
@@ -3,7 +3,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { Loader2, Upload, Hammer, Rocket, CheckCircle, XCircle, X, Minimize2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { notify } from '@/lib/notify';
import { useT } from '@/i18n/I18nProvider';
import type { Dictionary } from '@/i18n/dictionaries/fa';
@@ -54,13 +54,13 @@ export function BuildProgressModal({
const cancelMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/cancel`),
onSuccess: () => {
toast.success(c.deploymentCancelled);
notify.success(c.deploymentCancelled);
queryClient.invalidateQueries({ queryKey: ['application', appId] });
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
queryClient.invalidateQueries({ queryKey: ['build-progress', appId] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
},
onError: () => toast.error(c.cancelDeploymentFailed),
onError: () => notify.error(c.cancelDeploymentFailed),
});
const cfg = phaseConfig[progress.phase];
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import { notify } from '@/lib/notify';
import { useT, useLocale } from '@/i18n/I18nProvider';
import type { AppSnapshot } from '@/types';
import { formatBytes } from '@/lib/format-utils';
@@ -87,10 +87,10 @@ export function DatabaseSnapshotsPanel({
snap.status === 'completed' &&
snap.dbDumpPath
) {
toast.success(sn.backupReady);
notify.success(sn.backupReady);
}
if (prevInProgressRef.current.has(snap.id) && snap.status === 'failed') {
toast.error(snap.errorMessage || sn.backupFailed);
notify.error(sn.backupFailed);
}
}
prevInProgressRef.current = inProgressIds;
@@ -104,13 +104,10 @@ export function DatabaseSnapshotsPanel({
onSuccess: () => {
setShowPanel(true);
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
toast.info(sn.backupStarted);
notify.info(sn.backupStarted);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data
?.message;
const text = Array.isArray(msg) ? msg.join(', ') : msg;
toast.error(text || sn.createFailed);
notify.error(err, sn.createFailed);
},
});
@@ -125,9 +122,9 @@ export function DatabaseSnapshotsPanel({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
toast.success(sn.backupDeleted);
notify.success(sn.backupDeleted);
},
onError: () => toast.error(sn.deleteFailed),
onError: () => notify.error(sn.deleteFailed),
});
const downloadSnapshotDb = (snapshotId: string) => {
@@ -144,9 +141,9 @@ export function DatabaseSnapshotsPanel({
link.download = `database-backup-${snapshotId.slice(0, 8)}.sql`;
link.click();
URL.revokeObjectURL(link.href);
toast.success(sn.downloadStarted);
notify.success(sn.downloadStarted);
})
.catch(() => toast.error(sn.downloadFailed));
.catch(() => notify.error(sn.downloadFailed));
};
const handleDelete = async (snap: AppSnapshot) => {
@@ -2,7 +2,7 @@
import { useRef, useState } from 'react';
import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { notify } from '@/lib/notify';
import { useT } from '@/i18n/I18nProvider';
import { Select } from '@/components/ui/select';
import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
@@ -57,11 +57,11 @@ export function ManagedDatabaseConfig({
const acceptDump = (f: File) => {
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
toast.error(c.allowedFormats);
notify.error(c.allowedFormats);
return;
}
if (f.size > 500 * 1024 * 1024) {
toast.error(c.maxSize);
notify.error(c.maxSize);
return;
}
onDbDumpFileChange(f);
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import { notify } from '@/lib/notify';
import { useT } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation';
import type { Application, Invoice, OptionalServiceResourcesMap, ResourceUsage } from '@/types';
@@ -167,11 +167,10 @@ export function ManagedServiceResourcesPanel({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
toast.success(sr.resourcesUpdated);
notify.success(sr.resourcesUpdated);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || sr.updateFailed);
notify.error(err, sr.updateFailed);
},
});
@@ -188,14 +187,13 @@ export function ManagedServiceResourcesPanel({
setPendingUpgradePayload(null);
const paidAmount = res.data.paidAmount || 0;
if (paidAmount > 0) {
toast.success(sr.upgradedPaid.replace('{amount}', paidAmount.toLocaleString('en-US')));
notify.success(sr.upgradedPaid.replace('{amount}', paidAmount.toLocaleString('en-US')));
} else {
toast.success(sr.updatedSuccess);
notify.success(sr.updatedSuccess);
}
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || sr.upgradeFailed);
notify.error(err, sr.upgradeFailed);
},
});
@@ -207,8 +205,7 @@ export function ManagedServiceResourcesPanel({
setShowUpgradeConfirm(true);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || sr.calcFailed);
notify.error(err, sr.calcFailed);
},
});
@@ -216,7 +213,7 @@ export function ManagedServiceResourcesPanel({
mutationFn: (data: UpgradePayload) =>
api.post<Invoice>(`/billing/applications/${serviceId}/upgrade/invoice`, data).then((r) => r.data),
onSuccess: (invoice) => {
toast.success(sr.invoiceCreated);
notify.success(sr.invoiceCreated);
queryClient.invalidateQueries({ queryKey: ['invoices'] });
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
@@ -224,8 +221,7 @@ export function ManagedServiceResourcesPanel({
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || sr.invoiceFailed);
notify.error(err, sr.invoiceFailed);
},
});
@@ -240,12 +236,11 @@ export function ManagedServiceResourcesPanel({
onSuccess: (res) => {
const data = res.data;
setDbRestoreLogs(data.logs || null);
if (data.success) toast.success(sr.restoredSuccess);
else toast.error(data.message || sr.restoreFailed);
if (data.success) notify.success(sr.restoredSuccess);
else notify.error(sr.restoreFailed);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || sr.uploadFailed);
notify.error(err, sr.uploadFailed);
setDbRestoreLogs(null);
},
});
@@ -253,11 +248,11 @@ export function ManagedServiceResourcesPanel({
const handleDbFileUpload = useCallback(
(file: File) => {
if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) {
toast.error(sr.invalidFile);
notify.error(sr.invalidFile);
return;
}
if (file.size > 500 * 1024 * 1024) {
toast.error(sr.fileTooLarge);
notify.error(sr.fileTooLarge);
return;
}
setDbRestoreLogs(null);
@@ -300,7 +295,7 @@ export function ManagedServiceResourcesPanel({
const applyResources = () => {
if (needsRenewal) {
toast.warn(sr.renewFirst);
notify.warning(sr.renewFirst);
return;
}
+17 -1
View File
@@ -5,6 +5,8 @@ import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/lib/store';
import { useLocale } from '@/i18n/I18nProvider';
import { dirFor } from '@/i18n/config';
import { ModalProvider } from './confirm-modal';
const queryClient = new QueryClient({
@@ -18,6 +20,8 @@ const queryClient = new QueryClient({
export function Providers({ children }: { children: React.ReactNode }) {
const loadUser = useAuthStore((s) => s.loadUser);
const locale = useLocale();
const isRtl = dirFor(locale) === 'rtl';
const [mounted, setMounted] = useState(false);
useEffect(() => {
@@ -32,7 +36,19 @@ export function Providers({ children }: { children: React.ReactNode }) {
<ModalProvider>
{children}
</ModalProvider>
<ToastContainer position="top-right" autoClose={3000} hideProgressBar={false} closeOnClick pauseOnHover theme="light" />
<ToastContainer
position={isRtl ? 'top-left' : 'top-right'}
rtl={isRtl}
autoClose={4000}
newestOnTop
closeOnClick
pauseOnHover
draggable
icon={false}
theme="light"
toastClassName="abrban-toast"
className="abrban-toast-container"
/>
</QueryClientProvider>
);
}
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import { notify } from '@/lib/notify';
import { useT } from '@/i18n/I18nProvider';
import { Select } from '@/components/ui/select';
import type { Application, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
@@ -70,11 +70,10 @@ export function ServiceExternalAccessPanel({
onSuccess: () => {
refetchAccessGrants();
queryClient.invalidateQueries({ queryKey: ['access-grants', appId] });
toast.success(accessPersistent ? ea.permanentEnabled : ea.temporaryEnabled);
notify.success(accessPersistent ? ea.permanentEnabled : ea.temporaryEnabled);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || ea.enableFailed);
notify.error(err, ea.enableFailed);
},
});
@@ -83,9 +82,9 @@ export function ServiceExternalAccessPanel({
onSuccess: () => {
refetchAccessGrants();
queryClient.invalidateQueries({ queryKey: ['access-grants', appId] });
toast.success(ea.revoked);
notify.success(ea.revoked);
},
onError: () => toast.error(ea.revokeFailed),
onError: () => notify.error(ea.revokeFailed),
});
const copyToClipboard = (text: string, field: string) => {