Add i18n foundation (fa-IR/en-US) and localize landing + auth.
Introduce path-prefixed locale routing under app/[lang] with a middleware that detects locale from cookie/Accept-Language (default fa-IR) and redirects. Add fa-IR (source of truth) and en-US dictionaries, a server getDictionary, a client I18nProvider/useT, locale-aware Link + router helpers, and a language switcher. The root [lang] layout sets html lang/dir and the per-locale font (Peyda for fa, Inter for en). Landing sections and the login/register/auth shell now read all copy from the dictionaries; dashboard localization follows in a later commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,900 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type {
|
||||
PricingCatalog,
|
||||
PricingRateRow,
|
||||
OptionalServiceProfileRow,
|
||||
CustomDomainCatalogRow,
|
||||
BillingCycle,
|
||||
PricingResourceType,
|
||||
LifecycleSettings,
|
||||
} from '@/types';
|
||||
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
|
||||
|
||||
const resourceLabels: Record<PricingResourceType, string> = {
|
||||
base_fee: 'Base fee',
|
||||
cpu_per_core: 'CPU (per core)',
|
||||
memory_per_gb: 'Memory (per GB)',
|
||||
storage_per_gb: 'Storage (per GB)',
|
||||
database_addon: 'Database addon',
|
||||
redis_addon: 'Redis',
|
||||
rabbitmq_addon: 'RabbitMQ',
|
||||
elasticsearch_addon: 'Elasticsearch',
|
||||
custom_domain_addon: 'Custom domain + SSL',
|
||||
};
|
||||
|
||||
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 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 {
|
||||
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;
|
||||
}) {
|
||||
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 p-3 font-medium text-gray-600">Resource</th>
|
||||
{cycles.map((cycle) => (
|
||||
<th key={cycle} className="text-left p-3 font-medium text-gray-600 capitalize">
|
||||
{cycle} (T)
|
||||
</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">
|
||||
{resourceLabels[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 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">
|
||||
Prefilled limits for Fluent Bit log shippers (billed per enabled workload when logging is on).
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Log shipper CPU limit</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">Log shipper memory limit</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">
|
||||
Shown when a user enables this service in deploy. They can change CPU, memory, and storage in
|
||||
Resources & Configuration; actual billing uses their choices × unit prices below.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">CPU request</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.cpuRequest || '—'}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={profile.cpuRequest || '50m'}
|
||||
onChange={(e) => onUpdate({ cpuRequest: e.target.value })}
|
||||
>
|
||||
<option value="50m">50m</option>
|
||||
<option value="100m">100m</option>
|
||||
<option value="250m">250m</option>
|
||||
<option value="500m">500m</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">CPU limit</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.cpuLimit}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={profile.cpuLimit}
|
||||
onChange={(e) => onUpdate({ cpuLimit: e.target.value })}
|
||||
>
|
||||
<option value="200m">200m</option>
|
||||
<option value="250m">250m</option>
|
||||
<option value="500m">500m</option>
|
||||
<option value="1">1 core</option>
|
||||
<option value="2">2 cores</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Memory request</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.memoryRequest || '—'}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={profile.memoryRequest || '64Mi'}
|
||||
onChange={(e) => onUpdate({ memoryRequest: e.target.value })}
|
||||
>
|
||||
<option value="64Mi">64 Mi</option>
|
||||
<option value="128Mi">128 Mi</option>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Memory limit</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.memoryLimit}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={profile.memoryLimit}
|
||||
onChange={(e) => onUpdate({ memoryLimit: e.target.value })}
|
||||
>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
<option value="1Gi">1 Gi</option>
|
||||
<option value="2Gi">2 Gi</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="text-xs font-medium text-gray-600">Storage (Gi)</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;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Custom domain + SSL</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">Flat fee per billing cycle (not resource-based)</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 capitalize">{cycle} (T)</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 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'] });
|
||||
toast.success('Billing plans saved');
|
||||
setEditing(false);
|
||||
setDraft(null);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(formatApiError(err, 'Failed to save billing plans'));
|
||||
},
|
||||
});
|
||||
|
||||
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" /> Billing Plans
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
Set unit prices per resource. Users choose CPU, memory, and storage when deploying; cost =
|
||||
their usage × these rates.
|
||||
</p>
|
||||
</div>
|
||||
{!editing ? (
|
||||
<button
|
||||
onClick={startEdit}
|
||||
disabled={!catalog}
|
||||
className="btn-primary flex items-center gap-2 shrink-0"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" /> Edit plans
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setDraft(null);
|
||||
}}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
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">How billing works</p>
|
||||
<ul className="list-disc list-inside text-blue-800/90 space-y-0.5 text-xs sm:text-sm">
|
||||
<li>
|
||||
<strong>Applications</strong> — user picks runtime resources in deploy; you set price per
|
||||
core, GB, base fee, and database addon. CPU and RAM in estimates bill proportionally (for
|
||||
example half the per-GB rate at half a gigabyte of memory).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Optional services (Redis, RabbitMQ)</strong> — same unit matrix per service as
|
||||
runtimes; deploy wizard defaults are edited separately. CPU/RAM in the cost calculator bill in
|
||||
proportion to actual limits (e.g. 500Mi counts as 0.5× the per-GB memory rate).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Deploy defaults</strong> — optional prefill only; changing them does not change
|
||||
what existing apps pay unless the user chose those values at deploy.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-400">Loading...</div>
|
||||
) : !display ? (
|
||||
<div className="text-center py-12 text-gray-400">No pricing data</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">Application runtimes</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Unit pricing per runtime (hourly / monthly / yearly)</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((t) => t.value === activeRuntime)?.label} — unit prices
|
||||
</h3>
|
||||
{editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillYearlyFromMonthly('runtime')}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
Fill yearly from monthly ×12
|
||||
</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">Optional services</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Unit pricing per service (hourly / monthly / yearly)
|
||||
</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((t) => t.value === activeOptionalService)?.label} — unit prices
|
||||
</h3>
|
||||
{editing && activeOptionalEntry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillYearlyFromMonthly('optional')}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
Fill yearly from monthly ×12
|
||||
</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">Optional services — deploy defaults</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Prefill CPU, memory, and storage when a user enables each service in the deploy wizard
|
||||
</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">Add-ons</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Flat fees not tied to a runtime</p>
|
||||
</div>
|
||||
</div>
|
||||
<CustomDomainPricing
|
||||
customDomain={customDomain}
|
||||
readOnly={!editing}
|
||||
onChange={updateCustomDomain}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<LifecycleSettingsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LifecycleSettingsSection() {
|
||||
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'] });
|
||||
toast.success('Lifecycle settings updated');
|
||||
setEditing(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(formatApiError(err, 'Failed to save'));
|
||||
},
|
||||
});
|
||||
|
||||
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">Data Retention & Deletion Policy</h2>
|
||||
</div>
|
||||
{!editing && (
|
||||
<button onClick={startEditing} className="btn-secondary text-xs flex items-center gap-1">
|
||||
<Edit2 className="w-3 h-3" /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Configure how long user data is retained after plan expiration before permanent deletion.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-6 text-gray-400">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">Hourly Plans</h3>
|
||||
</div>
|
||||
<label className="text-xs text-blue-700 font-medium">Delete after (hours):</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">Monthly Plans</h3>
|
||||
</div>
|
||||
<label className="text-xs text-purple-700 font-medium">Delete after (days):</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">Yearly Plans</h3>
|
||||
</div>
|
||||
<label className="text-xs text-green-700 font-medium">Delete after (days):</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">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save Settings'}
|
||||
</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" /> Hourly Plans
|
||||
</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">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" /> Monthly Plans
|
||||
</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">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" /> Yearly Plans
|
||||
</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">days</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user