2026-03-22 00:32:56 +05:30

455 lines
24 KiB
JavaScript

import { useState, useEffect } from 'react'
import { CreditCard, Loader2, CircleHelp, X } from 'lucide-react'
import api from '../services/api'
import { useToast } from '../components/ToastProvider'
import { getAuthUser } from '../utils/authSession'
import { useNavigate } from 'react-router-dom'
import { readPageCache, writePageCache } from '../utils/pageCache'
export default function Billing() {
const cached = readPageCache('billing_page', 90 * 1000)
const navigate = useNavigate()
const toast = useToast()
const [stats, setStats] = useState(cached?.stats || null)
const [plan, setPlan] = useState(cached?.plan || null)
const [limits, setLimits] = useState(cached?.limits || null)
const [billingConfig, setBillingConfig] = useState(cached?.billingConfig || null)
const [payments, setPayments] = useState(cached?.payments || [])
const [loading, setLoading] = useState(!cached)
const [actionLoading, setActionLoading] = useState(false)
const [payingCode, setPayingCode] = useState('')
const [selectedPackageCode, setSelectedPackageCode] = useState('growth')
const [showTrialApplyModal, setShowTrialApplyModal] = useState(false)
const [showQuotaHelp, setShowQuotaHelp] = useState(false)
const [showPurchaseHelp, setShowPurchaseHelp] = useState(false)
const [showHistoryHelp, setShowHistoryHelp] = useState(false)
useEffect(() => {
fetchBillingInfo()
}, [])
const fetchBillingInfo = async () => {
const silent = Boolean(stats && plan && limits)
if (!silent) setLoading(true)
try {
const [profileRes, paymentsRes, billingConfigRes] = await Promise.all([
api.get('/api/user/profile'),
api.get('/api/user/billing/payments'),
api.get('/api/user/billing/config'),
])
const nextStats = profileRes.data.stats
const nextPlan = profileRes.data.plan
const nextLimits = profileRes.data.limits
const nextBillingConfig = billingConfigRes.data.config || null
const nextPayments = paymentsRes.data.payments || []
setStats(nextStats)
setPlan(nextPlan)
setLimits(nextLimits)
setBillingConfig(nextBillingConfig)
setPayments(nextPayments)
writePageCache('billing_page', {
stats: nextStats,
plan: nextPlan,
limits: nextLimits,
billingConfig: nextBillingConfig,
payments: nextPayments,
})
} catch (err) {
console.error("Failed to fetch billing info", err)
} finally {
if (!silent) setLoading(false)
}
}
const handleApply = async () => {
setActionLoading(true)
try {
await api.post('/api/user/trial/apply')
const res = await api.post('/api/user/trial/activate')
toast.success(res.data.message || 'Free trial activated successfully')
setShowTrialApplyModal(false)
await fetchBillingInfo()
} catch (err) {
toast.error(err.response?.data?.error || 'Failed to activate free trial')
} finally {
setActionLoading(false)
}
}
const handleActivate = async () => {
setActionLoading(true)
try {
const res = await api.post('/api/user/trial/activate')
toast.success(res.data.message || 'Free trial activated')
await fetchBillingInfo()
} catch (err) {
toast.error(err.response?.data?.error || 'Failed to activate free trial')
} finally {
setActionLoading(false)
}
}
const packages = [
{ code: 'starter', label: 'Starter Kit', credits: 100, amount: 20, save: '', popular: false },
{ code: 'growth', label: 'Growth Bundle', credits: 500, amount: 90, save: '10% off', popular: true },
{ code: 'volume', label: 'Volume Pack', credits: 1000, amount: 170, save: '15% off', popular: false },
{ code: 'enterprise', label: 'Enterprise', credits: 5000, amount: 800, save: '20% off', popular: false },
]
const selectedPackage = packages.find((pkg) => pkg.code === selectedPackageCode) || null
const handlePayViaUpi = async (pkg) => {
if (!billingConfig?.payments_enabled) {
toast.error('Payments are disabled by admin right now')
return
}
if (!billingConfig?.upi_available) {
toast.error('UPI is not configured by admin yet')
return
}
setPayingCode(pkg.code)
try {
const res = await api.post('/api/user/billing/payment-request', {
package_name: pkg.label,
credits: pkg.credits,
amount_inr: pkg.amount,
})
const payment = res.data.payment
if (payment?.upi_link) {
window.open(payment.upi_link, '_blank', 'noopener,noreferrer')
}
toast.info('UPI intent opened. Submit UTR after payment.')
const utr = window.prompt(`Enter UTR for ${payment.request_ref} after payment (optional now):`, '')
if (utr !== null) {
await api.post(`/api/user/billing/payment-request/${payment.request_ref}/submit`, { utr: utr || '' })
toast.success('Payment submitted for admin approval')
}
await fetchBillingInfo()
} catch (err) {
toast.error(err.response?.data?.error || 'Failed to start UPI payment')
} finally {
setPayingCode('')
}
}
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-accent" /></div>
const isLiveMode = plan?.mode === 'live'
const activeTotal = isLiveMode ? (stats?.live_total_credits || 0) : (stats?.trial_total_credits || 0)
const activeRemaining = isLiveMode ? (stats?.live_remaining_credits || 0) : (stats?.trial_remaining_credits || 0)
const activeUsed = isLiveMode ? (stats?.live_used_this_month || 0) : (stats?.trial_used_credits || 0)
const activeLabel = isLiveMode ? 'Live Credits Remaining' : 'Sandbox Credits Remaining'
const sandboxBase = limits?.sandbox_monthly_message_limit_base || 500
const liveBase = limits?.live_monthly_message_limit_base || 100
const liveBonus = stats?.live_bonus_credits || 0
const activeProgress = activeTotal ? (activeUsed / activeTotal) * 100 : 0
const currentUser = getAuthUser()
const isAdminUser = currentUser?.role === 'admin'
const formatDate = (value) => {
if (!value) return '-'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return '-'
return parsed.toLocaleString()
}
return (
<div className="flex flex-col gap-6">
<div>
<h2 className="text-2xl font-black text-text-primary tracking-tight m-0 mb-1">Plans & Billing</h2>
<p className="text-text-secondary text-sm font-semibold m-0">
Manage quotas, purchase live credits, and track approvals in one place.
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6 items-start">
<div className="xl:col-span-8 flex flex-col gap-6">
<div className="neu-raised rounded-2xl p-6">
<div className="flex flex-wrap items-center justify-between gap-3 mb-5">
<div>
<div className="text-xs font-black uppercase tracking-widest text-text-muted">Current Plan</div>
<div className="text-2xl font-black text-text-primary mt-1">{plan?.name || 'Free Trial'}</div>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setShowQuotaHelp(true)} className="neu-btn w-8 h-8 rounded-full" title="Quota help">
<CircleHelp size={13} />
</button>
<span className="badge-neutral">{plan?.label || 'Sandbox / Free Trial'}</span>
<span className="badge-neutral">{isLiveMode ? 'Live mode' : 'Sandbox mode'}</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="neu-inset rounded-xl p-4">
<div className="text-[11px] font-black uppercase tracking-wider text-text-muted mb-2">{activeLabel}</div>
<div className="text-2xl font-black text-accent">{activeRemaining} / {activeTotal}</div>
<div className="text-[11px] font-semibold text-text-secondary mt-1">Used: {activeUsed}</div>
<div className="mt-3 h-2 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.06)' }}>
<div className="h-full" style={{ width: `${activeProgress}%`, background: 'linear-gradient(90deg, var(--color-accent) 0%, var(--color-accent-dark) 100%)' }} />
</div>
</div>
<div className="neu-inset rounded-xl p-4">
<div className="text-[11px] font-black uppercase tracking-wider text-text-muted mb-2">Sandbox Bucket</div>
<div className="text-xl font-black text-text-primary">{stats?.trial_remaining_credits || 0} / {stats?.trial_total_credits || 0}</div>
<div className="text-[11px] font-semibold text-text-secondary mt-1">Base: {sandboxBase} Used: {stats?.trial_used_credits || 0}</div>
</div>
<div className="neu-inset rounded-xl p-4">
<div className="text-[11px] font-black uppercase tracking-wider text-text-muted mb-2">Live Bucket</div>
<div className="text-xl font-black text-text-primary">{stats?.live_remaining_credits || 0} / {stats?.live_total_credits || 0}</div>
<div className="text-[11px] font-semibold text-text-secondary mt-1">Base: {liveBase} + Purchased: {liveBonus}</div>
</div>
</div>
<div className="text-xs font-semibold text-text-secondary mt-4">
Sandbox and Live are separate counters. Sandbox is self-number only. Purchased packs add credits only to Live.
</div>
<div className="mt-5">
{plan?.trial_status === 'not_applied' && (
<button onClick={() => setShowTrialApplyModal(true)} disabled={actionLoading} className="neu-btn-accent px-6 py-3 w-full md:w-auto">
Apply Free Trial
</button>
)}
{plan?.trial_status === 'applied' && (
<button onClick={handleActivate} disabled={actionLoading} className="neu-btn-accent px-6 py-3 w-full md:w-auto">
{actionLoading ? <Loader2 size={16} className="animate-spin" /> : 'Activate Free Trial'}
</button>
)}
{plan?.trial_status === 'active' && (
<div className="flex flex-wrap items-center gap-2">
<button className="neu-btn px-6 py-3 w-full md:w-auto">Free Trial Active</button>
{isAdminUser ? (
<button
type="button"
onClick={() => navigate('/admin/payment-config')}
className="neu-btn px-6 py-3 w-full md:w-auto"
>
Manage Payment Methods
</button>
) : null}
</div>
)}
</div>
</div>
</div>
<div className="xl:col-span-4">
<div className="neu-raised rounded-2xl p-6 xl:sticky xl:top-6">
<div className="flex items-center justify-between gap-2 mb-2">
<h3 className="text-base font-black text-text-primary m-0">Buy Credits</h3>
<button type="button" onClick={() => setShowPurchaseHelp(true)} className="neu-btn w-8 h-8 rounded-full" title="Purchase help">
<CircleHelp size={13} />
</button>
</div>
<p className="text-sm text-text-secondary font-semibold mb-5">Select bundle and pay via UPI.</p>
<div className={`rounded-xl p-3 mb-4 ${billingConfig?.payments_enabled ? 'bg-accent/5' : 'bg-red-500/10'}`}>
<div className={`text-xs font-black uppercase tracking-wider ${billingConfig?.payments_enabled ? 'text-accent' : 'text-red-400'}`}>
{billingConfig?.payments_enabled ? 'Payments Enabled' : 'Payments Disabled'}
</div>
<div className="text-[11px] font-semibold text-text-secondary mt-1">
{billingConfig?.payment_note || 'Pay via UPI and submit UTR for admin approval.'}
</div>
</div>
<div className="neu-inset rounded-xl overflow-hidden">
<div className="grid grid-cols-[20px_minmax(0,1fr)_70px_70px] gap-2 px-3 py-2 border-b border-white/5 text-[10px] font-black uppercase tracking-wider text-text-muted">
<div />
<div>Package</div>
<div className="text-right">OTPs</div>
<div className="text-right">Price</div>
</div>
<div className="flex flex-col">
{packages.map((pkg) => {
const isSelected = selectedPackageCode === pkg.code
return (
<button
type="button"
key={pkg.code}
onClick={() => setSelectedPackageCode(pkg.code)}
className={`grid grid-cols-[20px_minmax(0,1fr)_70px_70px] gap-2 px-3 py-2.5 text-left border-none cursor-pointer border-b last:border-b-0 border-white/5 ${
isSelected ? 'bg-accent/10' : 'bg-transparent hover:bg-white/5'
}`}
>
<div className="flex items-center justify-center">
<span className={`w-3 h-3 rounded-full border ${isSelected ? 'border-accent bg-accent' : 'border-white/40'}`} />
</div>
<div className="min-w-0">
<div className="text-sm font-black text-text-primary truncate">{pkg.label}</div>
<div className="text-[10px] font-bold text-text-muted">
{pkg.popular ? 'Most Popular' : pkg.save || 'Standard'}
</div>
</div>
<div className="text-sm font-black text-text-primary text-right">{pkg.credits}</div>
<div className="text-sm font-black text-accent text-right">{pkg.amount}</div>
</button>
)
})}
</div>
</div>
<div className="text-xs font-semibold text-text-secondary mt-3">
Selected: <span className="font-black text-text-primary">{selectedPackage?.label || '-'}</span>
{selectedPackage ? `${selectedPackage.credits} OTP credits • ₹${selectedPackage.amount}` : ''}
</div>
<button
type="button"
disabled={!selectedPackage || payingCode === selectedPackage?.code}
onClick={() => selectedPackage && handlePayViaUpi(selectedPackage)}
className="neu-btn-accent py-3 px-4 w-full mt-4"
>
{selectedPackage && payingCode === selectedPackage.code ? (
<span className="inline-flex items-center gap-2"><Loader2 size={14} className="animate-spin" /> Processing...</span>
) : (
`Purchase Selected${selectedPackage ? ` • ₹${selectedPackage.amount}` : ''}`
)}
</button>
</div>
</div>
</div>
<div className="neu-raised rounded-2xl p-6">
<div className="flex items-center justify-between gap-2 mb-4">
<div className="flex items-center gap-2">
<CreditCard size={16} className="text-accent" />
<h3 className="text-base font-black text-text-primary m-0">Payment Requests & History</h3>
</div>
<button type="button" onClick={() => setShowHistoryHelp(true)} className="neu-btn w-8 h-8 rounded-full" title="History help">
<CircleHelp size={13} />
</button>
</div>
<div className="max-h-[320px] overflow-auto flex flex-col gap-2">
{payments.length === 0 ? (
<div className="text-sm font-semibold text-text-muted py-6 text-center">No payment requests yet.</div>
) : payments.map((payment) => (
<div key={payment.request_ref} className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-black text-text-primary">{payment.request_ref}</div>
<span className={payment.status === 'approved' ? 'badge-success' : payment.status === 'rejected' ? 'badge-error' : 'badge-warning'}>{payment.status}</span>
</div>
<div className="text-xs font-semibold text-text-secondary mt-1">
{payment.package_name} {payment.credits} credits {Number(payment.amount_inr).toFixed(2)}
</div>
<div className="text-[11px] font-semibold text-text-muted mt-1">Created: {formatDate(payment.created_at)}</div>
{payment.utr ? <div className="text-[11px] font-semibold text-text-muted mt-1">UTR: {payment.utr}</div> : null}
{payment.admin_note ? <div className="text-[11px] font-semibold text-text-secondary mt-1">Note: {payment.admin_note}</div> : null}
</div>
))}
</div>
</div>
{showQuotaHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowQuotaHelp(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[720px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-black text-text-primary m-0">Quota Help</h2>
<button type="button" onClick={() => setShowQuotaHelp(false)} className="neu-btn p-2"><X size={14} /></button>
</div>
<div className="grid gap-3 text-sm font-semibold text-text-secondary">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Sandbox bucket: self number only, separate from live.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Live bucket: any valid number, separate from sandbox.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Purchased packs add credits only to live bucket.</div>
</div>
</div>
</div>
) : null}
{showTrialApplyModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
onClick={() => !actionLoading && setShowTrialApplyModal(false)}
className="absolute inset-0 bg-black/70 border-none cursor-pointer"
/>
<div className="relative w-full max-w-[760px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-black text-text-primary m-0">Activate Free Trial</h2>
<button
type="button"
onClick={() => setShowTrialApplyModal(false)}
disabled={actionLoading}
className="neu-btn p-2"
>
<X size={14} />
</button>
</div>
<div className="grid gap-3 text-sm font-semibold text-text-secondary">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>
1. Sandbox trial quota: <b>{stats?.trial_total_credits || 500}</b> OTPs/month (self WhatsApp number only).
</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>
2. Live starter quota: <b>{stats?.live_total_credits || 100}</b> OTPs/month (can send to valid external numbers).
</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>
3. Sandbox and Live use separate API key sets and separate usage counters.
</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>
4. Misuse, spam, or policy violations may suspend trial access.
</div>
</div>
<div className="mt-5 flex flex-col sm:flex-row gap-3 sm:justify-end">
<button
type="button"
onClick={() => setShowTrialApplyModal(false)}
disabled={actionLoading}
className="neu-btn px-5 py-2.5"
>
Cancel
</button>
<button
type="button"
onClick={handleApply}
disabled={actionLoading}
className="neu-btn-accent px-5 py-2.5"
>
{actionLoading ? <span className="inline-flex items-center gap-2"><Loader2 size={14} className="animate-spin" /> Activating...</span> : 'Agree & Activate Trial'}
</button>
</div>
</div>
</div>
) : null}
{showPurchaseHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowPurchaseHelp(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[720px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-black text-text-primary m-0">Purchase Flow</h2>
<button type="button" onClick={() => setShowPurchaseHelp(false)} className="neu-btn p-2"><X size={14} /></button>
</div>
<div className="grid gap-3 text-sm font-semibold text-text-secondary">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>1. Select package.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>2. Click Purchase Selected.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>3. Complete UPI payment and submit UTR.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>4. Admin approves payment and credits move to live bucket.</div>
</div>
</div>
</div>
) : null}
{showHistoryHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowHistoryHelp(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[720px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-black text-text-primary m-0">Request Status</h2>
<button type="button" onClick={() => setShowHistoryHelp(false)} className="neu-btn p-2"><X size={14} /></button>
</div>
<div className="grid gap-3 text-sm font-semibold text-text-secondary">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><b>pending:</b> created but proof not submitted.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><b>submitted:</b> UTR submitted, waiting admin review.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><b>approved:</b> credits added to live bucket.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><b>rejected:</b> not approved by admin.</div>
</div>
</div>
</div>
) : null}
</div>
)
}