Files
cloud-host/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx
T
keyhan 91a66d5645 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>
2026-06-13 11:45:26 +03:30

878 lines
31 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { notify } from '@/lib/notify';
import { useT } from '@/i18n/I18nProvider';
import type {
PricingCatalog,
PricingRateRow,
OptionalServiceProfileRow,
CustomDomainCatalogRow,
BillingCycle,
PricingResourceType,
LifecycleSettings,
} from '@/types';
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
import { Select } from '@/components/ui/select';
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
/** PATCH body must match UpdatePricingCatalogDto (no runtimeOptions / label). */
function toPricingCatalogPatch(catalog: PricingCatalog) {
const optionalServices: Record<
string,
{ service: string; profile: OptionalServiceProfileRow; rates: PricingRateRow[] }
> = {};
for (const [key, entry] of Object.entries(catalog.optionalServices)) {
optionalServices[key] = {
service: entry.service ?? key,
profile: entry.profile,
rates: entry.rates,
};
}
return {
runtimes: catalog.runtimes,
optionalServices,
customDomain: catalog.customDomain,
};
}
function cloneCatalog(catalog: PricingCatalog): PricingCatalog {
const runtimes: PricingCatalog['runtimes'] = {};
for (const key of Object.keys(catalog.runtimes)) {
runtimes[key] = catalog.runtimes[key].map((r) => ({ ...r }));
}
const optionalServices: PricingCatalog['optionalServices'] = {};
for (const key of Object.keys(catalog.optionalServices)) {
const entry = catalog.optionalServices[key];
optionalServices[key] = {
...entry,
profile: { ...entry.profile },
rates: entry.rates.map((r) => ({ ...r })),
};
}
return {
...catalog,
runtimes,
optionalServices,
customDomain: { ...catalog.customDomain },
};
}
function PricingMatrixTable({
rows,
onChange,
readOnly,
}: {
rows: PricingRateRow[];
onChange: (resourceType: PricingResourceType, cycle: BillingCycle, value: number) => void;
readOnly: boolean;
}) {
const t = useT();
const b = t.dashboard.billing;
return (
<div className="overflow-x-auto">
<table className="w-full text-sm border border-gray-200 rounded-lg overflow-hidden">
<thead className="bg-gray-50">
<tr>
<th className="text-left rtl:text-right p-3 font-medium text-gray-600">{b.resourceCol}</th>
{cycles.map((cycle) => (
<th key={cycle} className="text-left rtl:text-right p-3 font-medium text-gray-600">
{b.cycles[cycle]} ({t.common.currencyShort})
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.resourceType} className="border-t border-gray-100">
<td className="p-3 font-medium text-gray-900">
{(b.resources as Record<string, string>)[row.resourceType] ?? row.resourceType}
</td>
{cycles.map((cycle) => {
const field =
cycle === 'hourly'
? 'hourlyPrice'
: cycle === 'monthly'
? 'monthlyPrice'
: 'yearlyPrice';
const val = row[field];
return (
<td key={cycle} className="p-3">
{readOnly ? (
<span className="font-mono text-gray-700">
{Number(val).toLocaleString('en-US')}
</span>
) : (
<input
type="number"
min={0}
className="input-field w-full max-w-[120px]"
value={val}
onChange={(e) =>
onChange(
row.resourceType,
cycle,
e.target.value === '' ? 0 : Number(e.target.value),
)
}
/>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
function DeployDefaultsFields({
service,
profile,
readOnly,
onUpdate,
}: {
service: string;
profile: OptionalServiceProfileRow;
readOnly: boolean;
onUpdate: (patch: Partial<OptionalServiceProfileRow>) => void;
}) {
const b = useT().dashboard.billing;
const isLogging = service === 'elasticsearch';
if (isLogging) {
return (
<div className="rounded-lg border border-yellow-200 bg-yellow-50/40 p-4 space-y-3">
<p className="text-xs text-gray-600">{b.loggingNote}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-gray-600">{b.logShipperCpuLimit}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.logShipperCpuLimit || '—'}</p>
) : (
<input
className="input-field w-full text-sm mt-0.5"
placeholder="50m"
value={profile.logShipperCpuLimit ?? ''}
onChange={(e) => onUpdate({ logShipperCpuLimit: e.target.value })}
/>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">{b.logShipperMemoryLimit}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.logShipperMemoryLimit || '—'}</p>
) : (
<input
className="input-field w-full text-sm mt-0.5"
placeholder="64Mi"
value={profile.logShipperMemoryLimit ?? ''}
onChange={(e) => onUpdate({ logShipperMemoryLimit: e.target.value })}
/>
)}
</div>
</div>
</div>
);
}
return (
<div className="rounded-lg border border-gray-200 bg-gray-50/60 p-4 space-y-3">
<p className="text-xs text-gray-600">{b.defaultsNote}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-gray-600">{b.cpuRequest}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.cpuRequest || '—'}</p>
) : (
<Select
size="md"
className="mt-0.5"
ariaLabel={b.cpuRequest}
value={profile.cpuRequest || '50m'}
onChange={(v) => onUpdate({ cpuRequest: v })}
options={[
{ value: '50m', label: '50m' },
{ value: '100m', label: '100m' },
{ value: '250m', label: '250m' },
{ value: '500m', label: '500m' },
]}
/>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">{b.cpuLimit}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.cpuLimit}</p>
) : (
<Select
size="md"
className="mt-0.5"
ariaLabel={b.cpuLimit}
value={profile.cpuLimit}
onChange={(v) => onUpdate({ cpuLimit: v })}
options={[
{ value: '200m', label: '200m' },
{ value: '250m', label: '250m' },
{ value: '500m', label: '500m' },
{ value: '1', label: b.oneCore },
{ value: '2', label: b.twoCores },
]}
/>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">{b.memoryRequest}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.memoryRequest || '—'}</p>
) : (
<Select
size="md"
className="mt-0.5"
ariaLabel={b.memoryRequest}
value={profile.memoryRequest || '64Mi'}
onChange={(v) => onUpdate({ memoryRequest: v })}
options={[
{ value: '64Mi', label: '64 Mi' },
{ value: '128Mi', label: '128 Mi' },
{ value: '256Mi', label: '256 Mi' },
{ value: '512Mi', label: '512 Mi' },
]}
/>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">{b.memoryLimit}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.memoryLimit}</p>
) : (
<Select
size="md"
className="mt-0.5"
ariaLabel={b.memoryLimit}
value={profile.memoryLimit}
onChange={(v) => onUpdate({ memoryLimit: v })}
options={[
{ value: '256Mi', label: '256 Mi' },
{ value: '512Mi', label: '512 Mi' },
{ value: '1Gi', label: '1 Gi' },
{ value: '2Gi', label: '2 Gi' },
]}
/>
)}
</div>
<div className="sm:col-span-2">
<label className="text-xs font-medium text-gray-600">{b.storageGi}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.storageGi}</p>
) : (
<input
type="number"
min={0}
step={0.1}
className="input-field w-full max-w-[140px] text-sm mt-0.5"
value={profile.storageGi}
onChange={(e) =>
onUpdate({ storageGi: e.target.value === '' ? 0 : Number(e.target.value) })
}
/>
)}
</div>
</div>
</div>
);
}
function CustomDomainPricing({
customDomain,
readOnly,
onChange,
}: {
customDomain: CustomDomainCatalogRow;
readOnly: boolean;
onChange: (cycle: BillingCycle, value: number) => void;
}) {
const t = useT();
const b = t.dashboard.billing;
return (
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-3">{b.customDomainTitle}</h3>
<p className="text-xs text-gray-500 mb-3">{b.customDomainSub}</p>
<div className="grid grid-cols-3 gap-2">
{cycles.map((cycle) => {
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
return (
<div key={cycle}>
<label className="text-xs text-gray-500">{b.cycles[cycle]} ({t.common.currencyShort})</label>
{readOnly ? (
<p className="font-mono text-sm">{Number(customDomain[field]).toLocaleString('en-US')}</p>
) : (
<input
type="number"
min={0}
className="input-field w-full text-sm mt-0.5"
value={customDomain[field]}
onChange={(e) =>
onChange(cycle, e.target.value === '' ? 0 : Number(e.target.value))
}
/>
)}
</div>
);
})}
</div>
</div>
);
}
export default function AdminBillingPage() {
const t = useT();
const b = t.dashboard.billing;
const queryClient = useQueryClient();
const [activeRuntime, setActiveRuntime] = useState<string>('nodejs');
const [activeOptionalService, setActiveOptionalService] = useState<string>('redis');
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<PricingCatalog | null>(null);
const { data: catalog, isLoading } = useQuery<PricingCatalog>({
queryKey: ['pricing-catalog'],
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
});
useEffect(() => {
const options = catalog?.runtimeOptions ?? [];
if (options.length === 0) return;
if (!options.some((o) => o.value === activeRuntime)) {
setActiveRuntime(options[0].value);
}
}, [catalog, activeRuntime]);
useEffect(() => {
const options = catalog?.optionalServiceOptions ?? [];
if (options.length === 0) return;
if (!options.some((o) => o.value === activeOptionalService)) {
setActiveOptionalService(options[0].value);
}
}, [catalog, activeOptionalService]);
const saveMutation = useMutation({
mutationFn: (catalog: PricingCatalog) =>
api.patch('/billing/pricing-catalog', toPricingCatalogPatch(catalog)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] });
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
notify.success(b.saved);
setEditing(false);
setDraft(null);
},
onError: (err: unknown) => {
notify.error(err, b.saveFailed);
},
});
const display = editing && draft ? draft : catalog;
const runtimeTabs = display?.runtimeOptions ?? catalog?.runtimeOptions ?? [];
const startEdit = () => {
if (!catalog) return;
setDraft(cloneCatalog(catalog));
if (!activeRuntime && catalog.runtimeOptions[0]) {
setActiveRuntime(catalog.runtimeOptions[0].value);
}
setEditing(true);
};
const updateRuntimePrice = (
resourceType: PricingResourceType,
cycle: BillingCycle,
value: number,
) => {
if (!draft) return;
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
setDraft({
...draft,
runtimes: {
...draft.runtimes,
[activeRuntime]: (draft.runtimes[activeRuntime] ?? []).map((row) =>
row.resourceType === resourceType ? { ...row, [field]: value } : row,
),
},
});
};
const updateOptionalProfile = (patch: Partial<OptionalServiceProfileRow>) => {
if (!draft) return;
const entry = draft.optionalServices[activeOptionalService];
if (!entry) return;
setDraft({
...draft,
optionalServices: {
...draft.optionalServices,
[activeOptionalService]: {
...entry,
profile: { ...entry.profile, ...patch },
},
},
});
};
const updateOptionalRate = (
resourceType: PricingResourceType,
cycle: BillingCycle,
value: number,
) => {
if (!draft) return;
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
const entry = draft.optionalServices[activeOptionalService];
if (!entry) return;
setDraft({
...draft,
optionalServices: {
...draft.optionalServices,
[activeOptionalService]: {
...entry,
rates: entry.rates.map((row) =>
row.resourceType === resourceType ? { ...row, [field]: value } : row,
),
},
},
});
};
const updateCustomDomain = (cycle: BillingCycle, value: number) => {
if (!draft) return;
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
setDraft({
...draft,
customDomain: { ...draft.customDomain, [field]: value },
});
};
const fillYearlyFromMonthly = (scope: 'runtime' | 'optional') => {
if (!draft) return;
if (scope === 'runtime') {
setDraft({
...draft,
runtimes: {
...draft.runtimes,
[activeRuntime]: (draft.runtimes[activeRuntime] ?? []).map((row) => ({
...row,
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
})),
},
});
} else {
const entry = draft.optionalServices[activeOptionalService];
if (!entry) return;
setDraft({
...draft,
optionalServices: {
...draft.optionalServices,
[activeOptionalService]: {
...entry,
rates: entry.rates.map((row) => ({
...row,
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
})),
},
},
});
}
};
const handleSave = () => {
if (!draft) return;
saveMutation.mutate(draft);
};
const runtimeRows = display?.runtimes[activeRuntime] ?? [];
const optionalServiceTabs = display?.optionalServiceOptions ?? [];
const activeOptionalEntry = display?.optionalServices[activeOptionalService];
const optionalRateRows = activeOptionalEntry?.rates ?? [];
const customDomain = display?.customDomain;
return (
<div className="max-w-5xl mx-auto space-y-6 animate-fade-in">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="page-title flex items-center gap-2">
<DollarSign className="w-6 h-6" /> {b.title}
</h1>
<p className="page-subtitle">{b.subtitle}</p>
</div>
{!editing ? (
<button
onClick={startEdit}
disabled={!catalog}
className="btn-primary flex items-center gap-2 shrink-0"
>
<Edit2 className="w-4 h-4" /> {b.editPlans}
</button>
) : (
<div className="flex gap-2 shrink-0">
<button
onClick={handleSave}
disabled={saveMutation.isPending}
className="btn-primary text-sm disabled:opacity-50"
>
{saveMutation.isPending ? b.saving : b.save}
</button>
<button
onClick={() => {
setEditing(false);
setDraft(null);
}}
className="btn-secondary text-sm"
>
{t.common.cancel}
</button>
</div>
)}
</div>
<div className="rounded-xl border border-blue-100 bg-blue-50/50 p-4 flex gap-3 text-sm text-blue-900">
<Info className="w-5 h-5 shrink-0 text-blue-600 mt-0.5" />
<div className="space-y-1">
<p className="font-medium">{b.howTitle}</p>
<ul className="list-disc list-inside text-blue-800/90 space-y-0.5 text-xs sm:text-sm">
<li>{b.howApps}</li>
<li>{b.howOptional}</li>
<li>{b.howDefaults}</li>
</ul>
</div>
</div>
{isLoading ? (
<div className="text-center py-12 text-gray-400">{t.common.loading}</div>
) : !display ? (
<div className="text-center py-12 text-gray-400">{b.noPricing}</div>
) : (
<>
<div className="card space-y-4">
<div className="flex items-start gap-3">
<Box className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{b.appRuntimes}</h2>
<p className="text-sm text-gray-500 mt-0.5">{b.appRuntimesSub}</p>
</div>
</div>
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
{runtimeTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setActiveRuntime(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeRuntime === tab.value
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-800">
{runtimeTabs.find((tab) => tab.value === activeRuntime)?.label} {b.unitPricesSuffix}
</h3>
{editing && (
<button
type="button"
onClick={() => fillYearlyFromMonthly('runtime')}
className="btn-secondary text-xs"
>
{b.fillYearly}
</button>
)}
</div>
<PricingMatrixTable
rows={runtimeRows}
readOnly={!editing}
onChange={updateRuntimePrice}
/>
</div>
<div className="card space-y-4">
<div className="flex items-start gap-3">
<Layers className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{b.optionalServices}</h2>
<p className="text-sm text-gray-500 mt-0.5">{b.optionalServicesSub}</p>
</div>
</div>
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
{optionalServiceTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setActiveOptionalService(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeOptionalService === tab.value
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-800">
{optionalServiceTabs.find((tab) => tab.value === activeOptionalService)?.label} {b.unitPricesSuffix}
</h3>
{editing && activeOptionalEntry && (
<button
type="button"
onClick={() => fillYearlyFromMonthly('optional')}
className="btn-secondary text-xs"
>
{b.fillYearly}
</button>
)}
</div>
{activeOptionalEntry && (
<PricingMatrixTable
rows={optionalRateRows}
readOnly={!editing}
onChange={updateOptionalRate}
/>
)}
</div>
<div className="card space-y-4">
<div className="flex items-start gap-3">
<Server className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{b.deployDefaults}</h2>
<p className="text-sm text-gray-500 mt-0.5">{b.deployDefaultsSub}</p>
</div>
</div>
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
{optionalServiceTabs.map((tab) => (
<button
key={`defaults-${tab.value}`}
type="button"
onClick={() => setActiveOptionalService(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeOptionalService === tab.value
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
{activeOptionalEntry && (
<DeployDefaultsFields
service={activeOptionalService}
profile={activeOptionalEntry.profile}
readOnly={!editing}
onUpdate={updateOptionalProfile}
/>
)}
</div>
{customDomain && (
<div className="card space-y-4">
<div className="flex items-start gap-3">
<Globe className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{b.addons}</h2>
<p className="text-sm text-gray-500 mt-0.5">{b.addonsSub}</p>
</div>
</div>
<CustomDomainPricing
customDomain={customDomain}
readOnly={!editing}
onChange={updateCustomDomain}
/>
</div>
)}
</>
)}
<LifecycleSettingsSection />
</div>
);
}
function LifecycleSettingsSection() {
const t = useT();
const b = t.dashboard.billing;
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [hourlyHours, setHourlyHours] = useState('');
const [monthlyDays, setMonthlyDays] = useState('');
const [yearlyDays, setYearlyDays] = useState('');
const { data: settings, isLoading } = useQuery<LifecycleSettings>({
queryKey: ['lifecycle-settings'],
queryFn: () => api.get('/lifecycle/settings').then((r) => r.data),
});
const saveMutation = useMutation({
mutationFn: (body: Record<string, number>) => api.patch('/lifecycle/settings', body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] });
notify.success(b.lifecycleSaved);
setEditing(false);
},
onError: (err: unknown) => {
notify.error(err, b.saveFailedShort);
},
});
const startEditing = () => {
if (settings) {
setHourlyHours(String((settings.hourly.deleteAfterMs || 0) / 3600000));
setMonthlyDays(String((settings.monthly.deleteAfterMs || 0) / 86400000));
setYearlyDays(String((settings.yearly.deleteAfterMs || 0) / 86400000));
}
setEditing(true);
};
const handleSave = () => {
const body: Record<string, number> = {};
if (hourlyHours) body.hourlyDeleteAfterMs = Number(hourlyHours) * 3600000;
if (monthlyDays) body.monthlyDeleteAfterMs = Number(monthlyDays) * 86400000;
if (yearlyDays) body.yearlyDeleteAfterMs = Number(yearlyDays) * 86400000;
saveMutation.mutate(body);
};
return (
<div className="card mt-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-red-500" />
<h2 className="text-lg font-semibold text-gray-900">{b.retentionTitle}</h2>
</div>
{!editing && (
<button onClick={startEditing} className="btn-secondary text-xs flex items-center gap-1">
<Edit2 className="w-3 h-3" /> {b.edit}
</button>
)}
</div>
<p className="text-sm text-gray-500 mb-4">{b.retentionSub}</p>
{isLoading ? (
<div className="text-center py-6 text-gray-400">{t.common.loading}</div>
) : editing ? (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-blue-50 border border-blue-100">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-blue-600" />
<h3 className="font-semibold text-blue-900">{b.hourlyPlans}</h3>
</div>
<label className="text-xs text-blue-700 font-medium">{b.deleteAfterHours}</label>
<input
type="number"
className="input-field mt-1 text-sm"
value={hourlyHours}
onChange={(e) => setHourlyHours(e.target.value)}
min={1}
placeholder="24"
/>
</div>
<div className="p-4 rounded-xl bg-purple-50 border border-purple-100">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-purple-600" />
<h3 className="font-semibold text-purple-900">{b.monthlyPlans}</h3>
</div>
<label className="text-xs text-purple-700 font-medium">{b.deleteAfterDays}</label>
<input
type="number"
className="input-field mt-1 text-sm"
value={monthlyDays}
onChange={(e) => setMonthlyDays(e.target.value)}
min={1}
placeholder="3"
/>
</div>
<div className="p-4 rounded-xl bg-green-50 border border-green-100">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-green-600" />
<h3 className="font-semibold text-green-900">{b.yearlyPlans}</h3>
</div>
<label className="text-xs text-green-700 font-medium">{b.deleteAfterDays}</label>
<input
type="number"
className="input-field mt-1 text-sm"
value={yearlyDays}
onChange={(e) => setYearlyDays(e.target.value)}
min={1}
placeholder="7"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setEditing(false)} className="btn-ghost">
{t.common.cancel}
</button>
<button
onClick={handleSave}
disabled={saveMutation.isPending}
className="btn-primary disabled:opacity-50"
>
{saveMutation.isPending ? b.saving : b.saveSettings}
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-blue-50/50 border border-blue-100">
<h3 className="font-semibold text-blue-900 flex items-center gap-1 text-sm">
<Clock className="w-4 h-4" /> {b.hourlyPlans}
</h3>
<p className="text-2xl font-bold text-blue-700 mt-2">
{settings?.hourly.deleteAfterHours ??
Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)}
<span className="text-sm font-normal ml-1 rtl:ml-0 rtl:mr-1">{b.hours}</span>
</p>
</div>
<div className="p-4 rounded-xl bg-purple-50/50 border border-purple-100">
<h3 className="font-semibold text-purple-900 flex items-center gap-1 text-sm">
<Clock className="w-4 h-4" /> {b.monthlyPlans}
</h3>
<p className="text-2xl font-bold text-purple-700 mt-2">
{settings?.monthly.deleteAfterDays ??
Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)}
<span className="text-sm font-normal ml-1 rtl:ml-0 rtl:mr-1">{b.days}</span>
</p>
</div>
<div className="p-4 rounded-xl bg-green-50/50 border border-green-100">
<h3 className="font-semibold text-green-900 flex items-center gap-1 text-sm">
<Clock className="w-4 h-4" /> {b.yearlyPlans}
</h3>
<p className="text-2xl font-bold text-green-700 mt-2">
{settings?.yearly.deleteAfterDays ??
Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)}
<span className="text-sm font-normal ml-1 rtl:ml-0 rtl:mr-1">{b.days}</span>
</p>
</div>
</div>
)}
</div>
);
}