91a66d5645
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>
878 lines
31 KiB
TypeScript
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>
|
|
);
|
|
}
|