Replace billing plans with per-runtime usage pricing catalog.

Store explicit hourly/monthly/yearly rates in pricing_rates and addon_rates, compute deploy costs without cycle conversion, and simplify admin UI and wallet payment to cycle-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 18:10:54 +03:30
parent 5239e8aa94
commit 35235fe0fc
15 changed files with 1293 additions and 716 deletions
+311 -386
View File
@@ -4,433 +4,347 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { ServicePlan, BillingCycle, PricingResourceType, LifecycleSettings } from '@/types';
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp, Shield, Clock, Globe } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
const runtimeOptions = [
{ value: 'nodejs', label: 'Node.js' },
{ value: 'laravel', label: 'Laravel' },
{ value: 'wordpress', label: 'WordPress' },
] as const;
import type {
PricingCatalog,
PricingRateRow,
BillingCycle,
PricingResourceType,
LifecycleSettings,
} from '@/types';
import { DollarSign, Edit2, Shield, Clock, Layers } from 'lucide-react';
type AppRuntime = 'nodejs' | 'laravel' | 'wordpress';
const runtimeLabels: Record<AppRuntime, string> = {
nodejs: 'Node.js',
laravel: 'Laravel',
wordpress: 'WordPress',
};
const runtimeTabs: { value: AppRuntime; label: string }[] = [
{ value: 'nodejs', label: 'Node.js' },
{ value: 'laravel', label: 'Laravel' },
{ value: 'wordpress', label: 'WordPress' },
];
const cycleLabels: Record<BillingCycle, string> = {
hourly: 'Hourly',
monthly: 'Monthly',
yearly: 'Yearly',
};
const addonResourceTypes: PricingResourceType[] = [
'redis_addon',
'rabbitmq_addon',
'elasticsearch_addon',
'custom_domain_addon',
];
const resourceLabels: Record<PricingResourceType, string> = {
base_fee: 'Base Fee',
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 Addon',
rabbitmq_addon: 'RabbitMQ Addon',
elasticsearch_addon: 'Elasticsearch Addon',
custom_domain_addon: 'Custom Domain + SSL',
database_addon: 'Database addon',
redis_addon: 'Redis',
rabbitmq_addon: 'RabbitMQ',
elasticsearch_addon: 'Elasticsearch',
custom_domain_addon: 'Custom domain + SSL',
};
const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon', 'redis_addon', 'rabbitmq_addon', 'elasticsearch_addon', 'custom_domain_addon'];
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
interface RuleForm {
resourceType: PricingResourceType;
unitPrice: string;
description: string;
function cloneCatalog(catalog: PricingCatalog): PricingCatalog {
const runtimes = {} as PricingCatalog['runtimes'];
for (const rt of runtimeTabs) {
runtimes[rt.value] = catalog.runtimes[rt.value].map((r) => ({ ...r }));
}
return {
runtimes,
addons: catalog.addons.map((a) => ({ ...a })),
};
}
const emptyRule = (): RuleForm => ({ resourceType: 'base_fee', unitPrice: '', description: '' });
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>
);
}
export default function AdminBillingPage() {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [expandedPlan, setExpandedPlan] = useState<string | null>(null);
const [formName, setFormName] = useState('');
const [formRuntime, setFormRuntime] = useState<AppRuntime>('nodejs');
const [formDesc, setFormDesc] = useState('');
const [formCycle, setFormCycle] = useState<BillingCycle>('monthly');
const [rules, setRules] = useState<RuleForm[]>([emptyRule()]);
const [activeRuntime, setActiveRuntime] = useState<AppRuntime>('nodejs');
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<PricingCatalog | null>(null);
const { data: plans = [], isLoading } = useQuery<ServicePlan[]>({
queryKey: ['billing-plans'],
queryFn: () => api.get('/billing/plans').then((r) => r.data),
const { data: catalog, isLoading } = useQuery<PricingCatalog>({
queryKey: ['pricing-catalog'],
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: (data: any) => editingId
? api.patch(`/billing/plans/${editingId}`, data)
: api.post('/billing/plans', data),
const saveMutation = useMutation({
mutationFn: (body: PricingCatalog) => api.patch('/billing/pricing-catalog', body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
toast.success(editingId ? 'Plan updated' : 'Plan created');
resetForm();
queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] });
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
toast.success('Pricing catalog saved');
setEditing(false);
setDraft(null);
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Error'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/billing/plans/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
toast.success('Plan deleted');
},
onError: () => toast.error('Failed to delete plan'),
});
const toggleMutation = useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
api.patch(`/billing/plans/${id}`, { isActive }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
onError: (err: unknown) => {
const message =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: undefined;
toast.error(message || 'Failed to save pricing');
},
});
const resetForm = () => {
setShowForm(false);
setEditingId(null);
setFormName('');
setFormRuntime('nodejs');
setFormDesc('');
setFormCycle('monthly');
setRules([emptyRule()]);
const display = editing && draft ? draft : catalog;
const startEdit = () => {
if (!catalog) return;
setDraft(cloneCatalog(catalog));
setEditing(true);
};
const startEdit = (plan: ServicePlan) => {
setEditingId(plan.id);
setFormName(plan.name);
setFormRuntime(plan.runtime);
setFormDesc(plan.description || '');
setFormCycle(plan.billingCycle);
setRules(
plan.pricingRules.map((r) => ({
resourceType: r.resourceType,
unitPrice: String(r.unitPrice),
description: r.description || '',
})),
);
setShowForm(true);
};
const handleSubmit = () => {
if (!formName.trim()) return toast.error('Plan name is required');
const validRules = rules.filter((r) => r.unitPrice && Number(r.unitPrice) > 0);
if (validRules.length === 0) return toast.error('Add at least one pricing rule');
createMutation.mutate({
name: formName,
runtime: formRuntime,
description: formDesc || undefined,
billingCycle: formCycle,
pricingRules: validRules.map((r) => ({
resourceType: r.resourceType,
unitPrice: Number(r.unitPrice),
description: r.description || undefined,
})),
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 addRule = () => setRules([...rules, emptyRule()]);
const removeRule = (i: number) => setRules(rules.filter((_, idx) => idx !== i));
const updateRule = (i: number, field: keyof RuleForm, value: string) => {
const updated = [...rules];
updated[i] = { ...updated[i], [field]: value };
setRules(updated);
const updateAddonPrice = (
resourceType: PricingResourceType,
cycle: BillingCycle,
value: number,
) => {
if (!draft) return;
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
setDraft({
...draft,
addons: draft.addons.map((row) =>
row.resourceType === resourceType ? { ...row, [field]: value } : row,
),
});
};
const formatPrice = (n: number) => Number(n).toLocaleString('en-US');
const fillYearlyFromMonthly = (scope: 'runtime' | 'addons') => {
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 {
setDraft({
...draft,
addons: draft.addons.map((row) => ({
...row,
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
})),
});
}
};
const handleSave = () => {
if (!draft) return;
saveMutation.mutate(draft);
};
const addonRows =
display?.addons.filter((a) => addonResourceTypes.includes(a.resourceType)) ?? [];
return (
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<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">Define service plans and pricing for each application type</p>
<h1 className="page-title flex items-center gap-2">
<DollarSign className="w-6 h-6" /> Billing & Pricing
</h1>
<p className="page-subtitle">
Usage-based prices per application type. Each resource has explicit hourly, monthly, and yearly rates deploy cost uses the column for the cycle the user selects.
</p>
</div>
{!showForm && (
<button onClick={() => setShowForm(true)} className="btn-primary flex items-center gap-2">
<Plus className="w-4 h-4" /> New Plan
{!editing ? (
<button
onClick={startEdit}
disabled={!catalog}
className="btn-primary flex items-center gap-2 shrink-0"
>
<Edit2 className="w-4 h-4" /> Edit pricing
</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>
{/* Create / Edit Form */}
{showForm && (
<div className="card space-y-4">
<h2 className="text-lg font-semibold">{editingId ? 'Edit Plan' : 'Create New Plan'}</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Application Type</label>
<select className="input-field" value={formRuntime} onChange={(e) => setFormRuntime(e.target.value as AppRuntime)}>
{runtimeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Plan Name</label>
<input className="input-field" placeholder="e.g. Node.js Standard" value={formName} onChange={(e) => setFormName(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Billing Cycle</label>
<select className="input-field" value={formCycle} onChange={(e) => setFormCycle(e.target.value as BillingCycle)}>
<option value="hourly">Hourly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
<input className="input-field" placeholder="Description of this plan" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} />
</div>
{/* Pricing Rules */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-semibold text-gray-700">Pricing Rules</label>
<button onClick={addRule} className="btn-secondary text-xs flex items-center gap-1">
<Plus className="w-3 h-3" /> Add Rule
</button>
</div>
<div className="space-y-3">
{rules.map((rule, i) => (
<div key={i} className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<select
className="input-field flex-1 text-sm"
value={rule.resourceType}
onChange={(e) => updateRule(i, 'resourceType', e.target.value)}
>
{allResourceTypes.map((rt) => (
<option key={rt} value={rt}>{resourceLabels[rt]}</option>
))}
</select>
<input
className="input-field w-36 text-sm"
type="number"
placeholder="Price (Toman)"
value={rule.unitPrice}
onChange={(e) => updateRule(i, 'unitPrice', e.target.value)}
/>
<input
className="input-field flex-1 text-sm"
placeholder="Note (optional)"
value={rule.description}
onChange={(e) => updateRule(i, 'description', e.target.value)}
/>
{rules.length > 1 && (
<button onClick={() => removeRule(i)} className="text-red-500 hover:text-red-700 p-1">
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={resetForm} className="btn-ghost">Cancel</button>
<button onClick={handleSubmit} disabled={createMutation.isPending} className="btn-primary disabled:opacity-50">
{createMutation.isPending ? 'Saving...' : editingId ? 'Update Plan' : 'Create Plan'}
</button>
</div>
</div>
)}
{/* Plans List */}
{isLoading ? (
<div className="text-center py-12 text-gray-400">Loading...</div>
) : plans.length === 0 ? (
<div className="text-center py-12 text-gray-400">No plans created yet</div>
) : !display ? (
<div className="text-center py-12 text-gray-400">No pricing data</div>
) : (
<div className="space-y-3">
{plans.map((plan) => (
<div key={plan.id} className="card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => setExpandedPlan(expandedPlan === plan.id ? null : plan.id)}
className="p-1 text-gray-400 hover:text-gray-600"
>
{expandedPlan === plan.id ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<div>
<h3 className="font-semibold text-gray-900">{plan.name}</h3>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="badge badge-blue">{runtimeLabels[plan.runtime] || plan.runtime}</span>
<span className="badge badge-purple">{cycleLabels[plan.billingCycle]}</span>
{plan.description && <span> {plan.description}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleMutation.mutate({ id: plan.id, isActive: !plan.isActive })}
className={`p-1 transition-colors ${plan.isActive ? 'text-green-500' : 'text-gray-400'}`}
title={plan.isActive ? 'Deactivate' : 'Activate'}
>
{plan.isActive ? <ToggleRight className="w-5 h-5" /> : <ToggleLeft className="w-5 h-5" />}
</button>
<button onClick={() => startEdit(plan)} className="p-1 text-blue-500 hover:text-blue-700">
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={async () => {
const ok = await confirm({ title: 'Delete Plan', message: `Are you sure you want to delete "${plan.name}"?`, confirmText: 'Delete', variant: 'danger' });
if (ok) deleteMutation.mutate(plan.id);
}}
className="p-1 text-red-500 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<>
<div className="card space-y-4">
<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>
{/* Expanded pricing rules */}
{expandedPlan === plan.id && (
<div className="mt-4 pt-4 border-t border-gray-100">
<table className="w-full text-sm">
<thead>
<tr className="text-gray-500 text-xs">
<th className="text-left pb-2">Resource</th>
<th className="text-left pb-2">Unit Price (Toman)</th>
<th className="text-left pb-2">Note</th>
</tr>
</thead>
<tbody>
{plan.pricingRules.map((rule) => (
<tr key={rule.id} className="border-t border-gray-50">
<td className="py-2 font-medium">{resourceLabels[rule.resourceType]}</td>
<td className="py-2 text-green-700 font-mono">{formatPrice(rule.unitPrice)}</td>
<td className="py-2 text-gray-500">{rule.description || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-gray-900">
{runtimeTabs.find((t) => t.value === activeRuntime)?.label} resources
</h2>
{editing && (
<button
type="button"
onClick={() => fillYearlyFromMonthly('runtime')}
className="btn-secondary text-xs"
>
Fill yearly from monthly ×12
</button>
)}
</div>
))}
</div>
<PricingMatrixTable
rows={display.runtimes[activeRuntime]}
readOnly={!editing}
onChange={updateRuntimePrice}
/>
</div>
<div className="card space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Layers className="w-5 h-5" /> Platform add-ons
</h2>
{editing && (
<button
type="button"
onClick={() => fillYearlyFromMonthly('addons')}
className="btn-secondary text-xs"
>
Fill yearly from monthly ×12
</button>
)}
</div>
<p className="text-sm text-gray-500">
Redis, RabbitMQ, Elasticsearch, and custom domain same prices for all application types.
</p>
<PricingMatrixTable
rows={addonRows}
readOnly={!editing}
onChange={updateAddonPrice}
/>
</div>
</>
)}
{/* ─── Custom Domain Pricing ───────────────────── */}
<CustomDomainPricingSection />
{/* ─── Lifecycle Retention Settings ───────────────────── */}
<LifecycleSettingsSection />
</div>
);
}
// ─── Custom Domain Pricing Sub-component ──────────────────────────
function CustomDomainPricingSection() {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [priceInput, setPriceInput] = useState('');
const { data: priceData, isLoading } = useQuery<{ monthlyPrice: number }>({
queryKey: ['custom-domain-price'],
queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data),
});
const saveMutation = useMutation({
mutationFn: (monthlyPrice: number) => api.patch('/billing/settings/custom-domain-price', { monthlyPrice }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
toast.success('Custom domain pricing updated');
setEditing(false);
},
onError: () => toast.error('Failed to update pricing'),
});
const handleEdit = () => {
setPriceInput(String(priceData?.monthlyPrice || 0));
setEditing(true);
};
const handleSave = () => {
const price = Number(priceInput);
if (isNaN(price) || price < 0) {
toast.error('Price must be a non-negative number');
return;
}
saveMutation.mutate(price);
};
return (
<div className="card mt-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Globe className="w-5 h-5" /> Custom Domain Pricing
</h2>
{!editing && (
<button onClick={handleEdit} className="btn-secondary text-sm flex items-center gap-1.5">
<Edit2 className="w-4 h-4" /> Edit
</button>
)}
</div>
{isLoading ? (
<p className="text-sm text-gray-500">Loading...</p>
) : editing ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Monthly Price (Toman)</label>
<input
type="number"
value={priceInput}
onChange={(e) => setPriceInput(e.target.value)}
className="input-field w-full max-w-xs"
min="0"
placeholder="e.g. 50000"
/>
<p className="text-xs text-gray-400 mt-1">
Set to 0 to make custom domains free. This price is added to the total cost when users enable a custom domain.
</p>
</div>
<div className="flex gap-2">
<button onClick={handleSave} disabled={saveMutation.isPending} className="btn-primary text-sm">
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button onClick={() => setEditing(false)} className="btn-secondary text-sm">Cancel</button>
</div>
</div>
) : (
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Monthly price per custom domain</p>
<p className="text-2xl font-bold text-gray-900">
{(priceData?.monthlyPrice || 0).toLocaleString('en-US')} <span className="text-sm font-normal text-gray-500">Toman</span>
</p>
</div>
{priceData?.monthlyPrice === 0 && (
<span className="badge badge-green">Free</span>
)}
</div>
</div>
)}
</div>
);
}
// ─── Lifecycle Settings Sub-component ─────────────────────────────
function LifecycleSettingsSection() {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
@@ -444,13 +358,19 @@ function LifecycleSettingsSection() {
});
const saveMutation = useMutation({
mutationFn: (body: any) => api.patch('/lifecycle/settings', body),
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: any) => toast.error(err.response?.data?.message || 'Failed to save'),
onError: (err: unknown) => {
const message =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: undefined;
toast.error(message || 'Failed to save');
},
});
const startEditing = () => {
@@ -463,7 +383,7 @@ function LifecycleSettingsSection() {
};
const handleSave = () => {
const body: any = {};
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;
@@ -485,7 +405,6 @@ function LifecycleSettingsSection() {
</div>
<p className="text-sm text-gray-500 mb-4">
Configure how long user data is retained after plan expiration before permanent deletion.
After a plan expires, the application is suspended (scaled to 0). If no payment is received within the grace period, the application and all its data are permanently deleted.
</p>
{isLoading ? (
@@ -540,8 +459,14 @@ function LifecycleSettingsSection() {
</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">
<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>
@@ -553,30 +478,30 @@ function LifecycleSettingsSection() {
<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)}
{settings?.hourly.deleteAfterHours ??
Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)}
<span className="text-sm font-normal ml-1">hours</span>
</p>
<p className="text-xs text-blue-500 mt-1">after suspension delete</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)}
{settings?.monthly.deleteAfterDays ??
Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)}
<span className="text-sm font-normal ml-1">days</span>
</p>
<p className="text-xs text-purple-500 mt-1">after suspension delete</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)}
{settings?.yearly.deleteAfterDays ??
Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)}
<span className="text-sm font-normal ml-1">days</span>
</p>
<p className="text-xs text-green-500 mt-1">after suspension delete</p>
</div>
</div>
)}
+2 -2
View File
@@ -202,7 +202,7 @@ export default function DeployPage() {
// Deduct from wallet
setDeployStage('paying');
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
await api.post(`/billing/wallet/pay/${appId}`, { cycle: selectedCycle });
return res;
},
@@ -280,7 +280,7 @@ export default function DeployPage() {
// Deduct from the wallet (which was just charged by gateway)
setDeployStage('paying');
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
await api.post(`/billing/wallet/pay/${appId}`, { cycle: selectedCycle });
return res;
},