Localize admin cluster and billing-plan pages.

Move the cluster management page (resource panel, tools install/uninstall
flow, statuses/health) and the billing-plans page (pricing matrices,
deploy defaults, add-ons, data-retention settings) onto the dictionaries
with locale-aware dates and RTL-aware tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-11 17:05:43 +03:30
parent 6c1133f534
commit fcba1ed93c
5 changed files with 463 additions and 190 deletions
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import { useT } from '@/i18n/I18nProvider';
import type {
PricingCatalog,
PricingRateRow,
@@ -15,18 +16,6 @@ import type {
} 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). */
@@ -89,15 +78,17 @@ function PricingMatrixTable({
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 p-3 font-medium text-gray-600">Resource</th>
<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 p-3 font-medium text-gray-600 capitalize">
{cycle} (T)
<th key={cycle} className="text-left rtl:text-right p-3 font-medium text-gray-600">
{b.cycles[cycle]} ({t.common.currencyShort})
</th>
))}
</tr>
@@ -106,7 +97,7 @@ function PricingMatrixTable({
{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]}
{(b.resources as Record<string, string>)[row.resourceType] ?? row.resourceType}
</td>
{cycles.map((cycle) => {
const field =
@@ -159,17 +150,16 @@ function DeployDefaultsFields({
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">
Prefilled limits for Fluent Bit log shippers (billed per enabled workload when logging is on).
</p>
<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">Log shipper CPU limit</label>
<label className="text-xs font-medium text-gray-600">{b.logShipperCpuLimit}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.logShipperCpuLimit || '—'}</p>
) : (
@@ -182,7 +172,7 @@ function DeployDefaultsFields({
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">Log shipper memory limit</label>
<label className="text-xs font-medium text-gray-600">{b.logShipperMemoryLimit}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.logShipperMemoryLimit || '—'}</p>
) : (
@@ -201,13 +191,10 @@ function DeployDefaultsFields({
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>
<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">CPU request</label>
<label className="text-xs font-medium text-gray-600">{b.cpuRequest}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.cpuRequest || '—'}</p>
) : (
@@ -224,7 +211,7 @@ function DeployDefaultsFields({
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">CPU limit</label>
<label className="text-xs font-medium text-gray-600">{b.cpuLimit}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.cpuLimit}</p>
) : (
@@ -236,13 +223,13 @@ function DeployDefaultsFields({
<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>
<option value="1">{b.oneCore}</option>
<option value="2">{b.twoCores}</option>
</select>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">Memory request</label>
<label className="text-xs font-medium text-gray-600">{b.memoryRequest}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.memoryRequest || '—'}</p>
) : (
@@ -259,7 +246,7 @@ function DeployDefaultsFields({
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">Memory limit</label>
<label className="text-xs font-medium text-gray-600">{b.memoryLimit}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.memoryLimit}</p>
) : (
@@ -276,7 +263,7 @@ function DeployDefaultsFields({
)}
</div>
<div className="sm:col-span-2">
<label className="text-xs font-medium text-gray-600">Storage (Gi)</label>
<label className="text-xs font-medium text-gray-600">{b.storageGi}</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.storageGi}</p>
) : (
@@ -306,17 +293,19 @@ function CustomDomainPricing({
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">Custom domain + SSL</h3>
<p className="text-xs text-gray-500 mb-3">Flat fee per billing cycle (not resource-based)</p>
<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 capitalize">{cycle} (T)</label>
<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>
) : (
@@ -339,6 +328,8 @@ function CustomDomainPricing({
}
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');
@@ -373,12 +364,12 @@ export default function AdminBillingPage() {
queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] });
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
toast.success('Billing plans saved');
toast.success(b.saved);
setEditing(false);
setDraft(null);
},
onError: (err: unknown) => {
toast.error(formatApiError(err, 'Failed to save billing plans'));
toast.error(formatApiError(err, b.saveFailed));
},
});
@@ -511,12 +502,9 @@ export default function AdminBillingPage() {
<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
<DollarSign className="w-6 h-6" /> {b.title}
</h1>
<p className="page-subtitle">
Set unit prices per resource. Users choose CPU, memory, and storage when deploying; cost =
their usage × these rates.
</p>
<p className="page-subtitle">{b.subtitle}</p>
</div>
{!editing ? (
<button
@@ -524,7 +512,7 @@ export default function AdminBillingPage() {
disabled={!catalog}
className="btn-primary flex items-center gap-2 shrink-0"
>
<Edit2 className="w-4 h-4" /> Edit plans
<Edit2 className="w-4 h-4" /> {b.editPlans}
</button>
) : (
<div className="flex gap-2 shrink-0">
@@ -533,7 +521,7 @@ export default function AdminBillingPage() {
disabled={saveMutation.isPending}
className="btn-primary text-sm disabled:opacity-50"
>
{saveMutation.isPending ? 'Saving...' : 'Save'}
{saveMutation.isPending ? b.saving : b.save}
</button>
<button
onClick={() => {
@@ -542,7 +530,7 @@ export default function AdminBillingPage() {
}}
className="btn-secondary text-sm"
>
Cancel
{t.common.cancel}
</button>
</div>
)}
@@ -551,38 +539,27 @@ export default function AdminBillingPage() {
<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>
<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>
<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>
<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">Loading...</div>
<div className="text-center py-12 text-gray-400">{t.common.loading}</div>
) : !display ? (
<div className="text-center py-12 text-gray-400">No pricing data</div>
<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">Application runtimes</h2>
<p className="text-sm text-gray-500 mt-0.5">Unit pricing per runtime (hourly / monthly / yearly)</p>
<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>
@@ -605,7 +582,7 @@ export default function AdminBillingPage() {
<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
{runtimeTabs.find((tab) => tab.value === activeRuntime)?.label} {b.unitPricesSuffix}
</h3>
{editing && (
<button
@@ -613,7 +590,7 @@ export default function AdminBillingPage() {
onClick={() => fillYearlyFromMonthly('runtime')}
className="btn-secondary text-xs"
>
Fill yearly from monthly ×12
{b.fillYearly}
</button>
)}
</div>
@@ -629,10 +606,8 @@ export default function AdminBillingPage() {
<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>
<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>
@@ -655,7 +630,7 @@ export default function AdminBillingPage() {
<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
{optionalServiceTabs.find((tab) => tab.value === activeOptionalService)?.label} {b.unitPricesSuffix}
</h3>
{editing && activeOptionalEntry && (
<button
@@ -663,7 +638,7 @@ export default function AdminBillingPage() {
onClick={() => fillYearlyFromMonthly('optional')}
className="btn-secondary text-xs"
>
Fill yearly from monthly ×12
{b.fillYearly}
</button>
)}
</div>
@@ -681,10 +656,8 @@ export default function AdminBillingPage() {
<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>
<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>
@@ -720,8 +693,8 @@ export default function AdminBillingPage() {
<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>
<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
@@ -740,6 +713,8 @@ export default function AdminBillingPage() {
}
function LifecycleSettingsSection() {
const t = useT();
const b = t.dashboard.billing;
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [hourlyHours, setHourlyHours] = useState('');
@@ -755,11 +730,11 @@ function LifecycleSettingsSection() {
mutationFn: (body: Record<string, number>) => api.patch('/lifecycle/settings', body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] });
toast.success('Lifecycle settings updated');
toast.success(b.lifecycleSaved);
setEditing(false);
},
onError: (err: unknown) => {
toast.error(formatApiError(err, 'Failed to save'));
toast.error(formatApiError(err, b.saveFailedShort));
},
});
@@ -785,29 +760,27 @@ function LifecycleSettingsSection() {
<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>
<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" /> Edit
<Edit2 className="w-3 h-3" /> {b.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>
<p className="text-sm text-gray-500 mb-4">{b.retentionSub}</p>
{isLoading ? (
<div className="text-center py-6 text-gray-400">Loading...</div>
<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">Hourly Plans</h3>
<h3 className="font-semibold text-blue-900">{b.hourlyPlans}</h3>
</div>
<label className="text-xs text-blue-700 font-medium">Delete after (hours):</label>
<label className="text-xs text-blue-700 font-medium">{b.deleteAfterHours}</label>
<input
type="number"
className="input-field mt-1 text-sm"
@@ -820,9 +793,9 @@ function LifecycleSettingsSection() {
<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>
<h3 className="font-semibold text-purple-900">{b.monthlyPlans}</h3>
</div>
<label className="text-xs text-purple-700 font-medium">Delete after (days):</label>
<label className="text-xs text-purple-700 font-medium">{b.deleteAfterDays}</label>
<input
type="number"
className="input-field mt-1 text-sm"
@@ -835,9 +808,9 @@ function LifecycleSettingsSection() {
<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>
<h3 className="font-semibold text-green-900">{b.yearlyPlans}</h3>
</div>
<label className="text-xs text-green-700 font-medium">Delete after (days):</label>
<label className="text-xs text-green-700 font-medium">{b.deleteAfterDays}</label>
<input
type="number"
className="input-field mt-1 text-sm"
@@ -850,14 +823,14 @@ function LifecycleSettingsSection() {
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setEditing(false)} className="btn-ghost">
Cancel
{t.common.cancel}
</button>
<button
onClick={handleSave}
disabled={saveMutation.isPending}
className="btn-primary disabled:opacity-50"
>
{saveMutation.isPending ? 'Saving...' : 'Save Settings'}
{saveMutation.isPending ? b.saving : b.saveSettings}
</button>
</div>
</div>
@@ -865,32 +838,32 @@ function LifecycleSettingsSection() {
<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
<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">hours</span>
<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" /> Monthly Plans
<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">days</span>
<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" /> Yearly Plans
<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">days</span>
<span className="text-sm font-normal ml-1 rtl:ml-0 rtl:mr-1">{b.days}</span>
</p>
</div>
</div>