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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user