200 lines
8.4 KiB
TypeScript
200 lines
8.4 KiB
TypeScript
"use client";
|
|
import { useEffect, useState } from "react";
|
|
import { CheckCircle2, Download, CreditCard, RefreshCw, Tag } from "lucide-react";
|
|
import { api, apiBase } from "@/lib/api";
|
|
import { inr } from "@/lib/utils";
|
|
import { purchasePlan } from "@/lib/razorpay";
|
|
|
|
export default function Billing() {
|
|
const [sub, setSub] = useState<any>(null);
|
|
const [plans, setPlans] = useState<any[]>([]);
|
|
const [invoices, setInvoices] = useState<any[]>([]);
|
|
const [coupon, setCoupon] = useState("");
|
|
const [couponMsg, setCouponMsg] = useState("");
|
|
const [busy, setBusy] = useState("");
|
|
const [toast, setToast] = useState("");
|
|
|
|
async function load() {
|
|
api("/billing/subscription").then(setSub).catch(() => {});
|
|
api("/billing/plans").then(setPlans).catch(() => setPlans([]));
|
|
api("/billing/invoices").then(setInvoices).catch(() => {});
|
|
}
|
|
useEffect(() => { load(); }, []);
|
|
|
|
async function applyCoupon() {
|
|
setCouponMsg("");
|
|
try {
|
|
const r = await api("/billing/coupon/apply", { method: "POST", body: JSON.stringify({ code: coupon }) });
|
|
setCouponMsg(`✓ ${r.code}: ${r.percent_off}% off applied`);
|
|
} catch (e: any) {
|
|
setCouponMsg(typeof e.detail === "string" ? e.detail : "Invalid coupon");
|
|
}
|
|
}
|
|
|
|
async function choose(planId: string, planName: string) {
|
|
setBusy(planId);
|
|
try {
|
|
await purchasePlan(planId, coupon, (inv) => {
|
|
setToast(`${planName} activated${inv ? ` · Invoice ${inv}` : ""}`);
|
|
});
|
|
await load();
|
|
} catch (e: any) {
|
|
setToast(e?.message || "Payment failed");
|
|
} finally { setBusy(""); }
|
|
}
|
|
|
|
async function toggleAutoRenew() {
|
|
const next = !sub?.auto_renew;
|
|
await api("/billing/subscription/auto-renewal", { method: "PUT", body: JSON.stringify({ auto_renew: next }) });
|
|
load();
|
|
}
|
|
|
|
const u = sub?.usage;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{toast && (
|
|
<div className="card p-3 bg-emerald-50 border-emerald-200 text-emerald-800 text-sm flex items-center gap-2">
|
|
<CheckCircle2 className="h-4 w-4" /> {toast}
|
|
</div>
|
|
)}
|
|
|
|
<h2 className="text-xl font-bold">Billing & Subscription</h2>
|
|
|
|
{/* KPIs */}
|
|
<div className="grid sm:grid-cols-4 gap-4">
|
|
<Kpi label="Current Plan" value={sub?.plan?.name ?? "—"} />
|
|
<Kpi label="Status" value={(sub?.status ?? "—")} className="capitalize" />
|
|
<Kpi label="Days Remaining" value={sub?.days_remaining ?? "—"} />
|
|
<div className="card p-5">
|
|
<div className="text-sm text-slate-500">Auto-renewal</div>
|
|
<button onClick={toggleAutoRenew}
|
|
className={`mt-2 w-11 h-6 rounded-full relative transition ${sub?.auto_renew ? "bg-emerald-500" : "bg-slate-300"}`}>
|
|
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition ${sub?.auto_renew ? "left-5" : "left-0.5"}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Usage bars */}
|
|
{u && (
|
|
<div className="card p-5">
|
|
<h3 className="font-bold mb-4">Usage</h3>
|
|
<div className="grid sm:grid-cols-3 gap-6">
|
|
<Usage label="Products" used={u.products} limit={u.product_limit} />
|
|
<Usage label="Users" used={u.users} limit={u.user_limit} />
|
|
<Usage label="Storage (GB)" used={u.storage_used_gb} limit={u.storage_total_gb} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Coupon */}
|
|
<div className="card p-5">
|
|
<h3 className="font-bold mb-3 flex items-center gap-2"><Tag className="h-4 w-4" /> Have a coupon?</h3>
|
|
<div className="flex gap-2 max-w-md">
|
|
<input value={coupon} onChange={(e) => setCoupon(e.target.value)} placeholder="WELCOME10"
|
|
className="flex-1 px-3 py-2 rounded-lg border border-slate-200 text-sm" />
|
|
<button onClick={applyCoupon} className="px-4 py-2 rounded-lg bg-slate-100 text-sm font-medium">Apply</button>
|
|
</div>
|
|
{couponMsg && <p className="text-sm mt-2 text-slate-600">{couponMsg}</p>}
|
|
</div>
|
|
|
|
{/* Available plans */}
|
|
<div>
|
|
<h3 className="font-bold mb-3">Available Plans</h3>
|
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
{plans.map((p) => {
|
|
const current = sub?.plan?.id === p.id;
|
|
return (
|
|
<div key={p.id} className={`card p-5 ${current ? "ring-2 ring-brand-500" : ""}`}>
|
|
<h4 className="font-bold">{p.name}</h4>
|
|
<div className="text-2xl font-extrabold mt-1">{p.price ? inr(p.price) : "Free"}
|
|
{p.price > 0 && <span className="text-xs font-normal text-slate-400">/mo</span>}</div>
|
|
<ul className="text-xs text-slate-500 mt-3 space-y-1">
|
|
<li>Products: {p.product_limit ?? "∞"}</li>
|
|
<li>Users: {p.user_limit ?? "∞"}</li>
|
|
<li>Storage: {p.storage_gb} GB</li>
|
|
</ul>
|
|
<button disabled={current || busy === p.id}
|
|
onClick={() => choose(p.id, p.name)}
|
|
className={`mt-4 w-full py-2 rounded-lg text-sm font-semibold ${current ? "bg-slate-100 text-slate-400" : "bg-brand-600 text-white"}`}>
|
|
{current ? "Current plan" : busy === p.id ? "Processing…" : "Choose"}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
{plans.length === 0 && <p className="text-sm text-slate-400">Plans load for billing admins.</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Payment history */}
|
|
<div>
|
|
<h3 className="font-bold mb-3">Payment History</h3>
|
|
<div className="card overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-50 text-slate-500 text-left">
|
|
<tr>
|
|
<th className="px-4 py-3 font-medium">Invoice</th>
|
|
<th className="px-4 py-3 font-medium">Plan</th>
|
|
<th className="px-4 py-3 font-medium">Subtotal</th>
|
|
<th className="px-4 py-3 font-medium">GST</th>
|
|
<th className="px-4 py-3 font-medium">Total</th>
|
|
<th className="px-4 py-3 font-medium">Status</th>
|
|
<th className="px-4 py-3"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{invoices.map((i) => (
|
|
<tr key={i.id} className="hover:bg-slate-50">
|
|
<td className="px-4 py-3 font-mono text-slate-700">{i.invoice_number}</td>
|
|
<td className="px-4 py-3">{i.plan_name}</td>
|
|
<td className="px-4 py-3">{inr(i.subtotal)}</td>
|
|
<td className="px-4 py-3">{inr(i.gst_amount)}</td>
|
|
<td className="px-4 py-3 font-semibold">{inr(i.total)}</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-xs font-semibold bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded-full capitalize">{i.status}</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<a href={`${apiBase}/billing/invoices/${i.id}/pdf`}
|
|
className="inline-flex items-center gap-1 text-brand-600 font-medium">
|
|
<Download className="h-4 w-4" /> PDF
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{invoices.length === 0 && (
|
|
<tr><td colSpan={7} className="px-4 py-12 text-center text-slate-400">
|
|
<CreditCard className="h-8 w-8 mx-auto mb-2 opacity-50" /> No invoices yet.
|
|
</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Kpi({ label, value, className }: any) {
|
|
return (
|
|
<div className="card p-5">
|
|
<div className="text-sm text-slate-500">{label}</div>
|
|
<div className={`text-2xl font-extrabold mt-1 ${className ?? ""}`}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Usage({ label, used, limit }: { label: string; used: number; limit: number | null }) {
|
|
const pct = limit == null ? 8 : Math.min(100, Math.round((used / limit) * 100));
|
|
return (
|
|
<div>
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span className="text-slate-600">{label}</span>
|
|
<span className="text-slate-500">{used} / {limit ?? "∞"}</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-slate-100">
|
|
<div className="h-full rounded-full bg-brand-500" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|