feat: billing v2 — runtime-based plans, English UI, payment flow
- ServicePlan now has 'runtime' field (nodejs/laravel/wordpress) - Admin billing page: Application Type dropdown, English UI, Toman prices - calculateCost filters active plans by matching runtime - Wallet page: English UI, payment gateway integration (Pay Now button) - Deploy page Review step: billing cycle selector (hourly/monthly/yearly), payment method choice (wallet or payment gateway), Pay & Deploy button - Payment gateway endpoints: POST /billing/gateway/initiate + /verify (simulated — ready for Zarinpal/IDPay integration) - Deploy requires payment: wallet deduction or gateway charge before deploy
This commit is contained in:
@@ -7,18 +7,32 @@ import { toast } from 'react-toastify';
|
||||
import type { ServicePlan, BillingCycle, PricingResourceType } from '@/types';
|
||||
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
const runtimeOptions = [
|
||||
{ value: 'nodejs', label: 'Node.js' },
|
||||
{ value: 'laravel', label: 'Laravel' },
|
||||
{ value: 'wordpress', label: 'WordPress' },
|
||||
] as const;
|
||||
|
||||
type AppRuntime = 'nodejs' | 'laravel' | 'wordpress';
|
||||
|
||||
const runtimeLabels: Record<AppRuntime, string> = {
|
||||
nodejs: 'Node.js',
|
||||
laravel: 'Laravel',
|
||||
wordpress: 'WordPress',
|
||||
};
|
||||
|
||||
const cycleLabels: Record<BillingCycle, string> = {
|
||||
hourly: 'ساعتی',
|
||||
monthly: 'ماهانه',
|
||||
yearly: 'سالانه',
|
||||
hourly: 'Hourly',
|
||||
monthly: 'Monthly',
|
||||
yearly: 'Yearly',
|
||||
};
|
||||
|
||||
const resourceLabels: Record<PricingResourceType, string> = {
|
||||
base_fee: 'هزینه پایه',
|
||||
cpu_per_core: 'CPU (هر هسته)',
|
||||
memory_per_gb: 'حافظه (هر GB)',
|
||||
storage_per_gb: 'دیسک (هر GB)',
|
||||
database_addon: 'افزونه دیتابیس',
|
||||
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',
|
||||
};
|
||||
|
||||
const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon'];
|
||||
@@ -37,6 +51,7 @@ export default function AdminBillingPage() {
|
||||
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()]);
|
||||
@@ -52,19 +67,19 @@ export default function AdminBillingPage() {
|
||||
: api.post('/billing/plans', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
|
||||
toast.success(editingId ? 'پلن بروزرسانی شد' : 'پلن ایجاد شد');
|
||||
toast.success(editingId ? 'Plan updated' : 'Plan created');
|
||||
resetForm();
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا'),
|
||||
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('پلن حذف شد');
|
||||
toast.success('Plan deleted');
|
||||
},
|
||||
onError: () => toast.error('خطا در حذف پلن'),
|
||||
onError: () => toast.error('Failed to delete plan'),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
@@ -79,6 +94,7 @@ export default function AdminBillingPage() {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setFormName('');
|
||||
setFormRuntime('nodejs');
|
||||
setFormDesc('');
|
||||
setFormCycle('monthly');
|
||||
setRules([emptyRule()]);
|
||||
@@ -87,6 +103,7 @@ export default function AdminBillingPage() {
|
||||
const startEdit = (plan: ServicePlan) => {
|
||||
setEditingId(plan.id);
|
||||
setFormName(plan.name);
|
||||
setFormRuntime(plan.runtime);
|
||||
setFormDesc(plan.description || '');
|
||||
setFormCycle(plan.billingCycle);
|
||||
setRules(
|
||||
@@ -100,12 +117,13 @@ export default function AdminBillingPage() {
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formName.trim()) return toast.error('نام پلن الزامی است');
|
||||
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('حداقل یک قاعده قیمتگذاری اضافه کنید');
|
||||
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) => ({
|
||||
@@ -124,18 +142,18 @@ export default function AdminBillingPage() {
|
||||
setRules(updated);
|
||||
};
|
||||
|
||||
const formatPrice = (n: number) => Number(n).toLocaleString('fa-IR');
|
||||
const formatPrice = (n: number) => Number(n).toLocaleString('en-US');
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="page-title flex items-center gap-2"><DollarSign className="w-6 h-6" /> مدیریت پلنها و قیمتگذاری</h1>
|
||||
<p className="page-subtitle">تعریف سرویسها و هزینهها برای هر نوع اپلیکیشن</p>
|
||||
<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>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<button onClick={() => setShowForm(true)} className="btn-primary flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" /> پلن جدید
|
||||
<Plus className="w-4 h-4" /> New Plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -143,34 +161,42 @@ export default function AdminBillingPage() {
|
||||
{/* Create / Edit Form */}
|
||||
{showForm && (
|
||||
<div className="card space-y-4">
|
||||
<h2 className="text-lg font-semibold">{editingId ? 'ویرایش پلن' : 'ایجاد پلن جدید'}</h2>
|
||||
<h2 className="text-lg font-semibold">{editingId ? 'Edit Plan' : 'Create New Plan'}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<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">نام پلن</label>
|
||||
<input className="input-field" placeholder="مثال: Node.js پایه" value={formName} onChange={(e) => setFormName(e.target.value)} />
|
||||
<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">دوره پرداخت</label>
|
||||
<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">ساعتی</option>
|
||||
<option value="monthly">ماهانه</option>
|
||||
<option value="yearly">سالانه</option>
|
||||
<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">توضیحات (اختیاری)</label>
|
||||
<input className="input-field" placeholder="توضیحات درباره پلن" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} />
|
||||
<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">قواعد قیمتگذاری</label>
|
||||
<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" /> افزودن
|
||||
<Plus className="w-3 h-3" /> Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -187,15 +213,15 @@ export default function AdminBillingPage() {
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input-field w-32 text-sm"
|
||||
className="input-field w-36 text-sm"
|
||||
type="number"
|
||||
placeholder="قیمت (تومان)"
|
||||
placeholder="Price (Toman)"
|
||||
value={rule.unitPrice}
|
||||
onChange={(e) => updateRule(i, 'unitPrice', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input-field flex-1 text-sm"
|
||||
placeholder="توضیح (اختیاری)"
|
||||
placeholder="Note (optional)"
|
||||
value={rule.description}
|
||||
onChange={(e) => updateRule(i, 'description', e.target.value)}
|
||||
/>
|
||||
@@ -210,9 +236,9 @@ export default function AdminBillingPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={resetForm} className="btn-ghost">انصراف</button>
|
||||
<button onClick={resetForm} className="btn-ghost">Cancel</button>
|
||||
<button onClick={handleSubmit} disabled={createMutation.isPending} className="btn-primary disabled:opacity-50">
|
||||
{createMutation.isPending ? 'در حال ذخیره...' : editingId ? 'بروزرسانی' : 'ایجاد پلن'}
|
||||
{createMutation.isPending ? 'Saving...' : editingId ? 'Update Plan' : 'Create Plan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,9 +246,9 @@ export default function AdminBillingPage() {
|
||||
|
||||
{/* Plans List */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-400">در حال بارگذاری...</div>
|
||||
<div className="text-center py-12 text-gray-400">Loading...</div>
|
||||
) : plans.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">هنوز پلنی ایجاد نشده</div>
|
||||
<div className="text-center py-12 text-gray-400">No plans created yet</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{plans.map((plan) => (
|
||||
@@ -238,7 +264,8 @@ export default function AdminBillingPage() {
|
||||
<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">{cycleLabels[plan.billingCycle]}</span>
|
||||
<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>
|
||||
@@ -247,7 +274,7 @@ export default function AdminBillingPage() {
|
||||
<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 ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||
title={plan.isActive ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{plan.isActive ? <ToggleRight className="w-5 h-5" /> : <ToggleLeft className="w-5 h-5" />}
|
||||
</button>
|
||||
@@ -255,7 +282,7 @@ export default function AdminBillingPage() {
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('حذف این پلن؟')) deleteMutation.mutate(plan.id); }}
|
||||
onClick={() => { if (confirm('Delete this plan?')) deleteMutation.mutate(plan.id); }}
|
||||
className="p-1 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -269,9 +296,9 @@ export default function AdminBillingPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-gray-500 text-xs">
|
||||
<th className="text-right pb-2">نوع منبع</th>
|
||||
<th className="text-right pb-2">قیمت واحد (تومان)</th>
|
||||
<th className="text-right pb-2">توضیحات</th>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user