'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 (
{cycles.map((cycle) => ( ))} {rows.map((row) => ( {cycles.map((cycle) => { const field = cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice'; const val = row[field]; return ( ); })} ))}
{b.resourceCol} {b.cycles[cycle]} ({t.common.currencyShort})
{(b.resources as Record)[row.resourceType] ?? row.resourceType} {readOnly ? ( {Number(val).toLocaleString('en-US')} ) : ( onChange( row.resourceType, cycle, e.target.value === '' ? 0 : Number(e.target.value), ) } /> )}
); } 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 || '—'}

) : ( 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 }, ]} /> )}
{readOnly ? (

{profile.memoryRequest || '—'}

) : ( onUpdate({ memoryLimit: v })} options={[ { value: '256Mi', label: '256 Mi' }, { value: '512Mi', label: '512 Mi' }, { value: '1Gi', label: '1 Gi' }, { value: '2Gi', label: '2 Gi' }, ]} /> )}
{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 (

{b.title}

{b.subtitle}

{!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 && (

{b.addons}

{b.addonsSub}

)} )}
); } 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}

setHourlyHours(e.target.value)} min={1} placeholder="24" />

{b.monthlyPlans}

setMonthlyDays(e.target.value)} min={1} placeholder="3" />

{b.yearlyPlans}

setYearlyDays(e.target.value)} min={1} placeholder="7" />
) : (

{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}

)}
); }