'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 (
);
}
function DeployDefaultsFields({
service,
profile,
readOnly,
onUpdate,
}: {
service: string;
profile: OptionalServiceProfileRow;
readOnly: boolean;
onUpdate: (patch: Partial) => void;
}) {
const b = useT().dashboard.billing;
const isLogging = service === 'elasticsearch';
if (isLogging) {
return (
{b.loggingNote}
{readOnly ? (
{profile.logShipperCpuLimit || '—'}
) : (
onUpdate({ logShipperCpuLimit: e.target.value })}
/>
)}
{readOnly ? (
{profile.logShipperMemoryLimit || '—'}
) : (
onUpdate({ logShipperMemoryLimit: e.target.value })}
/>
)}
);
}
return (
{b.defaultsNote}
{readOnly ? (
{profile.cpuRequest || '—'}
) : (
{readOnly ? (
{profile.cpuLimit}
) : (
{readOnly ? (
{profile.memoryRequest || '—'}
) : (
{readOnly ? (
{profile.memoryLimit}
) : (
{readOnly ? (
{profile.storageGi}
) : (
onUpdate({ storageGi: e.target.value === '' ? 0 : Number(e.target.value) })
}
/>
)}
);
}
function CustomDomainPricing({
customDomain,
readOnly,
onChange,
}: {
customDomain: CustomDomainCatalogRow;
readOnly: boolean;
onChange: (cycle: BillingCycle, value: number) => void;
}) {
const t = useT();
const b = t.dashboard.billing;
return (
{b.customDomainTitle}
{b.customDomainSub}
{cycles.map((cycle) => {
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
return (
{readOnly ? (
{Number(customDomain[field]).toLocaleString('en-US')}
) : (
onChange(cycle, e.target.value === '' ? 0 : Number(e.target.value))
}
/>
)}
);
})}
);
}
export default function AdminBillingPage() {
const t = useT();
const b = t.dashboard.billing;
const queryClient = useQueryClient();
const [activeRuntime, setActiveRuntime] = useState('nodejs');
const [activeOptionalService, setActiveOptionalService] = useState('redis');
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(null);
const { data: catalog, isLoading } = useQuery({
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) => {
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 (
{!editing ? (
) : (
)}
{b.howTitle}
- {b.howApps}
- {b.howOptional}
- {b.howDefaults}
{isLoading ? (
{t.common.loading}
) : !display ? (
{b.noPricing}
) : (
<>
{b.appRuntimes}
{b.appRuntimesSub}
{runtimeTabs.map((tab) => (
))}
{runtimeTabs.find((tab) => tab.value === activeRuntime)?.label} {b.unitPricesSuffix}
{editing && (
)}
{b.optionalServices}
{b.optionalServicesSub}
{optionalServiceTabs.map((tab) => (
))}
{optionalServiceTabs.find((tab) => tab.value === activeOptionalService)?.label} {b.unitPricesSuffix}
{editing && activeOptionalEntry && (
)}
{activeOptionalEntry && (
)}
{b.deployDefaults}
{b.deployDefaultsSub}
{optionalServiceTabs.map((tab) => (
))}
{activeOptionalEntry && (
)}
{customDomain && (
)}
>
)}
);
}
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({
queryKey: ['lifecycle-settings'],
queryFn: () => api.get('/lifecycle/settings').then((r) => r.data),
});
const saveMutation = useMutation({
mutationFn: (body: Record) => 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 = {};
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 (
{b.retentionTitle}
{!editing && (
)}
{b.retentionSub}
{isLoading ? (
{t.common.loading}
) : editing ? (
) : (
{b.hourlyPlans}
{settings?.hourly.deleteAfterHours ??
Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)}
{b.hours}
{b.monthlyPlans}
{settings?.monthly.deleteAfterDays ??
Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)}
{b.days}
{b.yearlyPlans}
{settings?.yearly.deleteAfterDays ??
Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)}
{b.days}
)}
);
}