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:
@@ -6,7 +6,7 @@ import { Link } from '@/i18n/Link';
|
|||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
||||||
import api from '@/lib/api';
|
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 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 { Search, X, Package, Hexagon, User, Database, Box, AlertTriangle, Clock, ArrowRightLeft, RefreshCw } from 'lucide-react';
|
||||||
import { useConfirm } from '@/components/confirm-modal';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
@@ -138,18 +138,18 @@ export default function AdminAppsPage() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['application-migrations'] });
|
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({
|
const retryMigration = useMutation({
|
||||||
mutationFn: (id: string) => api.post(`/application-migrations/${id}/retry`),
|
mutationFn: (id: string) => api.post(`/application-migrations/${id}/retry`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['application-migrations'] });
|
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
|
// Compute status counts from apps
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import { useT } from '@/i18n/I18nProvider';
|
import { useT } from '@/i18n/I18nProvider';
|
||||||
import type {
|
import type {
|
||||||
PricingCatalog,
|
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 {
|
function cloneCatalog(catalog: PricingCatalog): PricingCatalog {
|
||||||
const runtimes: PricingCatalog['runtimes'] = {};
|
const runtimes: PricingCatalog['runtimes'] = {};
|
||||||
for (const key of Object.keys(catalog.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: ['pricing-catalog'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
|
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
|
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
|
||||||
toast.success(b.saved);
|
notify.success(b.saved);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
toast.error(formatApiError(err, b.saveFailed));
|
notify.error(err, b.saveFailed);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -743,11 +734,11 @@ function LifecycleSettingsSection() {
|
|||||||
mutationFn: (body: Record<string, number>) => api.patch('/lifecycle/settings', body),
|
mutationFn: (body: Record<string, number>) => api.patch('/lifecycle/settings', body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] });
|
queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] });
|
||||||
toast.success(b.lifecycleSaved);
|
notify.success(b.lifecycleSaved);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
toast.error(formatApiError(err, b.saveFailedShort));
|
notify.error(err, b.saveFailedShort);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import type { Cluster, ClusterResources } from '@/types';
|
import type { Cluster, ClusterResources } from '@/types';
|
||||||
import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react';
|
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 {
|
interface ClusterToolField {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -178,18 +170,18 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
|
|||||||
invalidate();
|
invalidate();
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setFields({});
|
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({
|
const uninstallMutation = useMutation({
|
||||||
mutationFn: () => api.delete(`/clusters/${clusterId}/tools/${tool.id}`),
|
mutationFn: () => api.delete(`/clusters/${clusterId}/tools/${tool.id}`),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
invalidate();
|
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(
|
const unmetDeps = tool.dependencies.filter(
|
||||||
@@ -212,7 +204,7 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
|
|||||||
const submitForm = () => {
|
const submitForm = () => {
|
||||||
for (const f of tool.installFields) {
|
for (const f of tool.installFields) {
|
||||||
if (f.required && !fields[f.key]?.trim()) {
|
if (f.required && !fields[f.key]?.trim()) {
|
||||||
toast.error(cl.fieldRequired.replace('{field}', f.label));
|
notify.error(cl.fieldRequired.replace('{field}', f.label));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,13 +404,12 @@ export default function AdminClustersPage() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||||
toast.success(cl.added);
|
notify.success(cl.added);
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', weight: 1, tags: '', isDefault: false });
|
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', weight: 1, tags: '', isDefault: false });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
const message = err?.response?.data?.message || cl.addFailed;
|
notify.error(err, cl.addFailed);
|
||||||
toast.error(message);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -431,14 +422,14 @@ export default function AdminClustersPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||||
const data = res.data;
|
const data = res.data;
|
||||||
if (data.connected) {
|
if (data.connected) {
|
||||||
toast.success(cl.connectionOk.replace('{version}', data.version));
|
notify.success(cl.connectionOk.replace('{version}', data.version));
|
||||||
} else {
|
} else {
|
||||||
toast.error(cl.connectionFailed.replace('{error}', data.error));
|
notify.error(cl.connectionFailed.replace('{error}', data.error));
|
||||||
}
|
}
|
||||||
setTestingId(null);
|
setTestingId(null);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(cl.testFailed);
|
notify.error(cl.testFailed);
|
||||||
setTestingId(null);
|
setTestingId(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -447,7 +438,7 @@ export default function AdminClustersPage() {
|
|||||||
mutationFn: (id: string) => api.delete(`/clusters/${id}`),
|
mutationFn: (id: string) => api.delete(`/clusters/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||||
toast.success(cl.removed);
|
notify.success(cl.removed);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { FileText, Search, User, Wallet, CreditCard, XCircle, Download } from 'lucide-react';
|
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 api from '@/lib/api';
|
||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import type { Invoice, InvoiceStatus, PaymentMethod } from '@/types';
|
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 }) =>
|
mutationFn: ({ invoiceId, nextStatus, reason }: { invoiceId: string; nextStatus: InvoiceStatus; reason: string }) =>
|
||||||
api.patch(`/billing/admin/invoices/${invoiceId}/status`, { status: nextStatus, reason }).then((r) => r.data),
|
api.patch(`/billing/admin/invoices/${invoiceId}/status`, { status: nextStatus, reason }).then((r) => r.data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(inv.statusUpdated);
|
notify.success(inv.statusUpdated);
|
||||||
setStatusReason('');
|
setStatusReason('');
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-invoices'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-invoices'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-invoice', selectedId] });
|
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({
|
const downloadPdfMutation = useMutation({
|
||||||
@@ -71,7 +71,7 @@ export default function AdminInvoicesPage() {
|
|||||||
link.remove();
|
link.remove();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
onError: () => toast.error(inv.downloadFailed),
|
onError: () => notify.error(inv.downloadFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US');
|
const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US');
|
||||||
@@ -80,7 +80,7 @@ export default function AdminInvoicesPage() {
|
|||||||
const handleStatusUpdate = (nextStatus: InvoiceStatus) => {
|
const handleStatusUpdate = (nextStatus: InvoiceStatus) => {
|
||||||
if (!selectedInvoice) return;
|
if (!selectedInvoice) return;
|
||||||
if (!statusReason.trim()) {
|
if (!statusReason.trim()) {
|
||||||
toast.error(inv.reasonRequired);
|
notify.error(inv.reasonRequired);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateStatusMutation.mutate({
|
updateStatusMutation.mutate({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import type { Cluster, ClusterPool } from '@/types';
|
import type { Cluster, ClusterPool } from '@/types';
|
||||||
import { Scale, BarChart3, RotateCw, CheckCircle, XCircle, Pencil, Clock, X } from 'lucide-react';
|
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),
|
mutationFn: (data: typeof form) => api.post('/clusters/pools', data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-pools'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-pools'] });
|
||||||
toast.success(p.poolCreated);
|
notify.success(p.poolCreated);
|
||||||
resetForm();
|
resetForm();
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
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),
|
api.patch(`/clusters/pools/${id}`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-pools'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-pools'] });
|
||||||
toast.success(p.poolUpdated);
|
notify.success(p.poolUpdated);
|
||||||
resetForm();
|
resetForm();
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
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}`),
|
mutationFn: (id: string) => api.delete(`/clusters/pools/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-pools'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-pools'] });
|
||||||
toast.success(p.poolDeleted);
|
notify.success(p.poolDeleted);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import type { AdminUser } from '@/types';
|
import type { AdminUser } from '@/types';
|
||||||
import { Users, Search, X, Clock, KeyRound } from 'lucide-react';
|
import { Users, Search, X, Clock, KeyRound } from 'lucide-react';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
@@ -48,12 +48,12 @@ export default function AdminUsersPage() {
|
|||||||
mutationFn: (data: typeof form) => api.post('/users', data),
|
mutationFn: (data: typeof form) => api.post('/users', data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
toast.success(u.createdSuccess);
|
notify.success(u.createdSuccess);
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setForm({ email: '', password: '', firstName: '', lastName: '', role: 'user' });
|
setForm({ email: '', password: '', firstName: '', lastName: '', role: 'user' });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
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'}`),
|
api.patch(`/users/${id}/${isActive ? 'deactivate' : 'activate'}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
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 }),
|
api.patch(`/users/${id}/role`, { role }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
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 }),
|
api.patch(`/users/${id}/password`, { password }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
toast.success(u.passwordUpdated);
|
notify.success(u.passwordUpdated);
|
||||||
setPwdModalUser(null);
|
setPwdModalUser(null);
|
||||||
setPwdModalPassword('');
|
setPwdModalPassword('');
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg =
|
notify.error(err, u.passwordFailed);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
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 type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, OptionalServiceCredentials, Invoice } from '@/types';
|
||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { Link as NextLink } from '@/i18n/Link';
|
import { Link as NextLink } from '@/i18n/Link';
|
||||||
@@ -264,13 +264,13 @@ export default function AppDetailPage() {
|
|||||||
const renewMutation = useMutation({
|
const renewMutation = useMutation({
|
||||||
mutationFn: (cycle: string) => api.post(`/billing/applications/${appId}/renew`, { cycle }),
|
mutationFn: (cycle: string) => api.post(`/billing/applications/${appId}/renew`, { cycle }),
|
||||||
onSuccess: (res) => {
|
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: ['application', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
queryClient.invalidateQueries({ queryKey: ['wallet'] });
|
||||||
setShowRenewalModal(false);
|
setShowRenewalModal(false);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
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) =>
|
mutationFn: (cycle: string) =>
|
||||||
api.post<Invoice>(`/billing/applications/${appId}/renew/invoice`, { cycle }).then((r) => r.data),
|
api.post<Invoice>(`/billing/applications/${appId}/renew/invoice`, { cycle }).then((r) => r.data),
|
||||||
onSuccess: (invoice) => {
|
onSuccess: (invoice) => {
|
||||||
toast.success(ad.invoiceCreated);
|
notify.success(ad.invoiceCreated);
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
setShowRenewalModal(false);
|
setShowRenewalModal(false);
|
||||||
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
|
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
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({
|
const setDomainMutation = useMutation({
|
||||||
mutationFn: (domain: string) => api.post(`/applications/${appId}/domain`, { domain }),
|
mutationFn: (domain: string) => api.post(`/applications/${appId}/domain`, { domain }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(ad.domainSet);
|
notify.success(ad.domainSet);
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
refetchDomainInfo();
|
refetchDomainInfo();
|
||||||
setCustomDomainInput('');
|
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({
|
const verifyDnsMutation = useMutation({
|
||||||
mutationFn: () => api.post(`/applications/${appId}/domain/verify`),
|
mutationFn: () => api.post(`/applications/${appId}/domain/verify`),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
if (res.data.verified) {
|
if (res.data.verified) {
|
||||||
toast.success(ad.domainVerified);
|
notify.success(ad.domainVerified);
|
||||||
} else {
|
} 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] });
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
refetchDomainInfo();
|
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({
|
const removeDomainMutation = useMutation({
|
||||||
mutationFn: () => api.delete(`/applications/${appId}/domain`),
|
mutationFn: () => api.delete(`/applications/${appId}/domain`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(ad.customDomainRemoved);
|
notify.success(ad.customDomainRemoved);
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
refetchDomainInfo();
|
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 ──────────────────────────────────────
|
// ─── Snapshots ──────────────────────────────────────
|
||||||
@@ -365,12 +365,12 @@ export default function AppDetailPage() {
|
|||||||
const revisionRollbackMutation = useMutation({
|
const revisionRollbackMutation = useMutation({
|
||||||
mutationFn: (revision: number) => api.post(`/snapshots/applications/${appId}/revisions/${revision}/rollback`),
|
mutationFn: (revision: number) => api.post(`/snapshots/applications/${appId}/revisions/${revision}/rollback`),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
toast.success(res.data.message || 'Rollback completed');
|
notify.success(res.data.message || 'Rollback completed');
|
||||||
queryClient.invalidateQueries({ queryKey: ['revisions', appId] });
|
queryClient.invalidateQueries({ queryKey: ['revisions', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['deployments', 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) => {
|
const handleRevisionRollback = async (rev: K8sRevision) => {
|
||||||
@@ -389,20 +389,20 @@ export default function AppDetailPage() {
|
|||||||
mutationFn: () => api.post(`/snapshots/applications/${appId}`),
|
mutationFn: () => api.post(`/snapshots/applications/${appId}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['snapshots', appId] });
|
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({
|
const rollbackMutation = useMutation({
|
||||||
mutationFn: (snapshotId: string) => api.post(`/snapshots/${snapshotId}/rollback`),
|
mutationFn: (snapshotId: string) => api.post(`/snapshots/${snapshotId}/rollback`),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
const details = res.data.details || [];
|
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: ['application', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
|
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
|
||||||
},
|
},
|
||||||
onError: () => toast.error(ad.rollbackFailed),
|
onError: () => notify.error(ad.rollbackFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [deletingSnapshotId, setDeletingSnapshotId] = useState<string | null>(null);
|
const [deletingSnapshotId, setDeletingSnapshotId] = useState<string | null>(null);
|
||||||
@@ -416,9 +416,9 @@ export default function AppDetailPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['snapshots', appId] });
|
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) => {
|
const handleRollback = async (snap: AppSnapshot) => {
|
||||||
@@ -457,13 +457,13 @@ export default function AppDetailPage() {
|
|||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(link.href);
|
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') => {
|
const downloadCurrentArtifact = (artifact: 'source' | 'wp-content' | 'database') => {
|
||||||
// Prevent duplicate downloads
|
// Prevent duplicate downloads
|
||||||
if (downloadingArtifact) {
|
if (downloadingArtifact) {
|
||||||
toast.warn(ad.downloadInProgress);
|
notify.warning(ad.downloadInProgress);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +475,7 @@ export default function AppDetailPage() {
|
|||||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
const artifactName = artifact === 'source' ? 'Source Code' : artifact === 'wp-content' ? 'wp-content' : 'Database';
|
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, {
|
fetch(url, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
@@ -493,14 +493,14 @@ export default function AppDetailPage() {
|
|||||||
link.download = `current-${artifact}${ext}`;
|
link.download = `current-${artifact}${ext}`;
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(link.href);
|
URL.revokeObjectURL(link.href);
|
||||||
toast.success(ad.downloadedSuccess.replace('{name}', artifactName));
|
notify.success(ad.downloadedSuccess.replace('{name}', artifactName));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (err.name === 'AbortError') {
|
if (err.name === 'AbortError') {
|
||||||
toast.error(ad.downloadTimeout);
|
notify.error(ad.downloadTimeout);
|
||||||
} else {
|
} else {
|
||||||
toast.error(ad.downloadCurrentFailed.replace('{name}', artifact));
|
notify.error(ad.downloadCurrentFailed.replace('{name}', artifact));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -562,11 +562,11 @@ export default function AppDetailPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
toast.success(ad.deploymentTriggered);
|
notify.success(ad.deploymentTriggered);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
useDeployProgressStore.getState().stopTracking(appId);
|
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`),
|
mutationFn: () => api.post(`/deployments/applications/${appId}/stop`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
toast.success(ad.appStopped);
|
notify.success(ad.appStopped);
|
||||||
},
|
},
|
||||||
onError: () => toast.error(ad.stopFailed),
|
onError: () => notify.error(ad.stopFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const startMutation = useMutation({
|
const startMutation = useMutation({
|
||||||
mutationFn: () => api.post(`/deployments/applications/${appId}/start`),
|
mutationFn: () => api.post(`/deployments/applications/${appId}/start`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
toast.success(ad.appStarted);
|
notify.success(ad.appStarted);
|
||||||
},
|
},
|
||||||
onError: () => toast.error(ad.startFailed),
|
onError: () => notify.error(ad.startFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const restartMutation = useMutation({
|
const restartMutation = useMutation({
|
||||||
mutationFn: () => api.post(`/deployments/applications/${appId}/restart`),
|
mutationFn: () => api.post(`/deployments/applications/${appId}/restart`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
toast.success(ad.appRestarting);
|
notify.success(ad.appRestarting);
|
||||||
},
|
},
|
||||||
onError: () => toast.error(ad.restartFailed),
|
onError: () => notify.error(ad.restartFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const redeployMutation = useMutation({
|
const redeployMutation = useMutation({
|
||||||
@@ -604,11 +604,11 @@ export default function AppDetailPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
toast.success(ad.redeployStarted);
|
notify.success(ad.redeployStarted);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
useDeployProgressStore.getState().stopTracking(appId);
|
useDeployProgressStore.getState().stopTracking(appId);
|
||||||
toast.error(ad.redeployFailed);
|
notify.error(ad.redeployFailed);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -616,13 +616,13 @@ export default function AppDetailPage() {
|
|||||||
invalidateKeys: [['applications', 'application'], ['applications']],
|
invalidateKeys: [['applications', 'application'], ['applications']],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data?.resourceCredit) {
|
if (data?.resourceCredit) {
|
||||||
toast.success(ad.appDeletedCredit);
|
notify.success(ad.appDeletedCredit);
|
||||||
} else {
|
} else {
|
||||||
toast.success(ad.appDeleted);
|
notify.success(ad.appDeleted);
|
||||||
}
|
}
|
||||||
router.push('/dashboard/apps');
|
router.push('/dashboard/apps');
|
||||||
},
|
},
|
||||||
onError: () => toast.error(ad.deleteAppFailed),
|
onError: () => notify.error(ad.deleteAppFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const scaleMutation = useMutation({
|
const scaleMutation = useMutation({
|
||||||
@@ -639,12 +639,12 @@ export default function AppDetailPage() {
|
|||||||
setPendingUpgradePayload(null);
|
setPendingUpgradePayload(null);
|
||||||
const paidAmount = res.data.paidAmount || 0;
|
const paidAmount = res.data.paidAmount || 0;
|
||||||
if (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 {
|
} 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). */
|
/** CPU/memory for DB / Redis / RabbitMQ — applies directly in Kubernetes (no billing wizard). */
|
||||||
@@ -660,9 +660,9 @@ export default function AppDetailPage() {
|
|||||||
setResourceFormDirty(false);
|
setResourceFormDirty(false);
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
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
|
// Calculate upgrade cost before applying
|
||||||
@@ -672,21 +672,21 @@ export default function AppDetailPage() {
|
|||||||
setUpgradeCostData(res.data);
|
setUpgradeCostData(res.data);
|
||||||
setShowUpgradeConfirm(true);
|
setShowUpgradeConfirm(true);
|
||||||
},
|
},
|
||||||
onError: (err: any) => toast.error(err.response?.data?.message || ad.calcFailed),
|
onError: (err: any) => notify.error(err, ad.calcFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createUpgradeInvoiceMutation = useMutation({
|
const createUpgradeInvoiceMutation = useMutation({
|
||||||
mutationFn: (data: UpgradePayload) =>
|
mutationFn: (data: UpgradePayload) =>
|
||||||
api.post<Invoice>(`/billing/applications/${appId}/upgrade/invoice`, data).then((r) => r.data),
|
api.post<Invoice>(`/billing/applications/${appId}/upgrade/invoice`, data).then((r) => r.data),
|
||||||
onSuccess: (invoice) => {
|
onSuccess: (invoice) => {
|
||||||
toast.success(ad.invoiceCreated);
|
notify.success(ad.invoiceCreated);
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
setShowUpgradeConfirm(false);
|
setShowUpgradeConfirm(false);
|
||||||
setUpgradeCostData(null);
|
setUpgradeCostData(null);
|
||||||
setPendingUpgradePayload(null);
|
setPendingUpgradePayload(null);
|
||||||
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
|
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 => {
|
const buildWorkloadUpgradePayload = useCallback((): UpgradePayload => {
|
||||||
@@ -774,7 +774,7 @@ export default function AppDetailPage() {
|
|||||||
patchDatabaseCpuIfNeeded();
|
patchDatabaseCpuIfNeeded();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.warn(ad.renewFirst);
|
notify.warning(ad.renewFirst);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,7 +783,7 @@ export default function AppDetailPage() {
|
|||||||
const needsDbCpuPatch = scaleWorkload === 'database';
|
const needsDbCpuPatch = scaleWorkload === 'database';
|
||||||
|
|
||||||
if (!hasBillingPayload && !needsDbCpuPatch) {
|
if (!hasBillingPayload && !needsDbCpuPatch) {
|
||||||
toast.warn(ad.noChanges);
|
notify.warning(ad.noChanges);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,12 +879,12 @@ export default function AppDetailPage() {
|
|||||||
const targetUrl = data.ingressUrl || data.url;
|
const targetUrl = data.ingressUrl || data.url;
|
||||||
window.open(targetUrl, '_blank');
|
window.open(targetUrl, '_blank');
|
||||||
if (data.ingressUrl) {
|
if (data.ingressUrl) {
|
||||||
toast.success(ad.previewHttps);
|
notify.success(ad.previewHttps);
|
||||||
} else {
|
} 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({
|
const uploadMutation = useMutation({
|
||||||
@@ -900,11 +900,11 @@ export default function AppDetailPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
toast.success(ad.sourceUploaded);
|
notify.success(ad.sourceUploaded);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(ad.uploadFailed);
|
notify.error(ad.uploadFailed);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -921,24 +921,24 @@ export default function AppDetailPage() {
|
|||||||
const data = res.data;
|
const data = res.data;
|
||||||
setDbRestoreLogs(data.logs || null);
|
setDbRestoreLogs(data.logs || null);
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success(ad.dbRestored);
|
notify.success(ad.dbRestored);
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.message || 'Database restore failed');
|
notify.error(ad.dbRestoreFailed);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
toast.error(err.response?.data?.message || ad.dbUploadFailed);
|
notify.error(err, ad.dbUploadFailed);
|
||||||
setDbRestoreLogs(null);
|
setDbRestoreLogs(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleFileUpload = useCallback((file: File) => {
|
const handleFileUpload = useCallback((file: File) => {
|
||||||
if (!file.name.endsWith('.zip') && !file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) {
|
if (!file.name.endsWith('.zip') && !file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) {
|
||||||
toast.error(ad.fileZipOnly);
|
notify.error(ad.fileZipOnly);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > MAX_SOURCE_ARCHIVE_BYTES) {
|
if (file.size > MAX_SOURCE_ARCHIVE_BYTES) {
|
||||||
toast.error(ad.fileMax10);
|
notify.error(ad.fileMax10);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uploadMutation.mutate(file);
|
uploadMutation.mutate(file);
|
||||||
@@ -962,11 +962,11 @@ export default function AppDetailPage() {
|
|||||||
|
|
||||||
const handleDbFileUpload = useCallback((file: File) => {
|
const handleDbFileUpload = useCallback((file: File) => {
|
||||||
if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) {
|
if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) {
|
||||||
toast.error(ad.dbInvalidFile);
|
notify.error(ad.dbInvalidFile);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > 500 * 1024 * 1024) {
|
if (file.size > 500 * 1024 * 1024) {
|
||||||
toast.error(ad.fileMax500);
|
notify.error(ad.fileMax500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDbRestoreLogs(null);
|
setDbRestoreLogs(null);
|
||||||
@@ -1698,7 +1698,7 @@ export default function AppDetailPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(domainInfo.fullPlatformUrl);
|
navigator.clipboard.writeText(domainInfo.fullPlatformUrl);
|
||||||
toast.success(ad.copied);
|
notify.success(ad.copied);
|
||||||
}}
|
}}
|
||||||
className="text-blue-600 hover:text-blue-800 p-1"
|
className="text-blue-600 hover:text-blue-800 p-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { parseDotenv } from '@/lib/parseDotenv';
|
|||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
||||||
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
|
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import type {
|
import type {
|
||||||
CreateApplicationDto,
|
CreateApplicationDto,
|
||||||
ClusterPublic,
|
ClusterPublic,
|
||||||
@@ -352,13 +352,13 @@ export default function DeployPage() {
|
|||||||
setDnsCheckResult(data);
|
setDnsCheckResult(data);
|
||||||
if (data.verified) {
|
if (data.verified) {
|
||||||
setDnsVerified(true);
|
setDnsVerified(true);
|
||||||
toast.success(dw.dnsVerifiedProceed);
|
notify.success(dw.dnsVerifiedProceed);
|
||||||
} else {
|
} else {
|
||||||
toast.warning(data.message);
|
notify.warning(data.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(dw.dnsCheckFailed);
|
notify.error(dw.dnsCheckFailed);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -455,14 +455,14 @@ export default function DeployPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setDeployStage('deploying');
|
setDeployStage('deploying');
|
||||||
toast.success(dw.paymentSuccessDeploying);
|
notify.success(dw.paymentSuccessDeploying);
|
||||||
void triggerAppDeploy(res.data.id, res.data.name);
|
void triggerAppDeploy(res.data.id, res.data.name);
|
||||||
setDeployStage('done');
|
setDeployStage('done');
|
||||||
router.push(`/dashboard/apps/${res.data.id}`);
|
router.push(`/dashboard/apps/${res.data.id}`);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
toast.error(err.response?.data?.message || 'Payment or deployment failed');
|
notify.error(err, 'Payment or deployment failed');
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setDbUploadProgress(0);
|
setDbUploadProgress(0);
|
||||||
setTimeout(() => setDeployStage('idle'), 2000);
|
setTimeout(() => setDeployStage('idle'), 2000);
|
||||||
@@ -537,14 +537,14 @@ export default function DeployPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setDeployStage('deploying');
|
setDeployStage('deploying');
|
||||||
toast.success(dw.paymentSuccessDeploying);
|
notify.success(dw.paymentSuccessDeploying);
|
||||||
void triggerAppDeploy(res.data.id, res.data.name);
|
void triggerAppDeploy(res.data.id, res.data.name);
|
||||||
setDeployStage('done');
|
setDeployStage('done');
|
||||||
router.push(`/dashboard/apps/${res.data.id}`);
|
router.push(`/dashboard/apps/${res.data.id}`);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
toast.error(err.response?.data?.message || 'Payment failed');
|
notify.error(err, 'Payment failed');
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setDbUploadProgress(0);
|
setDbUploadProgress(0);
|
||||||
setTimeout(() => setDeployStage('idle'), 2000);
|
setTimeout(() => setDeployStage('idle'), 2000);
|
||||||
@@ -592,14 +592,14 @@ export default function DeployPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setDeployStage('deploying');
|
setDeployStage('deploying');
|
||||||
toast.success(dw.appCreatedDeploying);
|
notify.success(dw.appCreatedDeploying);
|
||||||
void triggerAppDeploy(res.data.id, res.data.name);
|
void triggerAppDeploy(res.data.id, res.data.name);
|
||||||
setDeployStage('done');
|
setDeployStage('done');
|
||||||
router.push(`/dashboard/apps/${res.data.id}`);
|
router.push(`/dashboard/apps/${res.data.id}`);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
toast.error(err.response?.data?.message || 'Failed to create application');
|
notify.error(err, 'Failed to create application');
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setDbUploadProgress(0);
|
setDbUploadProgress(0);
|
||||||
setTimeout(() => setDeployStage('idle'), 2000);
|
setTimeout(() => setDeployStage('idle'), 2000);
|
||||||
@@ -628,18 +628,18 @@ export default function DeployPage() {
|
|||||||
const count = Object.keys(vars).length;
|
const count = Object.keys(vars).length;
|
||||||
|
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
toast.error(errors.length > 0 ? errors[0] : dw.noValidEnvVars);
|
notify.error(errors.length > 0 ? errors[0] : dw.noValidEnvVars);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setForm((prev) => ({ ...prev, envVars: { ...prev.envVars, ...vars } }));
|
setForm((prev) => ({ ...prev, envVars: { ...prev.envVars, ...vars } }));
|
||||||
setEnvFile(file);
|
setEnvFile(file);
|
||||||
toast.success(`${count} environment variable${count === 1 ? '' : 's'} imported from ${file.name}`);
|
notify.success(`${count} environment variable${count === 1 ? '' : 's'} imported from ${file.name}`);
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
toast.warn(`${errors.length} line${errors.length === 1 ? '' : 's'} skipped`);
|
notify.warning(`${errors.length} line${errors.length === 1 ? '' : 's'} skipped`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.onerror = () => toast.error(dw.envReadFailed);
|
reader.onerror = () => notify.error(dw.envReadFailed);
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -670,11 +670,11 @@ export default function DeployPage() {
|
|||||||
const hasValidExt = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext));
|
const hasValidExt = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext));
|
||||||
|
|
||||||
if (!hasValidExt && !validTypes.includes(file.type)) {
|
if (!hasValidExt && !validTypes.includes(file.type)) {
|
||||||
toast.error(dw.onlyZipAllowed);
|
notify.error(dw.onlyZipAllowed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > MAX_SOURCE_CODE_UPLOAD_BYTES) {
|
if (file.size > MAX_SOURCE_CODE_UPLOAD_BYTES) {
|
||||||
toast.error(dw.fileMax10);
|
notify.error(dw.fileMax10);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setZipFile(file);
|
setZipFile(file);
|
||||||
@@ -683,7 +683,7 @@ export default function DeployPage() {
|
|||||||
const cur = parseInt(prev.appStorageSize || '2', 10) || 2;
|
const cur = parseInt(prev.appStorageSize || '2', 10) || 2;
|
||||||
return { ...prev, appStorageSize: String(Math.max(cur, minG)) };
|
return { ...prev, appStorageSize: String(Math.max(cur, minG)) };
|
||||||
});
|
});
|
||||||
toast.success(`Selected: ${file.name}`);
|
notify.success(`Selected: ${file.name}`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
@@ -707,11 +707,11 @@ export default function DeployPage() {
|
|||||||
const validExtensions = ['.zip', '.tar.gz', '.tgz'];
|
const validExtensions = ['.zip', '.tar.gz', '.tgz'];
|
||||||
const hasValidExt = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext));
|
const hasValidExt = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext));
|
||||||
if (!hasValidExt) {
|
if (!hasValidExt) {
|
||||||
toast.error(dw.onlyZipAllowed);
|
notify.error(dw.onlyZipAllowed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > MAX_SOURCE_CODE_UPLOAD_BYTES) {
|
if (file.size > MAX_SOURCE_CODE_UPLOAD_BYTES) {
|
||||||
toast.error(dw.archiveMax10);
|
notify.error(dw.archiveMax10);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setWpContentFile(file);
|
setWpContentFile(file);
|
||||||
@@ -722,7 +722,7 @@ export default function DeployPage() {
|
|||||||
const cur = parseInt(prev.appStorageSize || '2', 10) || 2;
|
const cur = parseInt(prev.appStorageSize || '2', 10) || 2;
|
||||||
return { ...prev, appStorageSize: String(Math.max(cur, target)) };
|
return { ...prev, appStorageSize: String(Math.max(cur, target)) };
|
||||||
});
|
});
|
||||||
toast.success(`WordPress files selected: ${file.name}`);
|
notify.success(`WordPress files selected: ${file.name}`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleWpDrop = useCallback((e: React.DragEvent) => {
|
const handleWpDrop = useCallback((e: React.DragEvent) => {
|
||||||
@@ -773,7 +773,7 @@ export default function DeployPage() {
|
|||||||
if (data.verified) {
|
if (data.verified) {
|
||||||
setDnsVerified(true);
|
setDnsVerified(true);
|
||||||
setDnsCheckResult(data);
|
setDnsCheckResult(data);
|
||||||
toast.success(dw.dnsVerified);
|
notify.success(dw.dnsVerified);
|
||||||
setStep(step + 1);
|
setStep(step + 1);
|
||||||
} else {
|
} else {
|
||||||
setDnsCheckResult(data);
|
setDnsCheckResult(data);
|
||||||
@@ -1599,9 +1599,9 @@ export default function DeployPage() {
|
|||||||
const f = e.dataTransfer.files[0];
|
const f = e.dataTransfer.files[0];
|
||||||
if (f) {
|
if (f) {
|
||||||
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
||||||
toast.error(dw.allowedDumpFormats);
|
notify.error(dw.allowedDumpFormats);
|
||||||
} else if (f.size > 500 * 1024 * 1024) {
|
} else if (f.size > 500 * 1024 * 1024) {
|
||||||
toast.error(dw.maxSize500);
|
notify.error(dw.maxSize500);
|
||||||
} else {
|
} else {
|
||||||
setDbDumpFile(f);
|
setDbDumpFile(f);
|
||||||
const suggested = Math.max(
|
const suggested = Math.max(
|
||||||
@@ -1628,9 +1628,9 @@ export default function DeployPage() {
|
|||||||
const f = e.target.files?.[0];
|
const f = e.target.files?.[0];
|
||||||
if (f) {
|
if (f) {
|
||||||
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
||||||
toast.error(dw.allowedDumpFormats);
|
notify.error(dw.allowedDumpFormats);
|
||||||
} else if (f.size > 500 * 1024 * 1024) {
|
} else if (f.size > 500 * 1024 * 1024) {
|
||||||
toast.error(dw.maxSize500);
|
notify.error(dw.maxSize500);
|
||||||
} else {
|
} else {
|
||||||
setDbDumpFile(f);
|
setDbDumpFile(f);
|
||||||
const suggested = Math.max(
|
const suggested = Math.max(
|
||||||
@@ -2849,7 +2849,7 @@ export default function DeployPage() {
|
|||||||
walletPayMutation.mutate();
|
walletPayMutation.mutate();
|
||||||
} else if (paymentMethod === 'wallet') {
|
} else if (paymentMethod === 'wallet') {
|
||||||
if (!hasEnoughBalance) {
|
if (!hasEnoughBalance) {
|
||||||
toast.error(dw.insufficientWallet);
|
notify.error(dw.insufficientWallet);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
walletPayMutation.mutate();
|
walletPayMutation.mutate();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, Download } from 'lucide-react';
|
import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, Download } from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import type { Invoice, InvoiceStatus } from '@/types';
|
import type { Invoice, InvoiceStatus } from '@/types';
|
||||||
@@ -69,11 +69,11 @@ export default function InvoicesPage() {
|
|||||||
mutationFn: ({ invoiceId, trackingCode, amount }: { invoiceId: string; trackingCode: string; amount: number }) =>
|
mutationFn: ({ invoiceId, trackingCode, amount }: { invoiceId: string; trackingCode: string; amount: number }) =>
|
||||||
api.post(`/billing/invoices/${invoiceId}/gateway/verify`, { trackingCode, amount }).then((r) => r.data),
|
api.post(`/billing/invoices/${invoiceId}/gateway/verify`, { trackingCode, amount }).then((r) => r.data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success(data.effect ? inv.paymentCompleteUpdated : inv.paymentComplete);
|
notify.success(data.effect ? inv.paymentCompleteUpdated : inv.paymentComplete);
|
||||||
refresh();
|
refresh();
|
||||||
window.history.replaceState({}, '', window.location.pathname);
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
},
|
},
|
||||||
onError: (err: any) => toast.error(err.response?.data?.message || inv.gatewayFailed),
|
onError: (err: any) => notify.error(err, inv.gatewayFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -99,10 +99,10 @@ export default function InvoicesPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.gatewayUrl && data.gatewayAmount > 0) return;
|
if (data.gatewayUrl && data.gatewayAmount > 0) return;
|
||||||
toast.success(data.effect ? inv.invoicePaidUpdated : inv.invoicePaid);
|
notify.success(data.effect ? inv.invoicePaidUpdated : inv.invoicePaid);
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
onError: (err: any) => toast.error(err.response?.data?.message || inv.paymentFailed),
|
onError: (err: any) => notify.error(err, inv.paymentFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const downloadPdfMutation = useMutation({
|
const downloadPdfMutation = useMutation({
|
||||||
@@ -117,7 +117,7 @@ export default function InvoicesPage() {
|
|||||||
link.remove();
|
link.remove();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
onError: () => toast.error(inv.downloadFailed),
|
onError: () => notify.error(inv.downloadFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US');
|
const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import { Link } from '@/i18n/Link';
|
import { Link } from '@/i18n/Link';
|
||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||||
@@ -127,14 +127,14 @@ export default function ManagedServiceDetailPage() {
|
|||||||
notifyDeployStarted(serviceId, app?.name);
|
notifyDeployStarted(serviceId, app?.name);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(sd.provisioningStarted);
|
notify.success(sd.provisioningStarted);
|
||||||
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
useDeployProgressStore.getState().stopTracking(serviceId);
|
useDeployProgressStore.getState().stopTracking(serviceId);
|
||||||
toast.error(sd.provisioningFailed);
|
notify.error(sd.provisioningFailed);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,37 +144,34 @@ export default function ManagedServiceDetailPage() {
|
|||||||
notifyDeployStarted(serviceId, app?.name);
|
notifyDeployStarted(serviceId, app?.name);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(sd.reprovisioningStarted);
|
notify.success(sd.reprovisioningStarted);
|
||||||
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
useDeployProgressStore.getState().stopTracking(serviceId);
|
useDeployProgressStore.getState().stopTracking(serviceId);
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
notify.error(err, sd.reprovisionFailed);
|
||||||
toast.error(msg || sd.reprovisionFailed);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const restartMutation = useMutation({
|
const restartMutation = useMutation({
|
||||||
mutationFn: () => api.post(`/deployments/applications/${serviceId}/restart`),
|
mutationFn: () => api.post(`/deployments/applications/${serviceId}/restart`),
|
||||||
onSuccess: () => toast.success(sd.serviceRestarted),
|
onSuccess: () => notify.success(sd.serviceRestarted),
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
notify.error(err, sd.restartFailed);
|
||||||
toast.error(msg || sd.restartFailed);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const renewMutation = useMutation({
|
const renewMutation = useMutation({
|
||||||
mutationFn: (cycle: string) => api.post(`/billing/applications/${serviceId}/renew`, { cycle }),
|
mutationFn: (cycle: string) => api.post(`/billing/applications/${serviceId}/renew`, { cycle }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(sd.serviceRenewed);
|
notify.success(sd.serviceRenewed);
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||||
setShowRenewalModal(false);
|
setShowRenewalModal(false);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
notify.error(err, sd.renewalFailed);
|
||||||
toast.error(msg || sd.renewalFailed);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,13 +180,13 @@ export default function ManagedServiceDetailPage() {
|
|||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||||
if (data?.resourceCredit) {
|
if (data?.resourceCredit) {
|
||||||
toast.success(sd.deletedWithCredit);
|
notify.success(sd.deletedWithCredit);
|
||||||
} else {
|
} else {
|
||||||
toast.success(sd.deleted);
|
notify.success(sd.deleted);
|
||||||
}
|
}
|
||||||
router.push('/dashboard/services');
|
router.push('/dashboard/services');
|
||||||
},
|
},
|
||||||
onError: () => toast.error(sd.deleteFailed),
|
onError: () => notify.error(sd.deleteFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyToClipboard = useCallback((text: string, field: string) => {
|
const copyToClipboard = useCallback((text: string, field: string) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import { Link } from '@/i18n/Link';
|
import { Link } from '@/i18n/Link';
|
||||||
import { useT } from '@/i18n/I18nProvider';
|
import { useT } from '@/i18n/I18nProvider';
|
||||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||||
@@ -187,7 +187,7 @@ export default function NewManagedServicePage() {
|
|||||||
throw new Error(s.deployFailed);
|
throw new Error(s.deployFailed);
|
||||||
}
|
}
|
||||||
setDeployStage('done');
|
setDeployStage('done');
|
||||||
toast.success(s.provisionedSuccess);
|
notify.success(s.provisionedSuccess);
|
||||||
router.push(`/dashboard/services/${appId}`);
|
router.push(`/dashboard/services/${appId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,12 +220,11 @@ export default function NewManagedServicePage() {
|
|||||||
onSuccess: (appId) =>
|
onSuccess: (appId) =>
|
||||||
finishDeploy(appId, form.name).catch(() => {
|
finishDeploy(appId, form.name).catch(() => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
toast.error(s.paySucceededDeployFailed);
|
notify.error(s.paySucceededDeployFailed);
|
||||||
}),
|
}),
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
notify.error(err, s.payProvisionFailed);
|
||||||
toast.error(msg || s.payProvisionFailed);
|
|
||||||
setTimeout(() => setDeployStage('idle'), 2000);
|
setTimeout(() => setDeployStage('idle'), 2000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -256,12 +255,11 @@ export default function NewManagedServicePage() {
|
|||||||
onSuccess: (appId) =>
|
onSuccess: (appId) =>
|
||||||
finishDeploy(appId, form.name).catch(() => {
|
finishDeploy(appId, form.name).catch(() => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
toast.error(s.paySucceededDeployFailed);
|
notify.error(s.paySucceededDeployFailed);
|
||||||
}),
|
}),
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
notify.error(err, s.payFailed);
|
||||||
toast.error(msg || s.payFailed);
|
|
||||||
setTimeout(() => setDeployStage('idle'), 2000);
|
setTimeout(() => setDeployStage('idle'), 2000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -283,7 +281,7 @@ export default function NewManagedServicePage() {
|
|||||||
if (payAmount === 0) walletPayMutation.mutate();
|
if (payAmount === 0) walletPayMutation.mutate();
|
||||||
else if (paymentMethod === 'wallet') {
|
else if (paymentMethod === 'wallet') {
|
||||||
if (!hasEnoughBalance) {
|
if (!hasEnoughBalance) {
|
||||||
toast.error(s.insufficientBalance);
|
notify.error(s.insufficientBalance);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
walletPayMutation.mutate();
|
walletPayMutation.mutate();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useLocalizedRouter } from '@/i18n/navigation';
|
|||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import type { Ticket } from '@/types';
|
import type { Ticket } from '@/types';
|
||||||
import { Wrench, Briefcase } from 'lucide-react';
|
import { Wrench, Briefcase } from 'lucide-react';
|
||||||
|
|
||||||
@@ -40,16 +40,16 @@ export default function TicketDetailPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setReply('');
|
setReply('');
|
||||||
queryClient.invalidateQueries({ queryKey: ['ticket', id] });
|
queryClient.invalidateQueries({ queryKey: ['ticket', id] });
|
||||||
toast.success(tk.replySent);
|
notify.success(tk.replySent);
|
||||||
},
|
},
|
||||||
onError: (err: any) => toast.error(err.response?.data?.message || tk.replyFailed),
|
onError: (err: any) => notify.error(err, tk.replyFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeMutation = useMutation({
|
const closeMutation = useMutation({
|
||||||
mutationFn: () => api.patch(`/tickets/${id}/close`).then((r) => r.data),
|
mutationFn: () => api.patch(`/tickets/${id}/close`).then((r) => r.data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ticket', id] });
|
queryClient.invalidateQueries({ queryKey: ['ticket', id] });
|
||||||
toast.success(tk.ticketClosed);
|
notify.success(tk.ticketClosed);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Link } from '@/i18n/Link';
|
|||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types';
|
import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types';
|
||||||
import { Wrench, Briefcase, Ticket as TicketIcon, X } from 'lucide-react';
|
import { Wrench, Briefcase, Ticket as TicketIcon, X } from 'lucide-react';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
@@ -56,12 +56,12 @@ export default function TicketsPage() {
|
|||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: CreateTicketDto) => api.post('/tickets', data).then((r) => r.data),
|
mutationFn: (data: CreateTicketDto) => api.post('/tickets', data).then((r) => r.data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(tk.createdSuccess);
|
notify.success(tk.createdSuccess);
|
||||||
queryClient.invalidateQueries({ queryKey: ['my-tickets'] });
|
queryClient.invalidateQueries({ queryKey: ['my-tickets'] });
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
setForm({ subject: '', department: availableDepartments[0]?.value || 'technical', priority: 'medium', message: '' });
|
setForm({ subject: '', department: availableDepartments[0]?.value || 'technical', priority: 'medium', message: '' });
|
||||||
},
|
},
|
||||||
onError: (err: any) => toast.error(err.response?.data?.message || tk.createFailed),
|
onError: (err: any) => notify.error(err, tk.createFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import type { WalletTransaction, TransactionType } from '@/types';
|
import type { WalletTransaction, TransactionType } from '@/types';
|
||||||
import { Link } from '@/i18n/Link';
|
import { Link } from '@/i18n/Link';
|
||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
@@ -48,11 +48,11 @@ export default function WalletPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||||
toast.success(w.chargedSuccess);
|
notify.success(w.chargedSuccess);
|
||||||
setChargeAmount('');
|
setChargeAmount('');
|
||||||
setShowCharge(false);
|
setShowCharge(false);
|
||||||
},
|
},
|
||||||
onError: (err: any) => toast.error(err.response?.data?.message || w.chargeFailed),
|
onError: (err: any) => notify.error(err, w.chargeFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Payment gateway charge
|
// Payment gateway charge
|
||||||
@@ -74,17 +74,17 @@ export default function WalletPage() {
|
|||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||||
toast.success(w.paymentSuccess);
|
notify.success(w.paymentSuccess);
|
||||||
setChargeAmount('');
|
setChargeAmount('');
|
||||||
setShowCharge(false);
|
setShowCharge(false);
|
||||||
},
|
},
|
||||||
onError: (err: any) => toast.error(err.response?.data?.message || w.paymentFailed),
|
onError: (err: any) => notify.error(err, w.paymentFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCharge = (method: 'wallet' | 'gateway') => {
|
const handleCharge = (method: 'wallet' | 'gateway') => {
|
||||||
const amount = Number(chargeAmount);
|
const amount = Number(chargeAmount);
|
||||||
if (!amount || amount < 1000) {
|
if (!amount || amount < 1000) {
|
||||||
toast.error(w.minChargeError);
|
notify.error(w.minChargeError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (method === 'wallet') {
|
if (method === 'wallet') {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import { LogIn, ArrowLeft } from 'lucide-react';
|
import { LogIn, ArrowLeft } from 'lucide-react';
|
||||||
import { AuthShell } from '@/components/auth/AuthShell';
|
import { AuthShell } from '@/components/auth/AuthShell';
|
||||||
import { AuthField } from '@/components/auth/AuthField';
|
import { AuthField } from '@/components/auth/AuthField';
|
||||||
@@ -22,10 +22,10 @@ export default function LoginPage() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
toast.success(tl.success);
|
notify.success(tl.success);
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err.response?.data?.message || tl.error);
|
notify.error(err, tl.error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import { UserPlus, ArrowLeft } from 'lucide-react';
|
import { UserPlus, ArrowLeft } from 'lucide-react';
|
||||||
import { AuthShell } from '@/components/auth/AuthShell';
|
import { AuthShell } from '@/components/auth/AuthShell';
|
||||||
import { AuthField } from '@/components/auth/AuthField';
|
import { AuthField } from '@/components/auth/AuthField';
|
||||||
@@ -21,10 +21,10 @@ export default function RegisterPage() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await register(form);
|
await register(form);
|
||||||
toast.success(tr.success);
|
notify.success(tr.success);
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err.response?.data?.message || tr.error);
|
notify.error(err, tr.error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,3 +237,58 @@ html.lenis body {
|
|||||||
font-size: 16px !important;
|
font-size: 16px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Toast notifications (react-toastify) ─────────────────────
|
||||||
|
Reskin the library defaults into project-styled cards. The body content
|
||||||
|
(icon chip + message) is rendered by src/lib/notify.tsx; here we shape the
|
||||||
|
shell, spacing, shadow, RTL flow and the type-colored progress bar. */
|
||||||
|
.abrban-toast-container {
|
||||||
|
width: auto;
|
||||||
|
max-width: min(92vw, 26rem);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abrban-toast-container .abrban-toast {
|
||||||
|
border-radius: 0.875rem; /* rounded-xl */
|
||||||
|
border: 1px solid rgb(229 231 235); /* gray-200 */
|
||||||
|
background: #fff;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 30px -12px rgba(2, 6, 23, 0.25),
|
||||||
|
0 2px 8px -4px rgba(2, 6, 23, 0.12);
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
min-height: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
color: rgb(31 41 55); /* gray-800 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.abrban-toast-container .Toastify__toast-body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.abrban-toast-container .Toastify__close-button {
|
||||||
|
color: rgb(156 163 175); /* gray-400 */
|
||||||
|
opacity: 1;
|
||||||
|
align-self: center;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
.abrban-toast-container .Toastify__close-button:hover {
|
||||||
|
color: rgb(75 85 99); /* gray-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thin, type-colored progress bar in place of the default rainbow gradients. */
|
||||||
|
.abrban-toast-container .Toastify__progress-bar {
|
||||||
|
height: 3px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.abrban-toast-container .Toastify__progress-bar--success { background: rgb(16 185 129); }
|
||||||
|
.abrban-toast-container .Toastify__progress-bar--error { background: rgb(239 68 68); }
|
||||||
|
.abrban-toast-container .Toastify__progress-bar--warning { background: rgb(245 158 11); }
|
||||||
|
.abrban-toast-container .Toastify__progress-bar--info { background: rgb(59 130 246); }
|
||||||
|
|
||||||
|
/* RTL: keep the colored accent border on the leading (right) edge. */
|
||||||
|
.abrban-toast-container.Toastify__toast-container--rtl {
|
||||||
|
right: 1rem;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { Loader2, Upload, Hammer, Rocket, CheckCircle, XCircle, X, Minimize2 } from 'lucide-react';
|
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 { useT } from '@/i18n/I18nProvider';
|
||||||
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
||||||
|
|
||||||
@@ -54,13 +54,13 @@ export function BuildProgressModal({
|
|||||||
const cancelMutation = useMutation({
|
const cancelMutation = useMutation({
|
||||||
mutationFn: () => api.post(`/deployments/applications/${appId}/cancel`),
|
mutationFn: () => api.post(`/deployments/applications/${appId}/cancel`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(c.deploymentCancelled);
|
notify.success(c.deploymentCancelled);
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
|
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['build-progress', appId] });
|
queryClient.invalidateQueries({ queryKey: ['build-progress', appId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
},
|
},
|
||||||
onError: () => toast.error(c.cancelDeploymentFailed),
|
onError: () => notify.error(c.cancelDeploymentFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = phaseConfig[progress.phase];
|
const cfg = phaseConfig[progress.phase];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import type { AppSnapshot } from '@/types';
|
import type { AppSnapshot } from '@/types';
|
||||||
import { formatBytes } from '@/lib/format-utils';
|
import { formatBytes } from '@/lib/format-utils';
|
||||||
@@ -87,10 +87,10 @@ export function DatabaseSnapshotsPanel({
|
|||||||
snap.status === 'completed' &&
|
snap.status === 'completed' &&
|
||||||
snap.dbDumpPath
|
snap.dbDumpPath
|
||||||
) {
|
) {
|
||||||
toast.success(sn.backupReady);
|
notify.success(sn.backupReady);
|
||||||
}
|
}
|
||||||
if (prevInProgressRef.current.has(snap.id) && snap.status === 'failed') {
|
if (prevInProgressRef.current.has(snap.id) && snap.status === 'failed') {
|
||||||
toast.error(snap.errorMessage || sn.backupFailed);
|
notify.error(sn.backupFailed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevInProgressRef.current = inProgressIds;
|
prevInProgressRef.current = inProgressIds;
|
||||||
@@ -104,13 +104,10 @@ export function DatabaseSnapshotsPanel({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setShowPanel(true);
|
setShowPanel(true);
|
||||||
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
|
||||||
toast.info(sn.backupStarted);
|
notify.info(sn.backupStarted);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data
|
notify.error(err, sn.createFailed);
|
||||||
?.message;
|
|
||||||
const text = Array.isArray(msg) ? msg.join(', ') : msg;
|
|
||||||
toast.error(text || sn.createFailed);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,9 +122,9 @@ export function DatabaseSnapshotsPanel({
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
|
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) => {
|
const downloadSnapshotDb = (snapshotId: string) => {
|
||||||
@@ -144,9 +141,9 @@ export function DatabaseSnapshotsPanel({
|
|||||||
link.download = `database-backup-${snapshotId.slice(0, 8)}.sql`;
|
link.download = `database-backup-${snapshotId.slice(0, 8)}.sql`;
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(link.href);
|
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) => {
|
const handleDelete = async (snap: AppSnapshot) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-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 { useT } from '@/i18n/I18nProvider';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
|
import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
|
||||||
@@ -57,11 +57,11 @@ export function ManagedDatabaseConfig({
|
|||||||
|
|
||||||
const acceptDump = (f: File) => {
|
const acceptDump = (f: File) => {
|
||||||
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
||||||
toast.error(c.allowedFormats);
|
notify.error(c.allowedFormats);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (f.size > 500 * 1024 * 1024) {
|
if (f.size > 500 * 1024 * 1024) {
|
||||||
toast.error(c.maxSize);
|
notify.error(c.maxSize);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onDbDumpFileChange(f);
|
onDbDumpFileChange(f);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import { useT } from '@/i18n/I18nProvider';
|
import { useT } from '@/i18n/I18nProvider';
|
||||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||||
import type { Application, Invoice, OptionalServiceResourcesMap, ResourceUsage } from '@/types';
|
import type { Application, Invoice, OptionalServiceResourcesMap, ResourceUsage } from '@/types';
|
||||||
@@ -167,11 +167,10 @@ export function ManagedServiceResourcesPanel({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
|
||||||
toast.success(sr.resourcesUpdated);
|
notify.success(sr.resourcesUpdated);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
notify.error(err, sr.updateFailed);
|
||||||
toast.error(msg || sr.updateFailed);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,14 +187,13 @@ export function ManagedServiceResourcesPanel({
|
|||||||
setPendingUpgradePayload(null);
|
setPendingUpgradePayload(null);
|
||||||
const paidAmount = res.data.paidAmount || 0;
|
const paidAmount = res.data.paidAmount || 0;
|
||||||
if (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 {
|
} else {
|
||||||
toast.success(sr.updatedSuccess);
|
notify.success(sr.updatedSuccess);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
notify.error(err, sr.upgradeFailed);
|
||||||
toast.error(msg || sr.upgradeFailed);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,8 +205,7 @@ export function ManagedServiceResourcesPanel({
|
|||||||
setShowUpgradeConfirm(true);
|
setShowUpgradeConfirm(true);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
notify.error(err, sr.calcFailed);
|
||||||
toast.error(msg || sr.calcFailed);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,7 +213,7 @@ export function ManagedServiceResourcesPanel({
|
|||||||
mutationFn: (data: UpgradePayload) =>
|
mutationFn: (data: UpgradePayload) =>
|
||||||
api.post<Invoice>(`/billing/applications/${serviceId}/upgrade/invoice`, data).then((r) => r.data),
|
api.post<Invoice>(`/billing/applications/${serviceId}/upgrade/invoice`, data).then((r) => r.data),
|
||||||
onSuccess: (invoice) => {
|
onSuccess: (invoice) => {
|
||||||
toast.success(sr.invoiceCreated);
|
notify.success(sr.invoiceCreated);
|
||||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||||
setShowUpgradeConfirm(false);
|
setShowUpgradeConfirm(false);
|
||||||
setUpgradeCostData(null);
|
setUpgradeCostData(null);
|
||||||
@@ -224,8 +221,7 @@ export function ManagedServiceResourcesPanel({
|
|||||||
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
|
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
notify.error(err, sr.invoiceFailed);
|
||||||
toast.error(msg || sr.invoiceFailed);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,12 +236,11 @@ export function ManagedServiceResourcesPanel({
|
|||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
const data = res.data;
|
const data = res.data;
|
||||||
setDbRestoreLogs(data.logs || null);
|
setDbRestoreLogs(data.logs || null);
|
||||||
if (data.success) toast.success(sr.restoredSuccess);
|
if (data.success) notify.success(sr.restoredSuccess);
|
||||||
else toast.error(data.message || sr.restoreFailed);
|
else notify.error(sr.restoreFailed);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
notify.error(err, sr.uploadFailed);
|
||||||
toast.error(msg || sr.uploadFailed);
|
|
||||||
setDbRestoreLogs(null);
|
setDbRestoreLogs(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -253,11 +248,11 @@ export function ManagedServiceResourcesPanel({
|
|||||||
const handleDbFileUpload = useCallback(
|
const handleDbFileUpload = useCallback(
|
||||||
(file: File) => {
|
(file: File) => {
|
||||||
if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) {
|
if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) {
|
||||||
toast.error(sr.invalidFile);
|
notify.error(sr.invalidFile);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > 500 * 1024 * 1024) {
|
if (file.size > 500 * 1024 * 1024) {
|
||||||
toast.error(sr.fileTooLarge);
|
notify.error(sr.fileTooLarge);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDbRestoreLogs(null);
|
setDbRestoreLogs(null);
|
||||||
@@ -300,7 +295,7 @@ export function ManagedServiceResourcesPanel({
|
|||||||
|
|
||||||
const applyResources = () => {
|
const applyResources = () => {
|
||||||
if (needsRenewal) {
|
if (needsRenewal) {
|
||||||
toast.warn(sr.renewFirst);
|
notify.warning(sr.renewFirst);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { ToastContainer } from 'react-toastify';
|
|||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
|
import { useLocale } from '@/i18n/I18nProvider';
|
||||||
|
import { dirFor } from '@/i18n/config';
|
||||||
import { ModalProvider } from './confirm-modal';
|
import { ModalProvider } from './confirm-modal';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -18,6 +20,8 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const loadUser = useAuthStore((s) => s.loadUser);
|
const loadUser = useAuthStore((s) => s.loadUser);
|
||||||
|
const locale = useLocale();
|
||||||
|
const isRtl = dirFor(locale) === 'rtl';
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -32,7 +36,19 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
<ModalProvider>
|
<ModalProvider>
|
||||||
{children}
|
{children}
|
||||||
</ModalProvider>
|
</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>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
import { useT } from '@/i18n/I18nProvider';
|
import { useT } from '@/i18n/I18nProvider';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import type { Application, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
|
import type { Application, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
|
||||||
@@ -70,11 +70,10 @@ export function ServiceExternalAccessPanel({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refetchAccessGrants();
|
refetchAccessGrants();
|
||||||
queryClient.invalidateQueries({ queryKey: ['access-grants', appId] });
|
queryClient.invalidateQueries({ queryKey: ['access-grants', appId] });
|
||||||
toast.success(accessPersistent ? ea.permanentEnabled : ea.temporaryEnabled);
|
notify.success(accessPersistent ? ea.permanentEnabled : ea.temporaryEnabled);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
notify.error(err, ea.enableFailed);
|
||||||
toast.error(msg || ea.enableFailed);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,9 +82,9 @@ export function ServiceExternalAccessPanel({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refetchAccessGrants();
|
refetchAccessGrants();
|
||||||
queryClient.invalidateQueries({ queryKey: ['access-grants', appId] });
|
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) => {
|
const copyToClipboard = (text: string, field: string) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { createContext, useContext, type ReactNode } from 'react';
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
import type { Locale } from './config';
|
import type { Locale } from './config';
|
||||||
import type { Dictionary } from './dictionaries/fa';
|
import type { Dictionary } from './dictionaries/fa';
|
||||||
|
import { setNotifyDict } from '@/lib/notify';
|
||||||
|
|
||||||
type I18nContextValue = {
|
type I18nContextValue = {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
@@ -20,6 +21,9 @@ export function I18nProvider({
|
|||||||
dict: Dictionary;
|
dict: Dictionary;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
// Register the active dictionary so the standalone `notify.error` helper can
|
||||||
|
// resolve localized, user-friendly error messages.
|
||||||
|
setNotifyDict(dict);
|
||||||
return <I18nContext.Provider value={{ locale, dict }}>{children}</I18nContext.Provider>;
|
return <I18nContext.Provider value={{ locale, dict }}>{children}</I18nContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ const en: Dictionary = {
|
|||||||
currencyShort: 'T',
|
currencyShort: 'T',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
generic: 'Something went wrong. Please try again.',
|
||||||
|
network: 'Could not reach the server. Check your internet connection.',
|
||||||
|
timeout: 'The server took too long to respond. Please try again.',
|
||||||
|
unauthorized: 'Your session has expired. Please sign in again.',
|
||||||
|
forbidden: 'You do not have permission to do this.',
|
||||||
|
notFound: 'The requested item was not found.',
|
||||||
|
conflict: 'This action conflicts with the current state. Refresh and try again.',
|
||||||
|
validation: 'The information you entered is invalid. Please check your input.',
|
||||||
|
rateLimit: 'Too many requests. Please wait a moment and try again.',
|
||||||
|
server: 'A server error occurred. Please try again shortly.',
|
||||||
|
},
|
||||||
|
|
||||||
language: {
|
language: {
|
||||||
label: 'Language',
|
label: 'Language',
|
||||||
},
|
},
|
||||||
@@ -1080,7 +1093,7 @@ const en: Dictionary = {
|
|||||||
upgradedPaid: 'Resources upgraded! Paid {amount} Toman',
|
upgradedPaid: 'Resources upgraded! Paid {amount} Toman',
|
||||||
upgradeFailed: 'Failed to upgrade resources', calcFailed: 'Failed to calculate upgrade cost',
|
upgradeFailed: 'Failed to upgrade resources', calcFailed: 'Failed to calculate upgrade cost',
|
||||||
invoiceCreated: 'Invoice created. Choose how you want to pay.', invoiceFailed: 'Failed to create upgrade invoice',
|
invoiceCreated: 'Invoice created. Choose how you want to pay.', invoiceFailed: 'Failed to create upgrade invoice',
|
||||||
dbRestored: 'Database restored successfully!', dbInvalidFile: 'Please upload a .sql, .dump, or .gz file',
|
dbRestored: 'Database restored successfully!', dbRestoreFailed: 'Database restore failed.', dbInvalidFile: 'Please upload a .sql, .dump, or .gz file',
|
||||||
fileMax500: 'File size must be less than 500MB', dbUploadFailed: 'Failed to upload database dump',
|
fileMax500: 'File size must be less than 500MB', dbUploadFailed: 'Failed to upload database dump',
|
||||||
snapshotStarted: 'Snapshot creation started', snapshotCreateFailed: 'Failed to create snapshot',
|
snapshotStarted: 'Snapshot creation started', snapshotCreateFailed: 'Failed to create snapshot',
|
||||||
snapshotDeleted: 'Snapshot deleted', snapshotDeleteFailed: 'Failed to delete snapshot',
|
snapshotDeleted: 'Snapshot deleted', snapshotDeleteFailed: 'Failed to delete snapshot',
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ const fa = {
|
|||||||
currencyShort: 'ت',
|
currencyShort: 'ت',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
generic: 'مشکلی پیش آمد. لطفاً دوباره تلاش کنید.',
|
||||||
|
network: 'ارتباط با سرور برقرار نشد. اتصال اینترنت خود را بررسی کنید.',
|
||||||
|
timeout: 'پاسخ سرور بیش از حد طول کشید. لطفاً دوباره تلاش کنید.',
|
||||||
|
unauthorized: 'نشست شما منقضی شده است. لطفاً دوباره وارد شوید.',
|
||||||
|
forbidden: 'شما اجازهٔ انجام این کار را ندارید.',
|
||||||
|
notFound: 'مورد درخواستی پیدا نشد.',
|
||||||
|
conflict: 'این عملیات با وضعیت فعلی تداخل دارد. صفحه را تازه کنید و دوباره تلاش کنید.',
|
||||||
|
validation: 'اطلاعات واردشده درست نیست. لطفاً ورودیها را بررسی کنید.',
|
||||||
|
rateLimit: 'تعداد درخواستها زیاد است. کمی صبر کنید و دوباره تلاش کنید.',
|
||||||
|
server: 'خطایی در سرور رخ داد. لطفاً کمی بعد دوباره تلاش کنید.',
|
||||||
|
},
|
||||||
|
|
||||||
language: {
|
language: {
|
||||||
label: 'زبان',
|
label: 'زبان',
|
||||||
},
|
},
|
||||||
@@ -1081,7 +1094,7 @@ const fa = {
|
|||||||
upgradedPaid: 'منابع ارتقا یافت! {amount} تومان پرداخت شد',
|
upgradedPaid: 'منابع ارتقا یافت! {amount} تومان پرداخت شد',
|
||||||
upgradeFailed: 'ارتقای منابع ناموفق بود', calcFailed: 'محاسبهٔ هزینهٔ ارتقا ناموفق بود',
|
upgradeFailed: 'ارتقای منابع ناموفق بود', calcFailed: 'محاسبهٔ هزینهٔ ارتقا ناموفق بود',
|
||||||
invoiceCreated: 'فاکتور ساخته شد. روش پرداخت را انتخاب کن.', invoiceFailed: 'ساخت فاکتور ارتقا ناموفق بود',
|
invoiceCreated: 'فاکتور ساخته شد. روش پرداخت را انتخاب کن.', invoiceFailed: 'ساخت فاکتور ارتقا ناموفق بود',
|
||||||
dbRestored: 'دیتابیس با موفقیت بازیابی شد!', dbInvalidFile: 'لطفاً فایل .sql، .dump یا .gz آپلود کن',
|
dbRestored: 'دیتابیس با موفقیت بازیابی شد!', dbRestoreFailed: 'بازیابی دیتابیس ناموفق بود.', dbInvalidFile: 'لطفاً فایل .sql، .dump یا .gz آپلود کن',
|
||||||
fileMax500: 'حجم فایل باید کمتر از ۵۰۰ مگابایت باشد', dbUploadFailed: 'آپلود dump دیتابیس ناموفق بود',
|
fileMax500: 'حجم فایل باید کمتر از ۵۰۰ مگابایت باشد', dbUploadFailed: 'آپلود dump دیتابیس ناموفق بود',
|
||||||
snapshotStarted: 'ساخت اسنپشات آغاز شد', snapshotCreateFailed: 'ساخت اسنپشات ناموفق بود',
|
snapshotStarted: 'ساخت اسنپشات آغاز شد', snapshotCreateFailed: 'ساخت اسنپشات ناموفق بود',
|
||||||
snapshotDeleted: 'اسنپشات حذف شد', snapshotDeleteFailed: 'حذف اسنپشات ناموفق بود',
|
snapshotDeleted: 'اسنپشات حذف شد', snapshotDeleteFailed: 'حذف اسنپشات ناموفق بود',
|
||||||
|
|||||||
@@ -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 { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { notify } from '@/lib/notify';
|
||||||
|
|
||||||
type DeleteResponse = { resourceCredit?: unknown };
|
type DeleteResponse = { resourceCredit?: unknown };
|
||||||
|
|
||||||
@@ -34,14 +34,14 @@ export function useApplicationDelete(options?: {
|
|||||||
if (options?.onSuccess) {
|
if (options?.onSuccess) {
|
||||||
options.onSuccess(data, id);
|
options.onSuccess(data, id);
|
||||||
} else if (data?.resourceCredit && options?.successWithCreditMessage) {
|
} else if (data?.resourceCredit && options?.successWithCreditMessage) {
|
||||||
toast.success(options.successWithCreditMessage);
|
notify.success(options.successWithCreditMessage);
|
||||||
} else {
|
} else {
|
||||||
toast.success(options?.successMessage ?? 'Deleted successfully');
|
notify.success(options?.successMessage ?? 'Deleted successfully');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
if (options?.onError) options.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