From 91a66d564546db027fb0596fb589a2364631ed86 Mon Sep 17 00:00:00 2001 From: keyhan Date: Sat, 13 Jun 2026 11:45:26 +0330 Subject: [PATCH] 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 --- .../app/[lang]/dashboard/admin/apps/page.tsx | 10 +- .../[lang]/dashboard/admin/billing/page.tsx | 19 +-- .../[lang]/dashboard/admin/clusters/page.tsx | 33 ++--- .../[lang]/dashboard/admin/invoices/page.tsx | 10 +- .../app/[lang]/dashboard/admin/pools/page.tsx | 12 +- .../app/[lang]/dashboard/admin/users/page.tsx | 18 +-- .../app/[lang]/dashboard/apps/[id]/page.tsx | 124 +++++++++--------- .../src/app/[lang]/dashboard/deploy/page.tsx | 52 ++++---- .../app/[lang]/dashboard/invoices/page.tsx | 12 +- .../[lang]/dashboard/services/[id]/page.tsx | 27 ++-- .../[lang]/dashboard/services/new/page.tsx | 16 +-- .../[lang]/dashboard/tickets/[id]/page.tsx | 8 +- .../src/app/[lang]/dashboard/tickets/page.tsx | 6 +- .../src/app/[lang]/dashboard/wallet/page.tsx | 12 +- frontend/src/app/[lang]/login/page.tsx | 6 +- frontend/src/app/[lang]/register/page.tsx | 6 +- frontend/src/app/globals.css | 55 ++++++++ .../src/components/build-progress-modal.tsx | 6 +- .../components/database-snapshots-panel.tsx | 21 ++- .../components/managed-database-config.tsx | 6 +- .../managed-service-resources-panel.tsx | 35 +++-- frontend/src/components/providers.tsx | 18 ++- .../service-external-access-panel.tsx | 11 +- frontend/src/i18n/I18nProvider.tsx | 4 + frontend/src/i18n/dictionaries/en.ts | 15 ++- frontend/src/i18n/dictionaries/fa.ts | 15 ++- frontend/src/lib/errors.ts | 116 ++++++++++++++++ frontend/src/lib/notify.tsx | 70 ++++++++++ frontend/src/lib/use-application-delete.ts | 8 +- 29 files changed, 501 insertions(+), 250 deletions(-) create mode 100644 frontend/src/lib/errors.ts create mode 100644 frontend/src/lib/notify.tsx diff --git a/frontend/src/app/[lang]/dashboard/admin/apps/page.tsx b/frontend/src/app/[lang]/dashboard/admin/apps/page.tsx index 2664102..b8dddaa 100644 --- a/frontend/src/app/[lang]/dashboard/admin/apps/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/apps/page.tsx @@ -6,7 +6,7 @@ import { Link } from '@/i18n/Link'; import { useT, useLocale } from '@/i18n/I18nProvider'; import type { Dictionary } from '@/i18n/dictionaries/fa'; import api from '@/lib/api'; -import { toast } from 'react-toastify'; +import { notify } from '@/lib/notify'; import type { Application, AppLifecycleStatus, ApplicationMigrationEvent, ApplicationMigrationJob, BillingCycle, Cluster } from '@/types'; import { Search, X, Package, Hexagon, User, Database, Box, AlertTriangle, Clock, ArrowRightLeft, RefreshCw } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; @@ -138,18 +138,18 @@ export default function AdminAppsPage() { }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['application-migrations'] }); - toast.success(aa.migrationQueued); + notify.success(aa.migrationQueued); }, - onError: (err: any) => toast.error(err?.response?.data?.message || aa.migrationQueueFailed), + onError: (err: any) => notify.error(err, aa.migrationQueueFailed), }); const retryMigration = useMutation({ mutationFn: (id: string) => api.post(`/application-migrations/${id}/retry`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['application-migrations'] }); - toast.success(aa.migrationRetryQueued); + notify.success(aa.migrationRetryQueued); }, - onError: (err: any) => toast.error(err?.response?.data?.message || aa.migrationRetryFailed), + onError: (err: any) => notify.error(err, aa.migrationRetryFailed), }); // Compute status counts from apps diff --git a/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx b/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx index b8c08b8..90e10fa 100644 --- a/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } 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 type { PricingCatalog, @@ -39,15 +39,6 @@ function toPricingCatalogPatch(catalog: PricingCatalog) { }; } -function formatApiError(err: unknown, fallback: string): string { - if (!err || typeof err !== 'object' || !('response' in err)) return fallback; - const message = (err as { response?: { data?: { message?: string | string[] } } }).response - ?.data?.message; - if (Array.isArray(message)) return message.join(', '); - if (typeof message === 'string') return message; - return fallback; -} - function cloneCatalog(catalog: PricingCatalog): PricingCatalog { const runtimes: PricingCatalog['runtimes'] = {}; for (const key of Object.keys(catalog.runtimes)) { @@ -377,12 +368,12 @@ export default function AdminBillingPage() { queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] }); queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] }); queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] }); - toast.success(b.saved); + notify.success(b.saved); setEditing(false); setDraft(null); }, onError: (err: unknown) => { - toast.error(formatApiError(err, b.saveFailed)); + notify.error(err, b.saveFailed); }, }); @@ -743,11 +734,11 @@ function LifecycleSettingsSection() { mutationFn: (body: Record) => api.patch('/lifecycle/settings', body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] }); - toast.success(b.lifecycleSaved); + notify.success(b.lifecycleSaved); setEditing(false); }, onError: (err: unknown) => { - toast.error(formatApiError(err, b.saveFailedShort)); + notify.error(err, b.saveFailedShort); }, }); diff --git a/frontend/src/app/[lang]/dashboard/admin/clusters/page.tsx b/frontend/src/app/[lang]/dashboard/admin/clusters/page.tsx index 81552d8..3e57759 100644 --- a/frontend/src/app/[lang]/dashboard/admin/clusters/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/clusters/page.tsx @@ -3,7 +3,7 @@ import { useState } 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 { Cluster, ClusterResources } from '@/types'; import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react'; @@ -123,14 +123,6 @@ function ResourcePanel({ clusterId }: { clusterId: string }) { ); } -function apiErrorMessage(err: unknown, fallback: string): string { - const e = err as { response?: { data?: { message?: string | string[] } } }; - const msg = e?.response?.data?.message; - if (Array.isArray(msg)) return msg.join(', '); - if (typeof msg === 'string' && msg.trim()) return msg; - return fallback; -} - interface ClusterToolField { key: string; label: string; @@ -178,18 +170,18 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT invalidate(); setShowForm(false); setFields({}); - toast.success(res.data?.message || cl.installStarted.replace('{name}', tool.name)); + notify.success(res.data?.message || cl.installStarted.replace('{name}', tool.name)); }, - onError: (err) => toast.error(apiErrorMessage(err, cl.installToolFailed.replace('{name}', tool.name))), + onError: (err) => notify.error(err, cl.installToolFailed.replace('{name}', tool.name)), }); const uninstallMutation = useMutation({ mutationFn: () => api.delete(`/clusters/${clusterId}/tools/${tool.id}`), onSuccess: (res) => { invalidate(); - toast.success(res.data?.message || cl.toolRemoved.replace('{name}', tool.name)); + notify.success(res.data?.message || cl.toolRemoved.replace('{name}', tool.name)); }, - onError: (err) => toast.error(apiErrorMessage(err, cl.removeToolFailed.replace('{name}', tool.name))), + onError: (err) => notify.error(err, cl.removeToolFailed.replace('{name}', tool.name)), }); const unmetDeps = tool.dependencies.filter( @@ -212,7 +204,7 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT const submitForm = () => { for (const f of tool.installFields) { if (f.required && !fields[f.key]?.trim()) { - toast.error(cl.fieldRequired.replace('{field}', f.label)); + notify.error(cl.fieldRequired.replace('{field}', f.label)); return; } } @@ -412,13 +404,12 @@ export default function AdminClustersPage() { }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-clusters'] }); - toast.success(cl.added); + notify.success(cl.added); setShowForm(false); setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', weight: 1, tags: '', isDefault: false }); }, onError: (err: any) => { - const message = err?.response?.data?.message || cl.addFailed; - toast.error(message); + notify.error(err, cl.addFailed); }, }); @@ -431,14 +422,14 @@ export default function AdminClustersPage() { queryClient.invalidateQueries({ queryKey: ['admin-clusters'] }); const data = res.data; if (data.connected) { - toast.success(cl.connectionOk.replace('{version}', data.version)); + notify.success(cl.connectionOk.replace('{version}', data.version)); } else { - toast.error(cl.connectionFailed.replace('{error}', data.error)); + notify.error(cl.connectionFailed.replace('{error}', data.error)); } setTestingId(null); }, onError: () => { - toast.error(cl.testFailed); + notify.error(cl.testFailed); setTestingId(null); }, }); @@ -447,7 +438,7 @@ export default function AdminClustersPage() { mutationFn: (id: string) => api.delete(`/clusters/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-clusters'] }); - toast.success(cl.removed); + notify.success(cl.removed); }, }); diff --git a/frontend/src/app/[lang]/dashboard/admin/invoices/page.tsx b/frontend/src/app/[lang]/dashboard/admin/invoices/page.tsx index c2f3bb7..bb5a58c 100644 --- a/frontend/src/app/[lang]/dashboard/admin/invoices/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/invoices/page.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FileText, Search, User, Wallet, CreditCard, XCircle, Download } from 'lucide-react'; -import { toast } from 'react-toastify'; +import { notify } from '@/lib/notify'; import api from '@/lib/api'; import { useT, useLocale } from '@/i18n/I18nProvider'; import type { Invoice, InvoiceStatus, PaymentMethod } from '@/types'; @@ -51,12 +51,12 @@ export default function AdminInvoicesPage() { mutationFn: ({ invoiceId, nextStatus, reason }: { invoiceId: string; nextStatus: InvoiceStatus; reason: string }) => api.patch(`/billing/admin/invoices/${invoiceId}/status`, { status: nextStatus, reason }).then((r) => r.data), onSuccess: () => { - toast.success(inv.statusUpdated); + notify.success(inv.statusUpdated); setStatusReason(''); queryClient.invalidateQueries({ queryKey: ['admin-invoices'] }); queryClient.invalidateQueries({ queryKey: ['admin-invoice', selectedId] }); }, - onError: (err: any) => toast.error(err.response?.data?.message || inv.statusUpdateFailed), + onError: (err: any) => notify.error(err, inv.statusUpdateFailed), }); const downloadPdfMutation = useMutation({ @@ -71,7 +71,7 @@ export default function AdminInvoicesPage() { link.remove(); window.URL.revokeObjectURL(url); }, - onError: () => toast.error(inv.downloadFailed), + onError: () => notify.error(inv.downloadFailed), }); const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US'); @@ -80,7 +80,7 @@ export default function AdminInvoicesPage() { const handleStatusUpdate = (nextStatus: InvoiceStatus) => { if (!selectedInvoice) return; if (!statusReason.trim()) { - toast.error(inv.reasonRequired); + notify.error(inv.reasonRequired); return; } updateStatusMutation.mutate({ diff --git a/frontend/src/app/[lang]/dashboard/admin/pools/page.tsx b/frontend/src/app/[lang]/dashboard/admin/pools/page.tsx index 7f3ea3b..18e5003 100644 --- a/frontend/src/app/[lang]/dashboard/admin/pools/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/pools/page.tsx @@ -3,7 +3,7 @@ import { useState } 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 { Cluster, ClusterPool } from '@/types'; import { Scale, BarChart3, RotateCw, CheckCircle, XCircle, Pencil, Clock, X } from 'lucide-react'; @@ -45,11 +45,11 @@ export default function AdminPoolsPage() { mutationFn: (data: typeof form) => api.post('/clusters/pools', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-pools'] }); - toast.success(p.poolCreated); + notify.success(p.poolCreated); resetForm(); }, onError: (err: any) => { - toast.error(err?.response?.data?.message || p.createFailed); + notify.error(err, p.createFailed); }, }); @@ -58,11 +58,11 @@ export default function AdminPoolsPage() { api.patch(`/clusters/pools/${id}`, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-pools'] }); - toast.success(p.poolUpdated); + notify.success(p.poolUpdated); resetForm(); }, onError: (err: any) => { - toast.error(err?.response?.data?.message || p.updateFailed); + notify.error(err, p.updateFailed); }, }); @@ -70,7 +70,7 @@ export default function AdminPoolsPage() { mutationFn: (id: string) => api.delete(`/clusters/pools/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-pools'] }); - toast.success(p.poolDeleted); + notify.success(p.poolDeleted); }, }); diff --git a/frontend/src/app/[lang]/dashboard/admin/users/page.tsx b/frontend/src/app/[lang]/dashboard/admin/users/page.tsx index 18e0177..b330570 100644 --- a/frontend/src/app/[lang]/dashboard/admin/users/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/users/page.tsx @@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; import { useAuthStore } from '@/lib/store'; import { useT, useLocale } from '@/i18n/I18nProvider'; -import { toast } from 'react-toastify'; +import { notify } from '@/lib/notify'; import type { AdminUser } from '@/types'; import { Users, Search, X, Clock, KeyRound } from 'lucide-react'; import { Select } from '@/components/ui/select'; @@ -48,12 +48,12 @@ export default function AdminUsersPage() { mutationFn: (data: typeof form) => api.post('/users', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-users'] }); - toast.success(u.createdSuccess); + notify.success(u.createdSuccess); setShowForm(false); setForm({ email: '', password: '', firstName: '', lastName: '', role: 'user' }); }, onError: (err: any) => { - toast.error(err?.response?.data?.message || u.createFailed); + notify.error(err, u.createFailed); }, }); @@ -62,7 +62,7 @@ export default function AdminUsersPage() { api.patch(`/users/${id}/${isActive ? 'deactivate' : 'activate'}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-users'] }); - toast.success(u.userUpdated); + notify.success(u.userUpdated); }, }); @@ -71,7 +71,7 @@ export default function AdminUsersPage() { api.patch(`/users/${id}/role`, { role }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-users'] }); - toast.success(u.roleUpdated); + notify.success(u.roleUpdated); }, }); @@ -80,16 +80,12 @@ export default function AdminUsersPage() { api.patch(`/users/${id}/password`, { password }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-users'] }); - toast.success(u.passwordUpdated); + notify.success(u.passwordUpdated); setPwdModalUser(null); setPwdModalPassword(''); }, onError: (err: unknown) => { - const msg = - err && typeof err === 'object' && 'response' in err - ? (err as { response?: { data?: { message?: string } } }).response?.data?.message - : undefined; - toast.error(typeof msg === 'string' ? msg : u.passwordFailed); + notify.error(err, u.passwordFailed); }, }); diff --git a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx index 2cc48df..e48befc 100644 --- a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams } from 'next/navigation'; import api from '@/lib/api'; -import { toast } from 'react-toastify'; +import { notify } from '@/lib/notify'; import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, OptionalServiceCredentials, Invoice } from '@/types'; import { useState, useRef, useCallback, useEffect } from 'react'; import { Link as NextLink } from '@/i18n/Link'; @@ -264,13 +264,13 @@ export default function AppDetailPage() { const renewMutation = useMutation({ mutationFn: (cycle: string) => api.post(`/billing/applications/${appId}/renew`, { cycle }), onSuccess: (res) => { - toast.success(res.data.message || 'Application renewed successfully!'); + notify.success(res.data.message || 'Application renewed successfully!'); queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['wallet'] }); setShowRenewalModal(false); }, onError: (err: any) => { - toast.error(err.response?.data?.message || 'Failed to renew application'); + notify.error(err, 'Failed to renew application'); }, }); @@ -278,13 +278,13 @@ export default function AppDetailPage() { mutationFn: (cycle: string) => api.post(`/billing/applications/${appId}/renew/invoice`, { cycle }).then((r) => r.data), onSuccess: (invoice) => { - toast.success(ad.invoiceCreated); + notify.success(ad.invoiceCreated); queryClient.invalidateQueries({ queryKey: ['invoices'] }); setShowRenewalModal(false); router.push(`/dashboard/invoices?invoice=${invoice.id}`); }, onError: (err: any) => { - toast.error(err.response?.data?.message || 'Failed to create renewal invoice'); + notify.error(err, 'Failed to create renewal invoice'); }, }); @@ -314,36 +314,36 @@ export default function AppDetailPage() { const setDomainMutation = useMutation({ mutationFn: (domain: string) => api.post(`/applications/${appId}/domain`, { domain }), onSuccess: () => { - toast.success(ad.domainSet); + notify.success(ad.domainSet); queryClient.invalidateQueries({ queryKey: ['application', appId] }); refetchDomainInfo(); setCustomDomainInput(''); }, - onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to set domain'), + onError: (err: any) => notify.error(err, 'Failed to set domain'), }); const verifyDnsMutation = useMutation({ mutationFn: () => api.post(`/applications/${appId}/domain/verify`), onSuccess: (res) => { if (res.data.verified) { - toast.success(ad.domainVerified); + notify.success(ad.domainVerified); } else { - toast.warning(res.data.message || 'DNS is not ready yet. Please try again later.'); + notify.warning(res.data.message || 'DNS is not ready yet. Please try again later.'); } queryClient.invalidateQueries({ queryKey: ['application', appId] }); refetchDomainInfo(); }, - onError: (err: any) => toast.error(err.response?.data?.message || 'DNS verification failed'), + onError: (err: any) => notify.error(err, 'DNS verification failed'), }); const removeDomainMutation = useMutation({ mutationFn: () => api.delete(`/applications/${appId}/domain`), onSuccess: () => { - toast.success(ad.customDomainRemoved); + notify.success(ad.customDomainRemoved); queryClient.invalidateQueries({ queryKey: ['application', appId] }); refetchDomainInfo(); }, - onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to remove domain'), + onError: (err: any) => notify.error(err, 'Failed to remove domain'), }); // ─── Snapshots ────────────────────────────────────── @@ -365,12 +365,12 @@ export default function AppDetailPage() { const revisionRollbackMutation = useMutation({ mutationFn: (revision: number) => api.post(`/snapshots/applications/${appId}/revisions/${revision}/rollback`), onSuccess: (res) => { - toast.success(res.data.message || 'Rollback completed'); + notify.success(res.data.message || 'Rollback completed'); queryClient.invalidateQueries({ queryKey: ['revisions', appId] }); queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['deployments', appId] }); }, - onError: (err: any) => toast.error(err.response?.data?.message || ad.rollbackFailed), + onError: (err: any) => notify.error(err, ad.rollbackFailed), }); const handleRevisionRollback = async (rev: K8sRevision) => { @@ -389,20 +389,20 @@ export default function AppDetailPage() { mutationFn: () => api.post(`/snapshots/applications/${appId}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['snapshots', appId] }); - toast.success(ad.snapshotStarted); + notify.success(ad.snapshotStarted); }, - onError: () => toast.error(ad.snapshotCreateFailed), + onError: () => notify.error(ad.snapshotCreateFailed), }); const rollbackMutation = useMutation({ mutationFn: (snapshotId: string) => api.post(`/snapshots/${snapshotId}/rollback`), onSuccess: (res) => { const details = res.data.details || []; - toast.success(ad.rollbackCompleted + details.join('\n')); + notify.success(ad.rollbackCompleted + details.join('\n')); queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['deployments', appId] }); }, - onError: () => toast.error(ad.rollbackFailed), + onError: () => notify.error(ad.rollbackFailed), }); const [deletingSnapshotId, setDeletingSnapshotId] = useState(null); @@ -416,9 +416,9 @@ export default function AppDetailPage() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['snapshots', appId] }); - toast.success(ad.snapshotDeleted); + notify.success(ad.snapshotDeleted); }, - onError: () => toast.error(ad.snapshotDeleteFailed), + onError: () => notify.error(ad.snapshotDeleteFailed), }); const handleRollback = async (snap: AppSnapshot) => { @@ -457,13 +457,13 @@ export default function AppDetailPage() { link.click(); URL.revokeObjectURL(link.href); }) - .catch(() => toast.error(ad.downloadFailed.replace('{name}', artifact))); + .catch(() => notify.error(ad.downloadFailed.replace('{name}', artifact))); }; const downloadCurrentArtifact = (artifact: 'source' | 'wp-content' | 'database') => { // Prevent duplicate downloads if (downloadingArtifact) { - toast.warn(ad.downloadInProgress); + notify.warning(ad.downloadInProgress); return; } @@ -475,7 +475,7 @@ export default function AppDetailPage() { const timeoutId = setTimeout(() => controller.abort(), timeout); const artifactName = artifact === 'source' ? 'Source Code' : artifact === 'wp-content' ? 'wp-content' : 'Database'; - toast.info(ad.downloading.replace('{name}', artifactName)); + notify.info(ad.downloading.replace('{name}', artifactName)); fetch(url, { headers: { Authorization: `Bearer ${token}` }, @@ -493,14 +493,14 @@ export default function AppDetailPage() { link.download = `current-${artifact}${ext}`; link.click(); URL.revokeObjectURL(link.href); - toast.success(ad.downloadedSuccess.replace('{name}', artifactName)); + notify.success(ad.downloadedSuccess.replace('{name}', artifactName)); }) .catch((err) => { clearTimeout(timeoutId); if (err.name === 'AbortError') { - toast.error(ad.downloadTimeout); + notify.error(ad.downloadTimeout); } else { - toast.error(ad.downloadCurrentFailed.replace('{name}', artifact)); + notify.error(ad.downloadCurrentFailed.replace('{name}', artifact)); } }) .finally(() => { @@ -562,11 +562,11 @@ export default function AppDetailPage() { }, onSuccess: () => { invalidateAll(); - toast.success(ad.deploymentTriggered); + notify.success(ad.deploymentTriggered); }, onError: () => { useDeployProgressStore.getState().stopTracking(appId); - toast.error(ad.deployFailed); + notify.error(ad.deployFailed); }, }); @@ -574,27 +574,27 @@ export default function AppDetailPage() { mutationFn: () => api.post(`/deployments/applications/${appId}/stop`), onSuccess: () => { invalidateAll(); - toast.success(ad.appStopped); + notify.success(ad.appStopped); }, - onError: () => toast.error(ad.stopFailed), + onError: () => notify.error(ad.stopFailed), }); const startMutation = useMutation({ mutationFn: () => api.post(`/deployments/applications/${appId}/start`), onSuccess: () => { invalidateAll(); - toast.success(ad.appStarted); + notify.success(ad.appStarted); }, - onError: () => toast.error(ad.startFailed), + onError: () => notify.error(ad.startFailed), }); const restartMutation = useMutation({ mutationFn: () => api.post(`/deployments/applications/${appId}/restart`), onSuccess: () => { invalidateAll(); - toast.success(ad.appRestarting); + notify.success(ad.appRestarting); }, - onError: () => toast.error(ad.restartFailed), + onError: () => notify.error(ad.restartFailed), }); const redeployMutation = useMutation({ @@ -604,11 +604,11 @@ export default function AppDetailPage() { }, onSuccess: () => { invalidateAll(); - toast.success(ad.redeployStarted); + notify.success(ad.redeployStarted); }, onError: () => { useDeployProgressStore.getState().stopTracking(appId); - toast.error(ad.redeployFailed); + notify.error(ad.redeployFailed); }, }); @@ -616,13 +616,13 @@ export default function AppDetailPage() { invalidateKeys: [['applications', 'application'], ['applications']], onSuccess: (data) => { if (data?.resourceCredit) { - toast.success(ad.appDeletedCredit); + notify.success(ad.appDeletedCredit); } else { - toast.success(ad.appDeleted); + notify.success(ad.appDeleted); } router.push('/dashboard/apps'); }, - onError: () => toast.error(ad.deleteAppFailed), + onError: () => notify.error(ad.deleteAppFailed), }); const scaleMutation = useMutation({ @@ -639,12 +639,12 @@ export default function AppDetailPage() { setPendingUpgradePayload(null); const paidAmount = res.data.paidAmount || 0; if (paidAmount > 0) { - toast.success(ad.upgradedPaid.replace('{amount}', paidAmount.toLocaleString('en-US'))); + notify.success(ad.upgradedPaid.replace('{amount}', paidAmount.toLocaleString('en-US'))); } else { - toast.success(ad.resourcesUpdatedSuccess); + notify.success(ad.resourcesUpdatedSuccess); } }, - onError: (err: any) => toast.error(err.response?.data?.message || ad.updateResourcesFailed), + onError: (err: any) => notify.error(err, ad.updateResourcesFailed), }); /** CPU/memory for DB / Redis / RabbitMQ — applies directly in Kubernetes (no billing wizard). */ @@ -660,9 +660,9 @@ export default function AppDetailPage() { setResourceFormDirty(false); invalidateAll(); queryClient.invalidateQueries({ queryKey: ['resources', appId] }); - toast.success(ad.resourcesUpdated); + notify.success(ad.resourcesUpdated); }, - onError: (err: any) => toast.error(err.response?.data?.message || ad.updateResourcesFailed), + onError: (err: any) => notify.error(err, ad.updateResourcesFailed), }); // Calculate upgrade cost before applying @@ -672,21 +672,21 @@ export default function AppDetailPage() { setUpgradeCostData(res.data); setShowUpgradeConfirm(true); }, - onError: (err: any) => toast.error(err.response?.data?.message || ad.calcFailed), + onError: (err: any) => notify.error(err, ad.calcFailed), }); const createUpgradeInvoiceMutation = useMutation({ mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${appId}/upgrade/invoice`, data).then((r) => r.data), onSuccess: (invoice) => { - toast.success(ad.invoiceCreated); + notify.success(ad.invoiceCreated); queryClient.invalidateQueries({ queryKey: ['invoices'] }); setShowUpgradeConfirm(false); setUpgradeCostData(null); setPendingUpgradePayload(null); router.push(`/dashboard/invoices?invoice=${invoice.id}`); }, - onError: (err: any) => toast.error(err.response?.data?.message || ad.invoiceFailed), + onError: (err: any) => notify.error(err, ad.invoiceFailed), }); const buildWorkloadUpgradePayload = useCallback((): UpgradePayload => { @@ -774,7 +774,7 @@ export default function AppDetailPage() { patchDatabaseCpuIfNeeded(); return; } - toast.warn(ad.renewFirst); + notify.warning(ad.renewFirst); return; } @@ -783,7 +783,7 @@ export default function AppDetailPage() { const needsDbCpuPatch = scaleWorkload === 'database'; if (!hasBillingPayload && !needsDbCpuPatch) { - toast.warn(ad.noChanges); + notify.warning(ad.noChanges); return; } @@ -879,12 +879,12 @@ export default function AppDetailPage() { const targetUrl = data.ingressUrl || data.url; window.open(targetUrl, '_blank'); if (data.ingressUrl) { - toast.success(ad.previewHttps); + notify.success(ad.previewHttps); } else { - toast.success(ad.previewPort.replace('{port}', String(data.nodePort))); + notify.success(ad.previewPort.replace('{port}', String(data.nodePort))); } }, - onError: () => toast.error(ad.previewFailed), + onError: () => notify.error(ad.previewFailed), }); const uploadMutation = useMutation({ @@ -900,11 +900,11 @@ export default function AppDetailPage() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['application', appId] }); - toast.success(ad.sourceUploaded); + notify.success(ad.sourceUploaded); setUploadProgress(0); }, onError: () => { - toast.error(ad.uploadFailed); + notify.error(ad.uploadFailed); setUploadProgress(0); }, }); @@ -921,24 +921,24 @@ export default function AppDetailPage() { const data = res.data; setDbRestoreLogs(data.logs || null); if (data.success) { - toast.success(ad.dbRestored); + notify.success(ad.dbRestored); } else { - toast.error(data.message || 'Database restore failed'); + notify.error(ad.dbRestoreFailed); } }, onError: (err: any) => { - toast.error(err.response?.data?.message || ad.dbUploadFailed); + notify.error(err, ad.dbUploadFailed); setDbRestoreLogs(null); }, }); const handleFileUpload = useCallback((file: File) => { if (!file.name.endsWith('.zip') && !file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) { - toast.error(ad.fileZipOnly); + notify.error(ad.fileZipOnly); return; } if (file.size > MAX_SOURCE_ARCHIVE_BYTES) { - toast.error(ad.fileMax10); + notify.error(ad.fileMax10); return; } uploadMutation.mutate(file); @@ -962,11 +962,11 @@ export default function AppDetailPage() { const handleDbFileUpload = useCallback((file: File) => { if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) { - toast.error(ad.dbInvalidFile); + notify.error(ad.dbInvalidFile); return; } if (file.size > 500 * 1024 * 1024) { - toast.error(ad.fileMax500); + notify.error(ad.fileMax500); return; } setDbRestoreLogs(null); @@ -1698,7 +1698,7 @@ export default function AppDetailPage() {