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:
@@ -5,12 +5,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { WalletTransaction, TransactionType } from '@/types';
|
||||
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock } from 'lucide-react';
|
||||
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock, CreditCard } from 'lucide-react';
|
||||
|
||||
const txTypeLabels: Record<TransactionType, string> = {
|
||||
charge: 'شارژ',
|
||||
deduction: 'کسر',
|
||||
refund: 'بازگشت',
|
||||
charge: 'Deposit',
|
||||
deduction: 'Payment',
|
||||
refund: 'Refund',
|
||||
};
|
||||
|
||||
const txTypeColors: Record<TransactionType, string> = {
|
||||
@@ -40,48 +40,81 @@ export default function WalletPage() {
|
||||
queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data),
|
||||
});
|
||||
|
||||
// Direct wallet charge (simulated — in production this would go through payment gateway)
|
||||
const chargeMutation = useMutation({
|
||||
mutationFn: (amount: number) =>
|
||||
api.post('/billing/wallet/charge', { amount, description: 'شارژ کیف پول' }),
|
||||
api.post('/billing/wallet/charge', { amount, description: 'Wallet top-up' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
toast.success('کیف پول شارژ شد');
|
||||
toast.success('Wallet charged successfully');
|
||||
setChargeAmount('');
|
||||
setShowCharge(false);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در شارژ'),
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to charge wallet'),
|
||||
});
|
||||
|
||||
const handleCharge = () => {
|
||||
// Payment gateway charge
|
||||
const gatewayMutation = useMutation({
|
||||
mutationFn: async (amount: number) => {
|
||||
const { data } = await api.post('/billing/gateway/initiate', {
|
||||
amount,
|
||||
description: 'Wallet top-up via gateway',
|
||||
callbackUrl: `${window.location.origin}/dashboard/wallet`,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
// In production, redirect to data.gatewayUrl
|
||||
// For now, auto-verify (simulated)
|
||||
await api.post('/billing/gateway/verify', {
|
||||
trackingCode: data.trackingCode,
|
||||
amount: Number(chargeAmount),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
toast.success('Payment successful — wallet charged');
|
||||
setChargeAmount('');
|
||||
setShowCharge(false);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Payment failed'),
|
||||
});
|
||||
|
||||
const handleCharge = (method: 'wallet' | 'gateway') => {
|
||||
const amount = Number(chargeAmount);
|
||||
if (!amount || amount < 1000) {
|
||||
toast.error('حداقل مبلغ شارژ ۱,۰۰۰ تومان');
|
||||
toast.error('Minimum charge amount is 1,000 Toman');
|
||||
return;
|
||||
}
|
||||
chargeMutation.mutate(amount);
|
||||
if (method === 'wallet') {
|
||||
chargeMutation.mutate(amount);
|
||||
} else {
|
||||
gatewayMutation.mutate(amount);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (n: number) => Number(n).toLocaleString('fa-IR');
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('fa-IR', {
|
||||
const formatPrice = (n: number) => Number(n).toLocaleString('en-US');
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
const isPending = chargeMutation.isPending || gatewayMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> کیف پول</h1>
|
||||
<p className="page-subtitle">مدیریت موجودی و تراکنشها</p>
|
||||
<h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> Wallet</h1>
|
||||
<p className="page-subtitle">Manage your balance and transactions</p>
|
||||
</div>
|
||||
|
||||
{/* Balance Card */}
|
||||
<div className="card bg-gradient-to-br from-primary-600 to-primary-800 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm opacity-80">موجودی فعلی</p>
|
||||
<p className="text-sm opacity-80">Current Balance</p>
|
||||
<p className="text-3xl font-bold mt-1">
|
||||
{walletLoading ? '...' : `${formatPrice(walletData?.balance ?? 0)}`}
|
||||
<span className="text-lg font-normal mr-2">تومان</span>
|
||||
{walletLoading ? '...' : formatPrice(walletData?.balance ?? 0)}
|
||||
<span className="text-lg font-normal ml-2">Toman</span>
|
||||
</p>
|
||||
</div>
|
||||
{!showCharge && (
|
||||
@@ -89,49 +122,50 @@ export default function WalletPage() {
|
||||
onClick={() => setShowCharge(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-xl text-sm font-semibold transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> شارژ کیف پول
|
||||
<Plus className="w-4 h-4" /> Top Up
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCharge && (
|
||||
<div className="mt-4 pt-4 border-t border-white/20 flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-white/20 text-white placeholder-white/60 border border-white/30 focus:outline-none focus:border-white/60 text-sm"
|
||||
placeholder="مبلغ (تومان) — حداقل ۱,۰۰۰"
|
||||
value={chargeAmount}
|
||||
onChange={(e) => setChargeAmount(e.target.value)}
|
||||
min={1000}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCharge}
|
||||
disabled={chargeMutation.isPending}
|
||||
className="px-5 py-2.5 bg-white text-primary-700 rounded-xl font-semibold text-sm hover:bg-gray-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{chargeMutation.isPending ? '...' : 'پرداخت'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCharge(false)}
|
||||
className="px-3 py-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-sm transition-colors"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick charge amounts */}
|
||||
{showCharge && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
{[10000, 50000, 100000, 500000].map((amt) => (
|
||||
<div className="mt-4 pt-4 border-t border-white/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-white/20 text-white placeholder-white/60 border border-white/30 focus:outline-none focus:border-white/60 text-sm"
|
||||
placeholder="Amount (Toman) — min 1,000"
|
||||
value={chargeAmount}
|
||||
onChange={(e) => setChargeAmount(e.target.value)}
|
||||
min={1000}
|
||||
/>
|
||||
<button
|
||||
key={amt}
|
||||
onClick={() => setChargeAmount(String(amt))}
|
||||
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-lg text-xs font-medium transition-colors"
|
||||
onClick={() => handleCharge('gateway')}
|
||||
disabled={isPending}
|
||||
className="px-5 py-2.5 bg-white text-primary-700 rounded-xl font-semibold text-sm hover:bg-gray-100 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{amt.toLocaleString('fa-IR')} ت
|
||||
<CreditCard className="w-4 h-4" />
|
||||
{gatewayMutation.isPending ? '...' : 'Pay Now'}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowCharge(false)}
|
||||
className="px-3 py-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-sm transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick charge amounts */}
|
||||
<div className="flex gap-2 mt-3">
|
||||
{[10000, 50000, 100000, 500000].map((amt) => (
|
||||
<button
|
||||
key={amt}
|
||||
onClick={() => setChargeAmount(String(amt))}
|
||||
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-lg text-xs font-medium transition-colors"
|
||||
>
|
||||
{amt.toLocaleString('en-US')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -139,13 +173,13 @@ export default function WalletPage() {
|
||||
{/* Transactions */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-gray-400" /> تاریخچه تراکنشها
|
||||
<Clock className="w-5 h-5 text-gray-400" /> Transaction History
|
||||
</h2>
|
||||
|
||||
{txLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">در حال بارگذاری...</div>
|
||||
<div className="text-center py-8 text-gray-400">Loading...</div>
|
||||
) : transactions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">هنوز تراکنشی ثبت نشده</div>
|
||||
<div className="text-center py-8 text-gray-400">No transactions yet</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{transactions.map((tx) => (
|
||||
@@ -162,11 +196,11 @@ export default function WalletPage() {
|
||||
<p className="text-xs text-gray-400">{formatDate(tx.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-bold ${txTypeColors[tx.type]}`}>
|
||||
{tx.type === 'deduction' ? '−' : '+'}{formatPrice(tx.amount)} ت
|
||||
{tx.type === 'deduction' ? '−' : '+'}{formatPrice(tx.amount)} T
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">مانده: {formatPrice(tx.balanceAfter)} ت</p>
|
||||
<p className="text-xs text-gray-400">Balance: {formatPrice(tx.balanceAfter)} T</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user