Initial project upload

This commit is contained in:
Mohamed Mathar Irfan
2026-07-28 17:57:02 +05:30
commit ed6610d5d8
23919 changed files with 3003316 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
export default function Audit() {
const [logs, setLogs] = useState<any[]>([]);
useEffect(() => { api("/admin/audit-logs").then(setLogs).catch(() => {}); }, []);
return (
<div className="space-y-5">
<h2 className="text-xl font-bold">Audit Logs</h2>
<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">Time</th><th className="px-4 py-3 font-medium">User</th><th className="px-4 py-3 font-medium">Action</th><th className="px-4 py-3 font-medium">Module</th><th className="px-4 py-3 font-medium">Details</th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{logs.map((l, i) => (
<tr key={i}>
<td className="px-4 py-3 text-slate-500">{l.at ? new Date(l.at).toLocaleString() : ""}</td>
<td className="px-4 py-3">{l.user ?? "—"}</td>
<td className="px-4 py-3">{l.action}</td>
<td className="px-4 py-3">{l.module}</td>
<td className="px-4 py-3 text-slate-500">{l.details}</td>
</tr>
))}
{logs.length === 0 && <tr><td colSpan={5} className="px-4 py-12 text-center text-slate-400">No audit logs.</td></tr>}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ShieldHalf, Building2, CreditCard, FileText, LogOut, BarChart3,
PieChart, RefreshCw, LifeBuoy, LayoutGrid,
} from "lucide-react";
import { api, clearToken } from "@/lib/api";
const nav = [
{ href: "/admin", label: "Dashboard", icon: LayoutGrid },
{ href: "/admin/organizations", label: "Organizations", icon: Building2 },
{ href: "/admin/utilization", label: "Utilization", icon: PieChart },
{ href: "/admin/retention", label: "Retention", icon: RefreshCw },
{ href: "/admin/plans", label: "Plans & Pricing", icon: CreditCard },
{ href: "/admin/revenue", label: "Revenue", icon: BarChart3 },
{ href: "/admin/support", label: "Support View", icon: LifeBuoy },
{ href: "/admin/audit", label: "Audit Logs", icon: FileText },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [me, setMe] = useState<any>(null);
useEffect(() => {
api("/me")
.then((m) => {
if (!m.is_product_admin) { router.replace("/dashboard"); return; }
setMe(m);
})
.catch(() => { clearToken(); router.replace("/login"); });
}, [router]);
if (!me) return <div className="min-h-screen grid place-items-center text-slate-400">Loading</div>;
function logout() { clearToken(); window.location.href = "/login"; }
return (
<div className="flex min-h-screen bg-slate-50">
<aside className="w-64 shrink-0 bg-sidebar text-white flex flex-col h-screen sticky top-0">
<div className="px-6 py-6 flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-white/10 grid place-items-center">
<ShieldHalf className="h-6 w-6" />
</div>
<div>
<div className="font-extrabold text-lg leading-none">VerifyPack</div>
<div className="text-[10px] text-white/50 mt-1">Super Admin Panel</div>
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 space-y-1 mt-2">
{nav.map(({ href, label, icon: Icon }) => (
<Link key={href} href={href}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-white/70 hover:bg-white/10 hover:text-white">
<Icon className="h-[18px] w-[18px]" /> {label}
</Link>
))}
</nav>
<div className="p-4 space-y-2">
<div className="rounded-xl bg-white/5 px-4 py-3 text-[11px]">
<div className="text-white font-semibold">{me.name}</div>
<div className="text-white/50 capitalize">{me.product_admin_role?.replace("_", " ")}</div>
</div>
<button onClick={logout}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl text-sm font-medium text-white/70 hover:bg-white/10">
<LogOut className="h-[18px] w-[18px]" /> Log out
</button>
</div>
</aside>
<main className="flex-1 min-w-0 p-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Mail, UserCog, Clock, Plus } from "lucide-react";
import { api } from "@/lib/api";
const LIFECYCLE = ["active", "grace", "archived", "deleted"];
export default function OrgDetail() {
const { org_id } = useParams<{ org_id: string }>();
const [d, setD] = useState<any>(null);
const [hist, setHist] = useState<any>(null);
const [notes, setNotes] = useState<any[]>([]);
const [note, setNote] = useState("");
const [toast, setToast] = useState("");
function load() {
api(`/admin/organizations/${org_id}`).then(setD).catch(() => {});
api(`/admin/organizations/${org_id}/history`).then(setHist).catch(() => {});
api(`/admin/organizations/${org_id}/notes`).then(setNotes).catch(() => setNotes([]));
}
useEffect(() => { load(); }, [org_id]);
async function addNote() {
if (!note.trim()) return;
await api(`/admin/organizations/${org_id}/notes`, { method: "POST", body: JSON.stringify({ note }) });
setNote(""); load();
}
async function sendReminder() {
const r = await api(`/admin/organizations/${org_id}/send-reminder`, { method: "POST" });
setToast(`Reminder sent to ${r.to}`);
setTimeout(() => setToast(""), 2500);
}
async function extend() {
await api(`/admin/organizations/${org_id}/subscription`, { method: "PUT", body: JSON.stringify({ extend_days: 30 }) });
setToast("Subscription extended 30 days"); load();
setTimeout(() => setToast(""), 2500);
}
async function impersonate() {
const r = await api(`/admin/organizations/${org_id}/impersonate`, { method: "POST" });
setToast(r.message);
setTimeout(() => setToast(""), 2500);
}
if (!d) return <div className="text-slate-400">Loading</div>;
const curStage = LIFECYCLE.indexOf(d.subscription_status);
return (
<div className="space-y-5">
{toast && <div className="card p-3 bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{toast}</div>}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{d.name}</h2>
<p className="text-sm text-slate-500 capitalize">{d.plan} · {d.subscription_status}</p>
</div>
<div className="flex gap-2">
<button onClick={extend} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Clock className="h-4 w-4" /> Extend 30d</button>
<button onClick={sendReminder} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Mail className="h-4 w-4" /> Send reminder</button>
<button onClick={impersonate} className="flex items-center gap-2 bg-brand-600 text-white px-3 py-1.5 rounded-lg text-sm font-semibold"><UserCog className="h-4 w-4" /> Impersonate</button>
</div>
</div>
{/* Lifecycle timeline */}
<div className="card p-5">
<h3 className="font-bold mb-4">Subscription Lifecycle</h3>
<div className="flex items-center">
{LIFECYCLE.map((stage, i) => (
<div key={stage} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center">
<div className={`h-9 w-9 rounded-full grid place-items-center text-xs font-bold capitalize
${i <= curStage ? "bg-brand-600 text-white" : "bg-slate-100 text-slate-400"}`}>
{i + 1}
</div>
<span className="text-xs mt-1 capitalize text-slate-500">{stage}</span>
</div>
{i < LIFECYCLE.length - 1 && (
<div className={`flex-1 h-0.5 mx-2 ${i < curStage ? "bg-brand-600" : "bg-slate-200"}`} />
)}
</div>
))}
</div>
</div>
{/* Utilization */}
<div className="grid sm:grid-cols-3 gap-4">
<UtilBar label="Products" used={d.products_used} limit={d.product_limit} />
<UtilBar label="Users" used={d.users_used} limit={d.user_limit} />
<UtilBar label="Storage (GB)" used={d.storage_used_gb} limit={d.storage_total_gb} />
</div>
<div className="grid sm:grid-cols-3 gap-4">
<Stat label="QR Codes" value={d.qr_codes} />
<Stat label="Total Scans" value={d.total_scans} />
<Stat label="Plan Price" value={`${d.plan_price}`} />
</div>
{/* Admins */}
<div className="card p-5">
<h3 className="font-bold mb-3">Admin Users</h3>
<div className="space-y-2 text-sm">
{(d.admins ?? []).map((a: any, i: number) => (
<div key={i} className="flex justify-between border-b border-slate-100 py-2">
<span>{a.name} <span className="text-slate-400">({a.email})</span></span>
<span className="text-slate-500">{a.last_login ? new Date(a.last_login).toLocaleDateString() : "never"}</span>
</div>
))}
{(!d.admins || d.admins.length === 0) && <p className="text-slate-400">No admins.</p>}
</div>
</div>
{/* History */}
{hist && (
<div className="grid md:grid-cols-2 gap-4">
<div className="card p-5">
<h3 className="font-bold mb-3">Invoices</h3>
<div className="space-y-1 text-sm">
{hist.invoices.map((i: any, k: number) => (
<div key={k} className="flex justify-between border-b border-slate-100 py-1.5">
<span className="font-mono">{i.invoice_number}</span>
<span>{i.total} · <span className="capitalize text-slate-500">{i.status}</span></span>
</div>
))}
{hist.invoices.length === 0 && <p className="text-slate-400">No invoices.</p>}
</div>
</div>
<div className="card p-5">
<h3 className="font-bold mb-3">Plan History</h3>
<div className="space-y-1 text-sm">
{hist.plan_history.map((h: any, k: number) => (
<div key={k} className="flex justify-between border-b border-slate-100 py-1.5">
<span>{h.from ?? "—"} {h.to}</span>
<span className="text-slate-400">{new Date(h.at).toLocaleDateString()}</span>
</div>
))}
{hist.plan_history.length === 0 && <p className="text-slate-400">No changes.</p>}
</div>
</div>
</div>
)}
{/* Support notes */}
<div className="card p-5">
<h3 className="font-bold mb-3">Internal Support Notes</h3>
<div className="flex gap-2 mb-3">
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Add an internal note…"
className="flex-1 px-3 py-2 rounded-lg border border-slate-200 text-sm" />
<button onClick={addNote} className="flex items-center gap-1 bg-brand-600 text-white px-3 py-2 rounded-lg text-sm font-semibold">
<Plus className="h-4 w-4" /> Add
</button>
</div>
<div className="space-y-2">
{notes.map((n) => (
<div key={n.id} className="text-sm border-b border-slate-100 py-2">
<div>{n.note}</div>
<div className="text-xs text-slate-400">{n.author} · {new Date(n.at).toLocaleString()}</div>
</div>
))}
{notes.length === 0 && <p className="text-sm text-slate-400">No notes yet.</p>}
</div>
</div>
</div>
);
}
function UtilBar({ 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 className="card p-4">
<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>
);
}
function Stat({ label, value }: any) {
return (
<div className="card p-4">
<div className="text-sm text-slate-500">{label}</div>
<div className="text-2xl font-extrabold mt-1">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Download, Search, Building2, CheckCircle2, Clock, Archive } from "lucide-react";
import { api, apiBase } from "@/lib/api";
function bar(used: number, limit: number | null) {
if (limit == null) return 12;
return Math.min(100, Math.round((used / limit) * 100));
}
export default function AdminOrgs() {
const [orgs, setOrgs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [q, setQ] = useState("");
const [status, setStatus] = useState("");
const [plan, setPlan] = useState("");
useEffect(() => {
api("/admin/organizations").then(setOrgs).catch(() => {}).finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => orgs.filter((o) =>
(!q || o.name.toLowerCase().includes(q.toLowerCase())) &&
(!status || o.subscription_status === status) &&
(!plan || o.plan === plan)), [orgs, q, status, plan]);
const kpis = {
total: orgs.length,
active: orgs.filter((o) => o.subscription_status === "active").length,
grace: orgs.filter((o) => o.subscription_status === "grace").length,
archived: orgs.filter((o) => o.subscription_status === "archived").length,
};
const plans = [...new Set(orgs.map((o) => o.plan).filter(Boolean))];
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">All Organizations</h2>
<p className="text-sm text-slate-500">Utilization across every client org.</p>
</div>
<div className="flex gap-2">
<a href={`${apiBase}/admin/utilization-report?format=csv`} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Download className="h-4 w-4" /> CSV</a>
<a href={`${apiBase}/admin/utilization-report?format=pdf`} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Download className="h-4 w-4" /> PDF</a>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Kpi label="Total Orgs" value={kpis.total} icon={<Building2 className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="Active" value={kpis.active} icon={<CheckCircle2 className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<Kpi label="Grace Period" value={kpis.grace} icon={<Clock className="h-5 w-5" />} tint="bg-amber-50 text-amber-600" />
<Kpi label="Archived" value={kpis.archived} icon={<Archive className="h-5 w-5" />} tint="bg-slate-100 text-slate-600" />
</div>
<div className="card p-4 flex flex-wrap items-end gap-3">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search org…" className="pl-9 pr-3 py-2 w-52 rounded-lg border border-slate-200 text-sm" />
</div>
<label className="block"><span className="text-xs text-slate-500">Status</span>
<select value={status} onChange={(e) => setStatus(e.target.value)} className="mt-1 block px-3 py-2 rounded-lg border border-slate-200 text-sm min-w-[130px]">
<option value="">All Status</option>{["active", "grace", "archived", "deleted"].map((s) => <option key={s} value={s} className="capitalize">{s}</option>)}
</select>
</label>
<label className="block"><span className="text-xs text-slate-500">Plan</span>
<select value={plan} onChange={(e) => setPlan(e.target.value)} className="mt-1 block px-3 py-2 rounded-lg border border-slate-200 text-sm min-w-[130px]">
<option value="">All Plans</option>{plans.map((pl) => <option key={pl} value={pl}>{pl}</option>)}
</select>
</label>
</div>
<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">Organization</th>
<th className="px-4 py-3 font-medium">Plan</th>
<th className="px-4 py-3 font-medium">Products</th>
<th className="px-4 py-3 font-medium">Users</th>
<th className="px-4 py-3 font-medium">QRs</th>
<th className="px-4 py-3 font-medium">Scans</th>
<th className="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((o) => (
<tr key={o.org_id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-medium text-brand-700"><Link href={`/admin/organizations/${o.org_id}`} className="hover:underline">{o.name}</Link></td>
<td className="px-4 py-3">{o.plan ?? "—"}</td>
<td className="px-4 py-3 w-40">
<div className="text-xs text-slate-500 mb-1">{o.products_used} / {o.product_limit ?? "∞"}</div>
<div className="h-1.5 rounded-full bg-slate-100"><div className="h-full rounded-full bg-blue-500" style={{ width: `${bar(o.products_used, o.product_limit)}%` }} /></div>
</td>
<td className="px-4 py-3">{o.users_used} / {o.user_limit ?? "∞"}</td>
<td className="px-4 py-3">{o.qr_codes}</td>
<td className="px-4 py-3">{o.total_scans}</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">{o.subscription_status}</span></td>
</tr>
))}
{!loading && filtered.length === 0 && <tr><td colSpan={7} className="px-4 py-12 text-center text-slate-400">No organizations match.</td></tr>}
</tbody>
</table>
</div>
</div>
);
}
function Kpi({ label, value, icon, tint }: any) {
return (
<div className="card p-5">
<div className="flex items-start gap-4">
<div className={`h-12 w-12 rounded-xl grid place-items-center shrink-0 ${tint}`}>{icon}</div>
<div><div className="text-sm text-slate-500">{label}</div><div className="text-2xl font-extrabold text-slate-900 mt-0.5">{value}</div></div>
</div>
</div>
);
}

123
frontend/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
PieChart, Pie, Cell,
} from "recharts";
import {
Building2, IndianRupee, TrendingUp, Users, QrCode, ScanLine, ArrowRight,
} from "lucide-react";
import { api } from "@/lib/api";
import { inr } from "@/lib/utils";
const DONUT = ["#2563eb", "#10b981", "#f59e0b", "#8b5cf6", "#94a3b8"];
export default function AdminDashboard() {
const [d, setD] = useState<any>(null);
const [rev, setRev] = useState<any>(null);
useEffect(() => {
api("/admin/dashboard").then(setD).catch(() => {});
api("/admin/revenue").then(setRev).catch(() => {});
}, []);
const trend = (d?.revenue_trend ?? []).map((t: any) => ({ label: t.month.slice(2), revenue: t.revenue }));
return (
<div className="space-y-5">
<div>
<h2 className="text-xl font-bold">Platform Overview</h2>
<p className="text-sm text-slate-500">VerifyPack all organizations at a glance.</p>
</div>
{/* KPI row 1 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Kpi label="Total Organizations" value={d?.total_orgs ?? 0} sub={`${d?.active ?? 0} active`} icon={<Building2 className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="MRR" value={inr(d?.mrr ?? 0)} sub={`ARR ${inr(d?.arr ?? 0)}`} icon={<IndianRupee className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<Kpi label="Paying Orgs" value={d?.paying_orgs ?? 0} sub={`ARPO ${inr(d?.arpo ?? 0)}`} icon={<Users className="h-5 w-5" />} tint="bg-violet-50 text-violet-600" />
<Kpi label="Total Revenue" value={inr(d?.total_revenue ?? 0)} sub="all time" icon={<TrendingUp className="h-5 w-5" />} tint="bg-amber-50 text-amber-500" />
</div>
{/* KPI row 2 — lifecycle + platform totals */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<Mini label="Active" value={d?.active ?? 0} color="text-emerald-600" />
<Mini label="Grace" value={d?.grace ?? 0} color="text-amber-600" />
<Mini label="Archived" value={d?.archived ?? 0} color="text-slate-500" />
<Mini label="Total QRs" value={d?.total_qr ?? 0} color="text-blue-600" icon={<QrCode className="h-4 w-4" />} />
<Mini label="Total Scans" value={d?.total_scans ?? 0} color="text-violet-600" icon={<ScanLine className="h-4 w-4" />} />
</div>
<div className="grid lg:grid-cols-3 gap-4">
<div className="card p-5 lg:col-span-2">
<h3 className="font-bold mb-2">Revenue Trend</h3>
<ResponsiveContainer width="100%" height={230}>
<AreaChart data={trend} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
<defs><linearGradient id="rev" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#10b981" stopOpacity={0.35} /><stop offset="100%" stopColor="#10b981" stopOpacity={0} />
</linearGradient></defs>
<CartesianGrid strokeDasharray="3 3" stroke="#eef2f7" vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} tick={{ fontSize: 12, fill: "#94a3b8" }} />
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12, fill: "#94a3b8" }} />
<Tooltip formatter={(v: any) => inr(v)} />
<Area type="monotone" dataKey="revenue" stroke="#10b981" strokeWidth={2.5} fill="url(#rev)" />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="card p-5">
<h3 className="font-bold mb-2">Revenue by Plan</h3>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={rev?.plan_breakdown ?? []} dataKey="revenue" nameKey="plan" innerRadius={50} outerRadius={80} paddingAngle={2}>
{(rev?.plan_breakdown ?? []).map((_: any, i: number) => <Cell key={i} fill={DONUT[i % DONUT.length]} />)}
</Pie>
<Tooltip formatter={(v: any) => inr(v)} />
</PieChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-2 justify-center mt-2 text-xs">
{(rev?.plan_breakdown ?? []).map((p: any, i: number) => (
<span key={p.plan} className="flex items-center gap-1"><span className="h-2 w-2 rounded-full" style={{ background: DONUT[i % DONUT.length] }} />{p.plan}</span>
))}
</div>
</div>
</div>
<div className="grid sm:grid-cols-3 gap-4">
<Quick href="/admin/organizations" label="View Organizations" />
<Quick href="/admin/revenue" label="Revenue & Finance" />
<Quick href="/admin/retention" label="Retention & Lifecycle" />
</div>
</div>
);
}
function Kpi({ label, value, sub, icon, tint }: any) {
return (
<div className="card p-5">
<div className="flex items-start gap-4">
<div className={`h-12 w-12 rounded-xl grid place-items-center shrink-0 ${tint}`}>{icon}</div>
<div className="min-w-0">
<div className="text-sm text-slate-500">{label}</div>
<div className="text-2xl font-extrabold text-slate-900 mt-0.5">{value}</div>
<div className="text-xs text-slate-400 mt-1">{sub}</div>
</div>
</div>
</div>
);
}
function Mini({ label, value, color, icon }: any) {
return (
<div className="card p-4">
<div className={`text-xl font-extrabold flex items-center gap-1 ${color}`}>{icon}{value.toLocaleString?.() ?? value}</div>
<div className="text-xs text-slate-500">{label}</div>
</div>
);
}
function Quick({ href, label }: any) {
return (
<Link href={href} className="card p-4 flex items-center justify-between hover:shadow-sm transition">
<span className="font-medium text-slate-700">{label}</span>
<ArrowRight className="h-4 w-4 text-brand-600" />
</Link>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useEffect, useState } from "react";
import { Plus, Pencil, X, CheckCircle2 } from "lucide-react";
import { api } from "@/lib/api";
import { inr } from "@/lib/utils";
type PlanForm = {
_id?: string; name: string; price: number;
product_limit: number | null; user_limit: number | null;
storage_gb: number; grace_days: number; archive_years: number;
features: string; enabled: boolean;
};
const empty: PlanForm = {
name: "", price: 0, product_limit: 100, user_limit: 3, storage_gb: 10,
grace_days: 30, archive_years: 1, features: "", enabled: true,
};
export default function Plans() {
const [plans, setPlans] = useState<any[]>([]);
const [form, setForm] = useState<PlanForm | null>(null);
const [toast, setToast] = useState("");
function load() { api("/admin/plans").then(setPlans).catch(() => {}); }
useEffect(() => { load(); }, []);
function openEdit(p: any) {
setForm({ _id: p.id, name: p.name, price: p.price,
product_limit: p.product_limit, user_limit: p.user_limit,
storage_gb: p.storage_gb, grace_days: p.grace_days ?? 30,
archive_years: p.archive_years ?? 1,
features: (p.features ?? []).join("\n"), enabled: p.enabled });
}
async function save() {
if (!form) return;
const { _id, features, ...rest } = form;
const payload = { ...rest, features: features.split("\n").map((s) => s.trim()).filter(Boolean) };
if (_id) await api(`/admin/plans/${_id}`, { method: "PUT", body: JSON.stringify(payload) });
else await api("/admin/plans", { method: "POST", body: JSON.stringify(payload) });
setForm(null); setToast("Plan saved"); setTimeout(() => setToast(""), 2000); load();
}
return (
<div className="space-y-5">
{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>}
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Plans & Pricing</h2>
<button onClick={() => setForm({ ...empty })} className="flex items-center gap-2 bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-semibold"><Plus className="h-4 w-4" /> Add Plan</button>
</div>
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-4">
{plans.map((p) => (
<div key={p.id} className={`card p-5 relative ${!p.enabled ? "opacity-60" : ""}`}>
<button onClick={() => openEdit(p)} className="absolute top-3 right-3 h-8 w-8 grid place-items-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50"><Pencil className="h-4 w-4" /></button>
<h3 className="font-bold">{p.name}</h3>
<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>
{!p.enabled && <span className="text-[10px] font-semibold text-rose-500 mt-2 block">DISABLED</span>}
</div>
))}
</div>
<div className="card p-5">
<h3 className="font-bold mb-1">System Configuration</h3>
<p className="text-sm text-slate-500">Grace period and archive/preservation windows are configured per plan above. Changes to plan prices do not affect active subscriptions until renewal.</p>
</div>
{form && (
<div className="fixed inset-0 bg-black/40 z-50 grid place-items-center p-4 overflow-y-auto" onClick={() => setForm(null)}>
<div className="bg-white rounded-2xl w-full max-w-lg my-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
<h3 className="font-bold">{form._id ? "Edit Plan" : "Add Plan"}</h3>
<button onClick={() => setForm(null)}><X className="h-5 w-5 text-slate-400" /></button>
</div>
<div className="p-5 grid grid-cols-2 gap-3">
<Inp label="Name" v={form.name} on={(x) => setForm({ ...form, name: x })} full />
<Inp label="Price (₹/mo)" type="number" v={form.price} on={(x) => setForm({ ...form, price: Number(x) })} />
<Inp label="Storage (GB)" type="number" v={form.storage_gb} on={(x) => setForm({ ...form, storage_gb: Number(x) })} />
<LimitInp label="Product limit" v={form.product_limit} on={(x) => setForm({ ...form, product_limit: x })} />
<LimitInp label="User limit" v={form.user_limit} on={(x) => setForm({ ...form, user_limit: x })} />
<Inp label="Grace days" type="number" v={form.grace_days} on={(x) => setForm({ ...form, grace_days: Number(x) })} />
<Inp label="Archive years" type="number" v={form.archive_years} on={(x) => setForm({ ...form, archive_years: Number(x) })} />
<label className="block col-span-2">
<span className="text-sm font-medium">Features (one per line)</span>
<textarea value={form.features} onChange={(e) => setForm({ ...form, features: e.target.value })} rows={4} className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm" />
</label>
<label className="flex items-center gap-2 col-span-2 text-sm">
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} /> Enabled (new signups can select this plan)
</label>
</div>
<div className="flex justify-end gap-3 px-5 py-4 border-t border-slate-100">
<button onClick={() => setForm(null)} className="px-5 py-2 rounded-lg border border-slate-200 text-sm font-medium">Cancel</button>
<button onClick={save} className="px-6 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold">Save</button>
</div>
</div>
</div>
)}
</div>
);
}
function Inp({ label, v, on, type = "text", full }: { label: string; v: any; on: (x: string) => void; type?: string; full?: boolean }) {
return (
<label className={`block ${full ? "col-span-2" : ""}`}>
<span className="text-sm font-medium">{label}</span>
<input type={type} value={v ?? ""} onChange={(e) => on(e.target.value)} className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm" />
</label>
);
}
function LimitInp({ label, v, on }: { label: string; v: number | null; on: (x: number | null) => void }) {
return (
<label className="block">
<span className="text-sm font-medium">{label}</span>
<div className="mt-1 flex gap-2 items-center">
<input type="number" value={v ?? ""} disabled={v === null} onChange={(e) => on(Number(e.target.value))}
className="flex-1 px-3 py-2 rounded-lg border border-slate-200 text-sm disabled:bg-slate-50" />
<label className="flex items-center gap-1 text-xs text-slate-500">
<input type="checkbox" checked={v === null} onChange={(e) => on(e.target.checked ? null : 100)} />
</label>
</div>
</label>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
export default function Retention() {
const [d, setD] = useState<any>(null);
useEffect(() => { api("/admin/subscription-lifecycle").then(setD).catch(() => {}); }, []);
return (
<div className="space-y-5">
<h2 className="text-xl font-bold">Subscription & Retention</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Kpi label="Active" value={d?.active ?? 0} tint="bg-emerald-50 text-emerald-700" />
<Kpi label="Grace Period" value={d?.grace ?? 0} tint="bg-amber-50 text-amber-700" />
<Kpi label="Archived" value={d?.archived ?? 0} tint="bg-slate-100 text-slate-600" />
<Kpi label="Deleted" value={d?.deleted ?? 0} tint="bg-rose-50 text-rose-700" />
</div>
<div className="card p-5">
<h3 className="font-bold mb-3">Upcoming Expiries (next 30 days)</h3>
<div className="space-y-2 text-sm">
{(d?.upcoming_expiries ?? []).map((u: any, i: number) => (
<div key={i} className="flex justify-between border-b border-slate-100 py-2">
<Link href={`/admin/organizations/${u.org_id}`} className="text-brand-700 hover:underline">{u.org}</Link>
<span className="text-slate-500">{new Date(u.renewal).toLocaleDateString()} · {u.days}d left</span>
</div>
))}
{(!d?.upcoming_expiries || d.upcoming_expiries.length === 0) && (
<p className="text-slate-400">No upcoming expiries.</p>
)}
</div>
</div>
</div>
);
}
function Kpi({ label, value, tint }: any) {
return (
<div className="card p-5">
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${tint}`}>{label}</span>
<div className="text-3xl font-extrabold mt-2">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useEffect, useState } from "react";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import { IndianRupee, TrendingUp, FileText, Download } from "lucide-react";
import { api, apiBase } from "@/lib/api";
import { inr } from "@/lib/utils";
const DONUT = ["#2563eb", "#10b981", "#f59e0b", "#8b5cf6", "#94a3b8"];
const STATUS: Record<string, string> = {
paid: "bg-emerald-50 text-emerald-700", pending: "bg-amber-50 text-amber-700",
failed: "bg-rose-50 text-rose-700", refunded: "bg-slate-100 text-slate-600",
};
export default function Revenue() {
const [d, setD] = useState<any>(null);
useEffect(() => { api("/admin/revenue").then(setD).catch(() => {}); }, []);
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Revenue & Finance</h2>
<a href={`${apiBase}/admin/revenue/export`} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Download className="h-4 w-4" /> CSV</a>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Kpi label="MRR" value={inr(d?.mrr ?? 0)} icon={<IndianRupee className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<Kpi label="ARR" value={inr(d?.arr ?? 0)} icon={<TrendingUp className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="Total Revenue" value={inr(d?.total_revenue ?? 0)} icon={<IndianRupee className="h-5 w-5" />} tint="bg-violet-50 text-violet-600" />
<Kpi label="Invoices" value={`${d?.paid_count ?? 0} / ${d?.invoices_count ?? 0}`} icon={<FileText className="h-5 w-5" />} tint="bg-amber-50 text-amber-500" />
</div>
<div className="grid lg:grid-cols-3 gap-4">
<div className="card p-5">
<h3 className="font-bold mb-2">Revenue by Plan</h3>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={d?.plan_breakdown ?? []} dataKey="revenue" nameKey="plan" innerRadius={50} outerRadius={80} paddingAngle={2}>
{(d?.plan_breakdown ?? []).map((_: any, i: number) => <Cell key={i} fill={DONUT[i % DONUT.length]} />)}
</Pie>
<Tooltip formatter={(v: any) => inr(v)} />
</PieChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-2 justify-center mt-2 text-xs">
{(d?.plan_breakdown ?? []).map((p: any, i: number) => (
<span key={p.plan} className="flex items-center gap-1"><span className="h-2 w-2 rounded-full" style={{ background: DONUT[i % DONUT.length] }} />{p.plan} · {inr(p.revenue)}</span>
))}
</div>
</div>
<div className="card overflow-x-auto lg:col-span-2">
<h3 className="font-bold p-5 pb-3">All Invoices</h3>
<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">Organization</th>
<th className="px-4 py-3 font-medium">Plan</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 font-medium">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(d?.invoices ?? []).map((i: any) => (
<tr key={i.id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-mono text-slate-600">{i.invoice_number}</td>
<td className="px-4 py-3">{i.org}</td>
<td className="px-4 py-3">{i.plan}</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 px-2 py-0.5 rounded-full capitalize ${STATUS[i.status] ?? "bg-slate-100"}`}>{i.status}</span></td>
<td className="px-4 py-3 text-slate-500">{new Date(i.at).toLocaleDateString()}</td>
</tr>
))}
{(!d?.invoices || d.invoices.length === 0) && <tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">No invoices yet.</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
);
}
function Kpi({ label, value, icon, tint }: any) {
return (
<div className="card p-5">
<div className="flex items-start gap-4">
<div className={`h-12 w-12 rounded-xl grid place-items-center shrink-0 ${tint}`}>{icon}</div>
<div><div className="text-sm text-slate-500">{label}</div><div className="text-2xl font-extrabold text-slate-900 mt-0.5">{value}</div></div>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { Search, Plus, Lock } from "lucide-react";
import { api } from "@/lib/api";
export default function Support() {
const [orgs, setOrgs] = useState<any[]>([]);
const [q, setQ] = useState("");
const [selected, setSelected] = useState<string>("");
const [view, setView] = useState<any>(null);
const [notes, setNotes] = useState<any[]>([]);
const [note, setNote] = useState("");
useEffect(() => { api("/admin/organizations").then(setOrgs).catch(() => {}); }, []);
async function open(orgId: string) {
setSelected(orgId);
api(`/admin/organizations/${orgId}/support-view`).then(setView).catch(() => {});
api(`/admin/organizations/${orgId}/notes`).then(setNotes).catch(() => setNotes([]));
}
async function addNote() {
if (!note.trim()) return;
await api(`/admin/organizations/${selected}/notes`, { method: "POST", body: JSON.stringify({ note }) });
setNote("");
api(`/admin/organizations/${selected}/notes`).then(setNotes);
}
const filtered = orgs.filter((o) => o.name.toLowerCase().includes(q.toLowerCase()));
return (
<div className="space-y-5">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">Support View</h2>
<span className="text-xs font-semibold bg-slate-100 text-slate-500 px-2 py-1 rounded-full flex items-center gap-1">
<Lock className="h-3 w-3" /> Read-only
</span>
</div>
<div className="grid md:grid-cols-[260px_1fr] gap-5">
{/* Org list */}
<div className="card p-4 h-fit">
<div className="relative mb-3">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search org…"
className="pl-9 pr-3 py-2 w-full rounded-lg border border-slate-200 text-sm" />
</div>
<div className="space-y-1 max-h-[60vh] overflow-y-auto">
{filtered.map((o) => (
<button key={o.org_id} onClick={() => open(o.org_id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm ${selected === o.org_id ? "bg-brand-50 text-brand-700" : "hover:bg-slate-50"}`}>
{o.name}
</button>
))}
</div>
</div>
{/* Detail */}
<div className="space-y-4">
{!view ? (
<div className="card p-12 text-center text-slate-400">Select an organization to debug.</div>
) : (
<>
<div className="card p-5">
<h3 className="font-bold">{view.org.name}</h3>
<p className="text-sm text-slate-500 capitalize">{view.org.status} · {view.org.contact_email}</p>
</div>
<div className="card overflow-x-auto">
<h3 className="font-bold p-4 pb-2">Products ({view.products.length})</h3>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-left">
<tr><th className="px-4 py-2">Name</th><th className="px-4 py-2">SKU</th><th className="px-4 py-2">Status</th><th className="px-4 py-2">QRs</th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{view.products.map((p: any, i: number) => (
<tr key={i}><td className="px-4 py-2">{p.name}</td><td className="px-4 py-2">{p.sku}</td><td className="px-4 py-2 capitalize">{p.status}</td><td className="px-4 py-2">{p.qr_count}</td></tr>
))}
</tbody>
</table>
</div>
<div className="card overflow-x-auto">
<h3 className="font-bold p-4 pb-2">Recent Scans</h3>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-left">
<tr><th className="px-4 py-2">Time</th><th className="px-4 py-2">Code</th><th className="px-4 py-2">City</th><th className="px-4 py-2">Device</th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{view.recent_scans.map((s: any, i: number) => (
<tr key={i}><td className="px-4 py-2 text-slate-500">{new Date(s.at).toLocaleString()}</td><td className="px-4 py-2 font-mono">{s.code}</td><td className="px-4 py-2">{s.city ?? "—"}</td><td className="px-4 py-2">{s.device ?? "—"}</td></tr>
))}
{view.recent_scans.length === 0 && <tr><td colSpan={4} className="px-4 py-6 text-center text-slate-400">No scans.</td></tr>}
</tbody>
</table>
</div>
<div className="card p-5">
<h3 className="font-bold mb-3">Internal Notes</h3>
<div className="flex gap-2 mb-3">
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Add a debugging note…"
className="flex-1 px-3 py-2 rounded-lg border border-slate-200 text-sm" />
<button onClick={addNote} className="flex items-center gap-1 bg-brand-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"><Plus className="h-4 w-4" /> Add</button>
</div>
<div className="space-y-2">
{notes.map((n) => (
<div key={n.id} className="text-sm border-b border-slate-100 py-2">
<div>{n.note}</div>
<div className="text-xs text-slate-400">{n.author} · {new Date(n.at).toLocaleString()}</div>
</div>
))}
{notes.length === 0 && <p className="text-sm text-slate-400">No notes.</p>}
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { Download } from "lucide-react";
import { api, apiBase } from "@/lib/api";
export default function Utilization() {
const [orgs, setOrgs] = useState<any[]>([]);
useEffect(() => { api("/admin/organizations").then(setOrgs).catch(() => {}); }, []);
const totals = orgs.reduce((a, o) => ({
products: a.products + o.products_used,
qrs: a.qrs + o.qr_codes,
scans: a.scans + o.total_scans,
revenue: a.revenue + (o.plan_price || 0),
}), { products: 0, qrs: 0, scans: 0, revenue: 0 });
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Utilization Report</h2>
<div className="flex gap-2">
<a href={`${apiBase}/admin/utilization-report?format=csv`} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Download className="h-4 w-4" /> CSV</a>
<a href={`${apiBase}/admin/utilization-report?format=pdf`} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Download className="h-4 w-4" /> PDF</a>
</div>
</div>
<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">Organization</th>
<th className="px-4 py-3 font-medium">Plan</th>
<th className="px-4 py-3 font-medium">Products</th>
<th className="px-4 py-3 font-medium">Users</th>
<th className="px-4 py-3 font-medium">Storage</th>
<th className="px-4 py-3 font-medium">QRs</th>
<th className="px-4 py-3 font-medium">Scans</th>
<th className="px-4 py-3 font-medium">Revenue</th>
<th className="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{orgs.map((o) => (
<tr key={o.org_id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-medium">{o.name}</td>
<td className="px-4 py-3">{o.plan ?? "—"}</td>
<td className="px-4 py-3">{o.products_used} / {o.product_limit ?? "∞"}</td>
<td className="px-4 py-3">{o.users_used} / {o.user_limit ?? "∞"}</td>
<td className="px-4 py-3">{o.storage_used_gb} GB</td>
<td className="px-4 py-3">{o.qr_codes}</td>
<td className="px-4 py-3">{o.total_scans}</td>
<td className="px-4 py-3">{o.plan_price}</td>
<td className="px-4 py-3 capitalize">{o.subscription_status}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-slate-50 font-semibold">
<td className="px-4 py-3" colSpan={2}>Totals ({orgs.length} orgs)</td>
<td className="px-4 py-3">{totals.products}</td>
<td className="px-4 py-3"></td>
<td className="px-4 py-3"></td>
<td className="px-4 py-3">{totals.qrs}</td>
<td className="px-4 py-3">{totals.scans}</td>
<td className="px-4 py-3">{totals.revenue}</td>
<td className="px-4 py-3"></td>
</tr>
</tfoot>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import { Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { setToken } from "@/lib/api";
function Inner() {
const sp = useSearchParams();
const r = useRouter();
useEffect(() => {
const t = sp.get("token");
if (t) { setToken(t); r.push("/dashboard"); }
else r.push("/login");
}, [sp, r]);
return <div className="min-h-screen grid place-items-center text-slate-500">Signing you in</div>;
}
export default function Callback() {
return <Suspense><Inner /></Suspense>;
}

View File

@@ -0,0 +1,473 @@
"use client";
import { Fragment, useEffect, useState } from "react";
import {
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
} from "recharts";
import { Download, ScanLine, Users, Globe, ShieldCheck } from "lucide-react";
import ScanTrendChart from "@/components/ScanTrendChart";
import { api, apiBase } from "@/lib/api";
import GeographicOverview from "@/components/GeographicOverview";
const PRESETS = [
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
];
const DONUT = ["#2563eb", "#10b981", "#f59e0b", "#94a3b8"];
const DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export default function Analytics() {
const [days, setDays] = useState(30);
const [s, setS] = useState<any>(null);
const [trend, setTrend] = useState<any[]>([]);
const [topP, setTopP] = useState<any[]>([]);
const [cities, setCities] = useState<any[]>([]);
const [devices, setDevices] = useState<any[]>([]);
const [heat, setHeat] = useState<any[]>([]);
const [recent, setRecent] = useState<any[]>([]);
const [geo, setGeo] = useState<any[]>([]);
const totalDevices = devices.reduce((sum, d) => sum + d.scans, 0);
const [brands, setBrands] = useState([]);
const [brandId, setBrandId] = useState("");
const [recentTotal, setRecentTotal] = useState(0);
const DONUT = [
"#22c55e", // Android - Green
"#2563eb", // iOS - Blue
"#7c3aed", // Desktop - Purple
"#f59e0b", // Other - Orange
];
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const params = new URLSearchParams();
if (fromDate && toDate) {
params.append("from_date", fromDate);
params.append("to_date", toDate);
} else {
params.append("days", String(days));
}
if (brandId) {
params.append("brand_id", brandId);
}
const query = params.toString();
const [recentPage, setRecentPage] = useState(1);
const [recentTotalPages, setRecentTotalPages] = useState(1);
const recentPageSize = 20;
const clearFilters = () => {
setBrandId("");
setFromDate("");
setToDate("");
setDays(30);
setRecentPage(1); // default preset
};
const [showAllProducts, setShowAllProducts] = useState(false);
useEffect(() => {
api("/analytics/brands").then(setBrands);
}, []);
useEffect(() => {
api(`/analytics/summary?${query}`).then(setS).catch(() => {});
api(`/analytics/trend?${query}`).then(setTrend).catch(() => {});
api(`/analytics/top-products?${query}`).then(setTopP).catch(() => {});
api(`/analytics/top-cities?${query}`).then(setCities).catch(() => {});
api(`/analytics/devices?${query}`).then(setDevices).catch(() => {});
api(`/analytics/geography?${query}`).then(setGeo).catch(() => {});
api(`/analytics/time-distribution?${query}`).then(setHeat).catch(() => {});
api(
`/analytics/recent-scans?${query}&page=${recentPage}&page_size=${recentPageSize}`
)
.then((res) => {
setRecent(res.items);
setRecentTotalPages(res.pages);
setRecentTotal(res.total);
})
.catch(() => {});
}, [days, fromDate, toDate,brandId, recentPage]);
const maxP = Math.max(1, ...topP.map((p) => p.scans));
const maxHeat = Math.max(1, ...heat.map((h) => h.count));
const startPage = Math.max(1, recentPage - 2);
const endPage = Math.min(recentTotalPages, startPage + 4);
const pages = [];
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
const downloadCsv = async () => {
const token = localStorage.getItem("vp_token"); // or whatever key you use
const res = await fetch(`${apiBase}/analytics/export?${query}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
alert("Failed to download CSV");
return;
}
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "scans.csv";
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
};
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Analytics & Insights</h2>
<div className="flex items-center gap-3">
<div className="flex rounded-lg border border-slate-200 overflow-hidden">
{PRESETS.map((p) => (
<button
key={p.days}
onClick={() => {
setDays(p.days);
setFromDate("");
setToDate("");
}}
className={`px-3 py-1.5 text-sm font-medium ${
days === p.days
? "bg-brand-600 text-white"
: "bg-white text-slate-600"
}`}
>
{p.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
/>
<span>to</span>
<input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
/>
</div>
<select
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm"
>
<option value="">All Brands</option>
{brands.map((b:any) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select><button
onClick={clearFilters}
className="border border-slate-200 px-3 py-2 rounded-lg text-sm font-medium hover:bg-slate-50"
>
Clear Filters
</button>
<button
onClick={downloadCsv}
className="flex items-center gap-2 border border-slate-200 px-3 py-2 rounded-lg text-sm font-medium"
>
<Download className="h-4 w-4" />
CSV
</button>
</div>
</div>
{/* KPIs */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
<Kpi label="Total Scans" value={s?.total_scans ?? 0} icon={<ScanLine className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="Unique Consumers" value={s?.unique_consumers ?? 0} icon={<Users className="h-5 w-5" />} tint="bg-violet-50 text-violet-600" />
<Kpi label="Today" value={s?.today_scans ?? 0} icon={<ScanLine className="h-5 w-5" />} tint="bg-orange-50 text-orange-500" />
<Kpi label="Active Products" value={s?.active_products ?? 0} icon={<ShieldCheck className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<Kpi label="Avg Trust" value={`${s?.avg_trust_score ?? 0}%`} icon={<ShieldCheck className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="Countries" value={s?.countries ?? 0} icon={<Globe className="h-5 w-5" />} tint="bg-amber-50 text-amber-500" />
</div>
<div className="grid lg:grid-cols-3 gap-4">
<div className="card p-5 lg:col-span-1">
<h3 className="font-bold mb-2">Scan Trend</h3>
<ScanTrendChart data={trend} />
</div>
<div className="card p-5">
<h3 className="font-bold mb-5">Device Types</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-center">
{/* Donut Chart */}
<div className="flex justify-center">
<ResponsiveContainer width={200} height={200}>
<PieChart>
<Pie
data={devices}
dataKey="scans"
nameKey="device"
innerRadius={55}
outerRadius={80}
stroke="#fff"
strokeWidth={2}
>
{devices.map((_, i) => (
<Cell
key={i}
fill={DONUT[i % DONUT.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: any) => [
`${value} scans`,
"Scans",
]}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="space-y-4">
{devices.map((d, i) => {
const percent =
totalDevices === 0
? 0
: ((d.scans / totalDevices) * 100).toFixed(1);
return (
<div
key={d.device}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<span
className="h-2 w-1 rounded-full"
style={{
backgroundColor: DONUT[i % DONUT.length],
}}
/>
<span className="font-medium text-slate-700">
{d.device}
</span>
</div>
<span className="text-sm font-semibold text-slate-600">
{percent}% ({d.scans.toLocaleString()})
</span>
</div>
);
})}
</div>
</div>
</div>
<div className="card p-5">
<h3 className="font-bold mb-4">Scan Time Distribution</h3>
<div className="overflow-x-auto">
<div className="inline-grid gap-0.5" style={{ gridTemplateColumns: "28px repeat(24, 10px)" }}>
<div />
{Array.from({ length: 24 }).map((_, h) => (
<div key={h} className="text-[8px] text-slate-400 text-center">{h % 3 === 0 ? h : ""}</div>
))}
{DOW.map((day, d) => (
<Fragment key={`row-${d}`}>
<div className="text-[10px] text-slate-500 pr-1 flex items-center">{day}</div>
{Array.from({ length: 24 }).map((_, h) => {
const cell = heat.find((x) => x.dow === d && x.hour === h);
const v = cell?.count ?? 0;
const op = v === 0 ? 0.06 : 0.2 + (v / maxHeat) * 0.8;
return <div key={`${d}-${h}`} title={`${day} ${h}:00 — ${v}`}
className="h-[12px] w-[12px] rounded-[2px]" style={{ background: `rgba(37,99,235,${op})` }} />;
})}
</Fragment>
))}
</div>
</div>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-4">
<div className="card p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold">Top Products</h3>
{topP.length > 5 && (
<button
onClick={() => setShowAllProducts(!showAllProducts)}
className="text-sm font-medium text-blue-600 hover:underline"
>
{showAllProducts ? "Show Less" : "View All"}
</button>
)}
</div>
<div className="space-y-3">
{(showAllProducts ? topP : topP.slice(0, 5)).map((p) => (
<div key={p.name} className="flex items-center gap-3">
<div className="w-36 truncate text-sm text-slate-600">
{p.name}
</div>
<div className="flex-1 h-3 rounded-full bg-slate-100 overflow-hidden">
<div
className="h-full rounded-full bg-blue-500"
style={{
width: `${(p.scans / maxP) * 100}%`,
}}
/>
</div>
<div className="w-12 text-right text-sm font-semibold">
{p.scans}
</div>
</div>
))}
{!showAllProducts && topP.length > 5 && (
<button
onClick={() => setShowAllProducts(true)}
className="text-sm font-medium text-blue-600 hover:underline"
>
+{topP.length - 5} more products
</button>
)}
{topP.length === 0 && (
<p className="text-sm text-slate-400">No data.</p>
)}
</div>
</div>
<div className="card p-5">
<h3 className="font-bold mb-4">Top Cities</h3>
<ol className="space-y-2 text-sm">
{cities.map((c, i) => (
<li key={c.city} className="flex justify-between">
<span className="text-slate-600">{i + 1}. {c.city}</span>
<span className="font-semibold">{c.scans}</span>
</li>
))}
{cities.length === 0 && <li className="text-slate-400">No data.</li>}
</ol>
</div>
<div className="card p-5">
<h3 className="font-bold mb-4">
Geographic Overview
</h3>
<GeographicOverview data={geo} />
</div>
</div>
{/* Heatmap */}
{/* Recent scans */}
<div className="card overflow-x-auto">
<h3 className="font-bold p-5 pb-3">Recent Scans</h3>
<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">Time</th>
<th className="px-4 py-3 font-medium">Code</th>
<th className="px-4 py-3 font-medium">City</th>
<th className="px-4 py-3 font-medium">Country</th>
<th className="px-4 py-3 font-medium">Device</th>
<th className="px-4 py-3 font-medium">IP Address</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{recent.map((r, i) => (
<tr key={i}>
<td className="px-4 py-3 text-slate-500">{new Date(r.at).toLocaleString()}</td>
<td className="px-4 py-3 font-mono text-slate-600">{r.code}</td>
<td className="px-4 py-3">{r.city ?? "—"}</td>
<td className="px-4 py-3">{r.country ?? "—"}</td>
<td className="px-4 py-3">{r.device ?? "—"}</td>
<td className="px-4 py-3">{r.ip ?? "—"}</td>
</tr>
))}
{recent.length === 0 && <tr><td colSpan={5} className="px-4 py-10 text-center text-slate-400">No scans yet.</td></tr>}
</tbody>
</table>
<div className="flex items-center justify-between px-4 py-3 text-sm text-slate-500 border-t">
<span>
Showing{" "}
{recent.length === 0
? 0
: (recentPage - 1) * recentPageSize + 1}{" "}
to{" "}
{Math.min(recentPage * recentPageSize, recentTotal)}{" "}
of {recentTotal}
</span>
<div className="flex gap-1">
<button
disabled={recentPage === 1}
onClick={() => setRecentPage(recentPage - 1)}
className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40"
>
</button>
{pages.map((p) => (
<button
key={p}
onClick={() => setRecentPage(p)}
className={`px-3 py-1 rounded ${
recentPage === p
? "bg-brand-600 text-white"
: "border border-slate-200"
}`}
>
{p}
</button>
))}
<button
disabled={recentPage === recentTotalPages}
onClick={() => setRecentPage(recentPage + 1)}
className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40"
>
</button>
</div>
</div>
</div>
</div>
);
}
function Kpi({ label, value, icon, tint }: any) {
return (
<div className="card p-4">
<div className="flex items-center gap-3">
<div className={`h-11 w-11 rounded-xl grid place-items-center shrink-0 ${tint}`}>{icon}</div>
<div className="min-w-0">
<div className="text-xl font-extrabold text-slate-900">{value}</div>
<div className="text-xs text-slate-500">{label}</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
"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>
);
}

View File

@@ -0,0 +1,888 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Package, CheckCircle2, Clock, AlertTriangle, Calendar, Search,
Plus, Download, Eye, Pencil, Upload, MoreVertical, X,Trash2,
} from "lucide-react";
import { api, apiBase } from "@/lib/api";
import ComplianceDetailModal from "@/components/ComplianceDetailModal";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
function imgSrc(url?: string) {
if (!url) return "";
if (url.startsWith("data:") || url.startsWith("http")) return url;
return `${apiBase}${url}`;
}
const TYPES = ["FSSAI License", "Drug License", "GMP Certificate", "ISO Certificate",
"EPR Registration", "Factory License", "Import Export Code", "Trademark Registration"];
const STATUS: Record<string, string> = {
complete: "bg-emerald-50 text-emerald-700",
pending: "bg-amber-50 text-amber-700",
expiring: "bg-amber-50 text-amber-700",
expired: "bg-rose-50 text-rose-700",
};
export default function Compliance() {
const [rows, setRows] = useState<any[]>([]);
const [s, setS] = useState<any>(null);
const [products, setProducts] = useState<any[]>([]);
const [filters, setFilters] = useState({ search: "", status_filter: "", compliance_type: "", country: "" });
const [detailId, setDetailId] = useState<string | null>(null);
const [menuFor, setMenuFor] = useState<string | null>(null);
const [form, setForm] = useState<any>(null); // add/edit form state (null = closed)
const [page, setPage] = useState(1);
const pageSize = 25;
const [showAlerts, setShowAlerts] = useState(true);
const [deleteRow, setDeleteRow] = useState<any>(null);
const [deleting, setDeleting] = useState(false);
const [selectedRows, setSelectedRows] = useState<string[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
function load() {
const qs = new URLSearchParams(
Object.entries(filters).filter(([, v]) => v) as any
).toString();
api(`/compliance${qs ? "?" + qs : ""}`)
.then(setRows)
.catch(() => setRows([]));
api("/compliance/summary")
.then((data) => {
setS(data);
setShowAlerts(true);
})
.catch(() => {});
}
useEffect(() => { load(); }, [filters]);
useEffect(() => { api("/products").then((d) => setProducts(d.items ?? [])).catch(() => {}); }, []);
const paged = useMemo(() => rows.slice((page - 1) * pageSize, page * pageSize), [rows, page]);
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const set = (k: string, v: string) => { setPage(1); setFilters((f) => ({ ...f, [k]: v })); };
function handleExport() {
const exportRows =
selectedRows.length > 0
? rows.filter((r) => selectedRows.includes(r.id))
: rows;
const headers = [
"Product",
"SKU",
"Compliance Type",
"License Number",
"License Name",
"Issuing Authority",
"Issue Date",
"Expiry Date",
"Days Left",
"Status",
"Country",
"State",
"Category",
"Applicable To",
"Manufacturing Plant",
"Remarks",
"Created By",
"Created On",
"Last Updated",
"Document URL",
];
const csv = [
headers.join(","),
...exportRows.map((r) =>
[
`"${r.product_name ?? ""}"`,
`"${r.sku ?? ""}"`,
`"${r.compliance_type ?? ""}"`,
`"${r.license_number ?? ""}"`,
`"${r.license_name ?? ""}"`,
`"${r.issuing_authority ?? ""}"`,
`"${r.issue_date ?? ""}"`,
`"${r.expiry_date ?? ""}"`,
`"${r.days_left == null
? "-"
: r.days_left < 0
? "Expired"
: r.days_left === 0
? "Today"
: `${r.days_left} Days`
}"`,
`"${r.status ?? ""}"`,
`"${r.country ?? ""}"`,
`"${r.state ?? ""}"`,
`"${r.category ?? ""}"`,
`"${r.applicable_to ?? ""}"`,
`"${r.plant ?? ""}"`,
`"${r.remarks ?? ""}"`,
`"${r.created_by ?? ""}"`,
`"${r.created_at ? new Date(r.created_at).toLocaleString("en-IN") : ""}"`,
`"${r.updated_at ? new Date(r.updated_at).toLocaleString("en-IN") : ""}"`,
`"${r.document_url ?? ""}"`,
].join(",")
),
].join("\n");
const blob = new Blob([csv], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download =
selectedRows.length > 0
? `selected_compliance_${selectedRows.length}.csv`
: `compliance_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function handleImport(
e: React.ChangeEvent<HTMLInputElement>
) {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
try {
await fetch(`${apiBase}/compliance/import`, {
method: "POST",
body: formData,
headers: {
Authorization: `Bearer ${localStorage.getItem("vp_token")}`,
},
});
load();
alert("Import successful");
} catch {
alert("Import failed");
}
e.target.value = "";
}
async function handlePdfExport() {
const exportRows =
selectedRows.length > 0
? rows.filter((r) => selectedRows.includes(r.id))
: rows;
const doc = new jsPDF({
orientation: "landscape",
unit: "mm",
format: "a4",
});
// ===========================
// Logo
// ===========================
try {
const img = new Image();
img.src = "/logo.png";
await new Promise((resolve) => {
img.onload = resolve;
img.onerror = resolve;
});
doc.addImage(img, "PNG", 14, 10, 18, 18);
} catch {}
// ===========================
// Company Header
// ===========================
doc.setFont("helvetica", "bold");
doc.setFontSize(18);
doc.text("VerifyPack", 38, 18);
doc.setFont("helvetica", "normal");
doc.setFontSize(10);
doc.setTextColor(100);
doc.text("Digital Trust for Every Product", 38, 24);
// ===========================
// Report Title
// ===========================
doc.setDrawColor(220);
doc.line(14, 32, 283, 32);
doc.setTextColor(0);
doc.setFont("helvetica", "bold");
doc.setFontSize(15);
doc.text("Compliance Report", 14, 40);
doc.setFont("helvetica", "normal");
doc.setFontSize(10);
doc.text(
`Generated On : ${new Date().toLocaleString("en-IN")}`,
14,
47
);
doc.text(
`Total Records : ${exportRows.length}`,
250,
47,
{ align: "right" }
);
// ===========================
// Table
// ===========================
autoTable(doc, {
startY: 55,
head: [[
"Product",
"SKU",
"Compliance Type",
"License No",
"Issue Date",
"Expiry Date",
"Days Left",
"Status",
"Country",
"Authority",
]],
body: exportRows.map((r) => [
r.product_name || "",
r.sku || "",
r.compliance_type || "",
r.license_number || "",
r.issue_date
? new Date(r.issue_date).toLocaleDateString("en-IN")
: "-",
r.expiry_date
? new Date(r.expiry_date).toLocaleDateString("en-IN")
: "-",
r.days_left == null
? "-"
: r.days_left < 0
? "Expired"
: r.days_left === 0
? "Today"
: `${r.days_left} Days`,
r.days_left == null
? "Pending"
: r.days_left < 0
? "Expired"
: r.days_left <= 30
? "Expiring"
: "Complete",
r.country || "-",
r.issuing_authority || "-",
]),
styles: {
fontSize: 8,
cellPadding: 2,
},
headStyles: {
fillColor: [37, 99, 235],
textColor: 255,
fontStyle: "bold",
},
alternateRowStyles: {
fillColor: [245, 245, 245],
},
margin: {
left: 14,
right: 14,
},
didDrawPage: (data) => {
doc.setFontSize(9);
doc.setTextColor(120);
doc.text(
`Page ${data.pageNumber}`,
doc.internal.pageSize.getWidth() - 20,
doc.internal.pageSize.getHeight() - 8,
{
align: "right",
}
);
doc.text(
"Generated by VerifyPack",
14,
doc.internal.pageSize.getHeight() - 8
);
},
});
// ===========================
// Save PDF
// ===========================
doc.save(
selectedRows.length
? `selected_compliance_report.pdf`
: `compliance_report.pdf`
);
}
async function confirmDelete() {
if (!deleteRow) return;
try {
setDeleting(true);
await api(`/compliance/${deleteRow.id}`, {
method: "DELETE",
});
setDeleteRow(null);
load();
} catch (err) {
alert("Failed to delete compliance record.");
} finally {
setDeleting(false);
}
}
function toggleRow(id: string) {
setSelectedRows((prev) =>
prev.includes(id)
? prev.filter((x) => x !== id)
: [...prev, id]
);
}
const allSelected =
paged.length > 0 &&
paged.every((r) => selectedRows.includes(r.id));
function toggleAll() {
if (allSelected) {
setSelectedRows((prev) =>
prev.filter((id) => !paged.some((r) => r.id === id))
);
} else {
setSelectedRows((prev) => [
...new Set([...prev, ...paged.map((r) => r.id)]),
]);
}
}
async function deleteSelected() {
try {
setDeleting(true);
await Promise.all(
selectedRows.map((id) =>
api(`/compliance/${id}`, {
method: "DELETE",
})
)
);
setSelectedRows([]);
setBulkDeleteOpen(false);
load();
} finally {
setDeleting(false);
}
}
function openAdd() {
setForm({ product_id: "", compliance_type: TYPES[0], license_number: "", license_name: "",
issuing_authority: "", issue_date: "", expiry_date: "", country: "India", state: "",
category: "", applicable_to: "Manufacturing Plant", plant: "", remarks: "", _id: "" });
}
function openEdit(r: any) {
setDetailId(null);
setForm({ ...r, _id: r.id, product_id: r.product_id ?? "",
issue_date: r.issue_date ? r.issue_date.slice(0, 10) : "",
expiry_date: r.expiry_date ? r.expiry_date.slice(0, 10) : "" });
}
async function saveForm() {
const { _id, ...payload } = form;
if (_id) await api(`/compliance/${_id}`, { method: "PUT", body: JSON.stringify(payload) });
else await api("/compliance", { method: "POST", body: JSON.stringify(payload) });
setForm(null); load();
}
return (
<div className="space-y-5" onClick={() => setMenuFor(null)}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Compliance Center</h2>
</div>
{/* KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<Kpi label="Total Products" value={s?.total_products ?? 0} sub="All products" icon={<Package className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="Complete" value={s?.complete ?? 0} sub={`${s?.complete_pct ?? 0}% of total`} subColor="text-emerald-600" icon={<CheckCircle2 className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<Kpi label="Pending" value={s?.pending ?? 0} sub={`${s?.pending_pct ?? 0}% of total`} subColor="text-amber-600" icon={<Clock className="h-5 w-5" />} tint="bg-amber-50 text-amber-600" />
<Kpi label="Expired" value={s?.expired ?? 0} sub={`${s?.expired_pct ?? 0}% of total`} subColor="text-rose-600" icon={<AlertTriangle className="h-5 w-5" />} tint="bg-rose-50 text-rose-500" />
<Kpi label="Upcoming Expiry" value={s?.expiring ?? 0} sub="Next 30 days" icon={<Calendar className="h-5 w-5" />} tint="bg-violet-50 text-violet-600" />
</div>
{/* Filters */}
<div className="card p-4 flex flex-wrap items-end gap-3">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<input value={filters.search} onChange={(e) => set("search", e.target.value)} placeholder="Search compliance…"
className="pl-9 pr-3 py-2 w-52 rounded-lg border border-slate-200 text-sm" />
</div>
<Filter label="Status" value={filters.status_filter} onChange={(v) => set("status_filter", v)} opts={["complete", "pending", "expiring", "expired"].map((x) => ({ value: x, label: x }))} all="All Status" />
<Filter label="Type" value={filters.compliance_type} onChange={(v) => set("compliance_type", v)} opts={TYPES.map((t) => ({ value: t, label: t }))} all="All Types" />
<Filter label="Country" value={filters.country} onChange={(v) => set("country", v)} opts={[{ value: "India", label: "India" }]} all="All Countries" />
</div>
{/* Alerts */}
{/* Alerts */}
{showAlerts && s?.alerts?.length > 0 && (
<div className="card p-4 bg-amber-50 border border-amber-200 rounded-xl relative">
<button
onClick={() => setShowAlerts(false)}
className="absolute top-3 right-3 rounded-md p-1 text-amber-700 hover:bg-amber-100"
>
<X className="h-4 w-4" />
</button>
<div className="font-semibold text-amber-800 text-sm flex items-center gap-2 mb-2">
<AlertTriangle className="h-4 w-4" />
Attention needed
</div>
<div className="space-y-1">
{s.alerts.map((a: any, i: number) => (
<div key={i} className="text-sm text-amber-700">
{a.type}
{a.number ? ` (${a.number})` : ""}
{" — "}
{a.status}
{a.expiry
? ` · expires ${new Date(a.expiry).toLocaleDateString()}`
: ""}
</div>
))}
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="text-sm text-slate-500">
Total: {rows.length}
</div>
{/* Import */}
<label className="flex items-center gap-2 px-4 py-2 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 cursor-pointer text-sm font-medium">
<Upload className="h-4 w-4" />
Import
<input
type="file"
accept=".csv,.xlsx,.xls,.json"
className="hidden"
onChange={handleImport}
/>
</label>
{/* Export */}
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-sm font-medium"
>
<Download className="h-4 w-4" />
{selectedRows.length > 0
? `Export Selected (${selectedRows.length})`
: "Export"}
</button>
<button
onClick={handlePdfExport}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium hover:bg-slate-50"
>
<Download className="h-4 w-4" />
{selectedRows.length
? `PDF (${selectedRows.length})`
: "PDF Report"}
</button>
{/* Add */}
<button
onClick={openAdd}
className="flex items-center gap-2 bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-semibold"
>
<Plus className="h-4 w-4" />
Add Compliance
</button>
</div>
{selectedRows.length > 0 && (
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-brand-600">
{selectedRows.length} Selected
</span>
<button
onClick={() => setBulkDeleteOpen(true)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm text-white"
>
Delete Selected
</button>
</div>
)}
</div>
{/* Table */}
<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">
<input
type="checkbox"
checked={allSelected}
onChange={toggleAll}
/>
</th>
<th className="px-4 py-3 font-medium">Product</th>
<th className="px-4 py-3 font-medium">Type</th>
<th className="px-4 py-3 font-medium">License / Reg.</th>
<th className="px-4 py-3 font-medium">Issue Date</th>
<th className="px-4 py-3 font-medium">Expiry Date</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Country</th>
<th className="px-4 py-3 font-medium">Last Updated</th>
<th className="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{paged.map((c) => (
<tr key={c.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedRows.includes(c.id)}
onChange={() => toggleRow(c.id)}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{c.product_image
? <img src={imgSrc(c.product_image)} className="h-10 w-10 rounded object-contain border border-slate-100" />
: <div className="h-10 w-10 rounded bg-slate-100" />}
<div>
<div className="font-medium text-slate-800">{c.product_name ?? "—"}</div>
<div className="text-xs text-slate-400">{c.sku ?? ""}</div>
</div>
</div>
</td>
<td className="px-4 py-3">{c.compliance_type}</td>
<td className="px-4 py-3 font-mono text-slate-600">{c.license_number ?? "—"}</td>
<td className="px-4 py-3 text-slate-500">{c.issue_date ? new Date(c.issue_date).toLocaleDateString() : "—"}</td>
<td className="px-4 py-3">
{c.expiry_date ? (
<div>
<div className={c.days_left != null && c.days_left < 0 ? "text-rose-600" : c.days_left != null && c.days_left < 30 ? "text-amber-600" : "text-slate-700"}>
{new Date(c.expiry_date).toLocaleDateString()}
</div>
{c.days_left != null && <div className={`text-xs ${c.days_left < 0 ? "text-rose-500" : "text-slate-400"}`}>
{c.days_left < 0 ? "(Expired)" : `(In ${c.days_left} days)`}
</div>}
</div>
) : "—"}
</td>
<td className="px-4 py-3"><span className={`text-xs font-semibold px-2 py-0.5 rounded-full capitalize ${STATUS[c.status] ?? "bg-slate-100"}`}>{c.status}</span></td>
<td className="px-4 py-3">{c.country ?? "—"}</td>
<td className="px-4 py-3 text-slate-500">
{c.updated_at ? (
<div>
<div>
{new Date(c.updated_at).toLocaleDateString("en-IN")}
</div>
<div className="text-xs text-slate-400">
{new Date(c.updated_at).toLocaleTimeString("en-IN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</div>
</div>
) : (
"—"
)}
</td>
<td className="px-4 py-3 relative">
<div className="flex items-center gap-1">
<button title="View" onClick={() => setDetailId(c.id)} className="h-8 w-8 grid place-items-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50"><Eye className="h-4 w-4" /></button>
<button onClick={(e) => { e.stopPropagation(); setMenuFor(menuFor === c.id ? null : c.id); }} className="h-8 w-8 grid place-items-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50"><MoreVertical className="h-4 w-4" /></button>
</div>
{menuFor === c.id && (
<div onClick={(e) => e.stopPropagation()} className="absolute right-4 top-12 z-20 w-44 bg-white rounded-xl border border-slate-200 shadow-lg py-1 text-left">
<button onClick={() => { openEdit(c); setMenuFor(null); }} className="w-full flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"><Pencil className="h-4 w-4" /> Edit</button>
<button onClick={() => { setDetailId(c.id); setMenuFor(null); }} className="w-full flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"><Upload className="h-4 w-4" /> Upload Document</button>
<div className="my-1 border-t border-slate-100"></div>
<button
onClick={() => {
setDeleteRow(c);
setMenuFor(null);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</div>
)}
</td>
</tr>
))}
{paged.length === 0 && <tr><td colSpan={8} className="px-4 py-12 text-center text-slate-400">No compliance records.</td></tr>}
</tbody>
</table>
<div className="flex items-center justify-between px-4 py-3 text-sm text-slate-500">
<span>Showing {rows.length === 0 ? 0 : (page - 1) * pageSize + 1} to {Math.min(page * pageSize, rows.length)} of {rows.length}</span>
<div className="flex gap-1">
<button disabled={page === 1} onClick={() => setPage(page - 1)} className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40"></button>
{Array.from({ length: totalPages }).slice(0, 5).map((_, i) => (
<button key={i} onClick={() => setPage(i + 1)} className={`px-3 py-1 rounded ${page === i + 1 ? "bg-brand-600 text-white" : "border border-slate-200"}`}>{i + 1}</button>
))}
<button disabled={page === totalPages} onClick={() => setPage(page + 1)} className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40"></button>
</div>
</div>
</div>
{/* Detail modal */}
{detailId && (
<ComplianceDetailModal cid={detailId}
onClose={() => setDetailId(null)}
onEdit={() => { const r = rows.find((x) => x.id === detailId); if (r) openEdit(r); }}
onChanged={load} />
)}
{deleteRow && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => setDeleteRow(null)}
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-md rounded-2xl bg-white shadow-xl"
>
<div className="border-b px-6 py-4">
<h2 className="text-lg font-bold text-slate-900">
Delete Compliance
</h2>
</div>
<div className="p-6 space-y-4">
<div className="flex items-center gap-4">
{deleteRow.product_image ? (
<img
src={imgSrc(deleteRow.product_image)}
className="h-16 w-16 rounded-lg border object-contain"
/>
) : (
<div className="h-16 w-16 rounded-lg bg-slate-100" />
)}
<div>
<div className="font-semibold text-slate-900">
{deleteRow.product_name}
</div>
<div className="text-sm text-slate-500">
{deleteRow.compliance_type}
</div>
<div className="text-xs text-slate-400">
{deleteRow.license_number || "No License Number"}
</div>
</div>
</div>
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-700">
Are you sure you want to delete this compliance record?
</p>
<p className="mt-2 text-xs text-red-600">
This action cannot be undone.
</p>
</div>
</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">
<button
onClick={() => setDeleteRow(null)}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm"
>
Cancel
</button>
<button
onClick={confirmDelete}
disabled={deleting}
className="rounded-lg bg-red-600 px-5 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
>
{deleting ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
)}
{bulkDeleteOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
<div className="border-b px-6 py-4">
<h2 className="text-lg font-bold">
Delete Compliance Records
</h2>
</div>
<div className="p-6">
<p className="text-slate-700">
Are you sure you want to delete
<span className="font-bold text-red-600">
{" "}
{selectedRows.length} compliance record
{selectedRows.length > 1 ? "s" : ""}
</span>
?
</p>
<p className="mt-2 text-sm text-slate-500">
This action cannot be undone.
</p>
</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">
<button
onClick={() => setBulkDeleteOpen(false)}
className="rounded-lg border px-4 py-2"
>
Cancel
</button>
<button
onClick={deleteSelected}
disabled={deleting}
className="rounded-lg bg-red-600 px-5 py-2 text-white"
>
{deleting
? "Deleting..."
: `Delete (${selectedRows.length})`}
</button>
</div>
</div>
</div>
)}
{/* Add/Edit form modal */}
{form && (
<div className="fixed inset-0 bg-black/40 z-50 grid place-items-center p-4 overflow-y-auto" onClick={() => setForm(null)}>
<div className="bg-white rounded-2xl w-full max-w-lg my-4 max-h-[92vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100 sticky top-0 bg-white rounded-t-2xl">
<h3 className="font-bold">{form._id ? "Edit Compliance" : "Add Compliance"}</h3>
<button onClick={() => setForm(null)}><X className="h-5 w-5 text-slate-400" /></button>
</div>
<div className="p-5 grid grid-cols-2 gap-3">
<Fld label="Product" full>
<select value={form.product_id} onChange={(e) => setForm({ ...form, product_id: e.target.value })} className="input">
<option value="">Select product</option>
{products.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</Fld>
<Fld label="Compliance Type">
<select value={form.compliance_type} onChange={(e) => setForm({ ...form, compliance_type: e.target.value })} className="input">
{TYPES.map((t) => <option key={t}>{t}</option>)}
</select>
</Fld>
<Inp label="License / Reg. No." v={form.license_number} on={(x) => setForm({ ...form, license_number: x })} />
<Inp label="License Name" v={form.license_name} on={(x) => setForm({ ...form, license_name: x })} />
<Inp label="Issuing Authority" v={form.issuing_authority} on={(x) => setForm({ ...form, issuing_authority: x })} />
<Inp label="Issue Date" type="date" v={form.issue_date} on={(x) => setForm({ ...form, issue_date: x })} />
<Inp label="Expiry Date" type="date" v={form.expiry_date} on={(x) => setForm({ ...form, expiry_date: x })} />
<Inp label="Country" v={form.country} on={(x) => setForm({ ...form, country: x })} />
<Inp label="State" v={form.state} on={(x) => setForm({ ...form, state: x })} />
<Inp label="Category" v={form.category} on={(x) => setForm({ ...form, category: x })} />
<Inp label="Applicable To" v={form.applicable_to} on={(x) => setForm({ ...form, applicable_to: x })} />
<Inp label="Manufacturing Plant" v={form.plant} on={(x) => setForm({ ...form, plant: x })} />
<Inp label="Remarks" full v={form.remarks} on={(x) => setForm({ ...form, remarks: x })} />
</div>
<div className="flex justify-end gap-3 px-5 py-4 border-t border-slate-100 sticky bottom-0 bg-white">
<button onClick={() => setForm(null)} className="px-5 py-2 rounded-lg border border-slate-200 text-sm font-medium">Cancel</button>
<button onClick={saveForm} className="px-6 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold">Save</button>
</div>
</div>
</div>
)}
<style>{`.input{margin-top:.25rem;width:100%;padding:.5rem .75rem;border-radius:.5rem;border:1px solid #e2e8f0;font-size:.875rem}`}</style>
</div>
);
}
function Kpi({ label, value, sub, subColor = "text-slate-400", icon, tint }: any) {
return (
<div className="card p-5">
<div className="flex items-start gap-4">
<div className={`h-12 w-12 rounded-xl grid place-items-center shrink-0 ${tint}`}>{icon}</div>
<div className="min-w-0">
<div className="text-sm text-slate-500">{label}</div>
<div className="text-2xl font-extrabold text-slate-900 mt-0.5">{value}</div>
<div className={`text-xs font-medium mt-1 ${subColor}`}>{sub}</div>
</div>
</div>
</div>
);
}
function Filter({ label, value, onChange, opts, all }: {
label: string; value: string; onChange: (v: string) => void; opts: { value: string; label: string }[]; all: string;
}) {
return (
<label className="block">
<span className="text-xs text-slate-500">{label}</span>
<select value={value} onChange={(e) => onChange(e.target.value)} className="mt-1 block px-3 py-2 rounded-lg border border-slate-200 text-sm min-w-[130px] capitalize">
<option value="">{all}</option>
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
);
}
function Fld({ label, children, full }: any) {
return <label className={`block ${full ? "col-span-2" : ""}`}><span className="text-sm font-medium">{label}</span>{children}</label>;
}
function Inp({ label, v, on, type = "text", full }: { label: string; v: any; on: (x: string) => void; type?: string; full?: boolean }) {
return (
<label className={`block ${full ? "col-span-2" : ""}`}>
<span className="text-sm font-medium">{label}</span>
<input type={type} value={v ?? ""} onChange={(e) => on(e.target.value)} className="input" />
</label>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Bell, HelpCircle, Search, Menu } from "lucide-react";
import Sidebar from "@/components/Sidebar";
import { api, clearToken } from "@/lib/api";
import { useRef } from "react";
import { LogOut } from "lucide-react";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [me, setMe] = useState<any>(null);
const [org, setOrg] = useState<any>(null);
const [ready, setReady] = useState(false);
const [navOpen, setNavOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
function logout() {
clearToken();
router.replace("/login");
}
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
profileRef.current &&
!profileRef.current.contains(e.target as Node)
) {
setProfileOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () =>
document.removeEventListener("mousedown", handleClick);
}, []);
useEffect(() => {
api("/me")
.then((m) => {
setMe(m);
// Product Admins don't belong here — send them to the Super Admin Panel.
if (m.is_product_admin) {
router.replace("/admin");
return;
}
if (!m.has_org) {
router.replace("/onboarding");
return;
}
api("/organization").then(setOrg).catch(() => {});
setReady(true);
})
.catch((e) => {
clearToken();
router.replace("/login");
});
}, [router]);
if (!ready)
return <div className="min-h-screen grid place-items-center text-slate-400">Loading</div>;
const initial = (me?.name?.[0] ?? "U").toUpperCase();
return (
<div className="flex min-h-screen bg-slate-50">
<Sidebar org={org} open={navOpen} onClose={() => setNavOpen(false)} />
<div className="flex-1 min-w-0">
<header className="sticky top-0 z-10 bg-slate-50/80 backdrop-blur px-4 md:px-8 py-4 flex items-center gap-4">
<button onClick={() => setNavOpen(true)} className="md:hidden h-10 w-10 grid place-items-center rounded-full bg-white border border-slate-200">
<Menu className="h-4 w-4 text-slate-600" />
</button>
<div className="flex-1">
<h1 className="text-lg font-bold text-slate-900">
Welcome back, {me?.name ?? "there"}! 👋
</h1>
<p className="text-sm text-slate-500">
Here&apos;s what&apos;s happening with your products today.
</p>
</div>
<div className="relative hidden md:block">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<input placeholder="Search anything..."
className="pl-9 pr-4 py-2 w-64 rounded-full border border-slate-200 bg-white text-sm
focus:outline-none focus:ring-2 focus:ring-brand-500/30" />
</div>
<button className="relative h-10 w-10 grid place-items-center rounded-full bg-white border border-slate-200">
<Bell className="h-4 w-4 text-slate-600" />
</button>
<button className="h-10 w-10 grid place-items-center rounded-full bg-white border border-slate-200">
<HelpCircle className="h-4 w-4 text-slate-600" />
</button>
<div className="relative" ref={profileRef}>
<button
onClick={() => setProfileOpen(!profileOpen)}
className="flex items-center gap-2 pl-2 rounded-lg hover:bg-slate-100 px-2 py-1"
>
<div className="h-9 w-9 rounded-full bg-brand-500 text-white grid place-items-center font-semibold text-sm">
{initial}
</div>
<div className="text-sm leading-tight hidden sm:block text-left">
<div className="font-semibold text-slate-800">
{me?.name}
</div>
<div className="text-[11px] text-slate-400 capitalize">
{me?.client_role}
</div>
</div>
</button>
{profileOpen && (
<div className="absolute right-0 mt-2 w-72 bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden z-50">
<div className="px-4 py-4">
<div className="font-semibold text-slate-900">
{me?.name}
</div>
<div className="text-sm text-slate-500 mt-1">
{me?.email}
</div>
</div>
<div className="border-t" />
<button
onClick={logout}
className="w-full flex items-center gap-2 px-4 py-3 hover:bg-red-50 text-red-600 font-medium transition-colors"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
)}
</div>
</header>
<main className="px-4 md:px-8 pb-10">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
Package, QrCode, Boxes, ScanLine, ShieldCheck, CheckCircle2, FileText, Bell,
Plus, BarChart3, Layers, ArrowUpRight,
} from "lucide-react";
import { KpiCard, KpiSkeleton } from "@/components/KpiCard";
import ScanTrendChart from "@/components/ScanTrendChart";
import { api } from "@/lib/api";
const INDIA_DOTS = [
[62, 42], [55, 52], [48, 30], [58, 70], [60, 58], [44, 46],
];
export default function DashboardPage() {
const [s, setS] = useState<any>(null);
const [trend, setTrend] = useState<any[]>([]);
const [topP, setTopP] = useState<any[]>([]);
const [topL, setTopL] = useState<any[]>([]);
const [activity, setActivity] = useState<any[]>([]);
useEffect(() => {
api("/dashboard/summary").then(setS).catch(() => {});
api("/dashboard/scan-trend?days=7").then(setTrend).catch(() => {});
api("/dashboard/top-products").then(setTopP).catch(() => {});
api("/dashboard/top-locations").then(setTopL).catch(() => {});
api("/dashboard/recent-activity").then(setActivity).catch(() => {});
}, []);
const maxP = Math.max(1, ...topP.map((p) => p.scans));
return (
<div className="space-y-5">
{/* KPI row 1 */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{!s ? (
[...Array(4)].map((_, i) => <KpiSkeleton key={i} />)
) : (
<>
<KpiCard title="Products" value={s.products} sub={`Limit ${s.product_limit ?? "∞"}`}
icon={<Package className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<KpiCard title="Active QR Codes" value={s.active_qr} sub="100% Active"
icon={<QrCode className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<KpiCard title="Total Batches" value={s.batches} sub="across all products" subColor="text-slate-400"
icon={<Boxes className="h-5 w-5" />} tint="bg-violet-50 text-violet-600" />
<KpiCard title="Today's Scans" value={s.scans_today} sub="↑ 12.5% vs yesterday"
icon={<ScanLine className="h-5 w-5" />} tint="bg-orange-50 text-orange-500" />
</>
)}
</div>
{/* KPI row 2 */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{!s ? (
[...Array(4)].map((_, i) => <KpiSkeleton key={i} />)
) : (
<>
<KpiCard title="Trust Score" value={`${s.trust_score_avg}%`} sub="Excellent"
icon={<ShieldCheck className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<KpiCard title="Active QRs" value={s.active_qr} sub="Live & scannable"
subColor="text-slate-400" icon={<CheckCircle2 className="h-5 w-5" />}
tint="bg-emerald-50 text-emerald-600" />
<KpiCard title="Storage Used"
value={`${s.storage_used_gb} GB`} sub={`of ${s.storage_total_gb} GB`}
subColor="text-slate-400" icon={<FileText className="h-5 w-5" />}
tint="bg-amber-50 text-amber-500" />
<KpiCard title="Pending Tasks" value={5} sub="View All" subColor="text-blue-600"
icon={<Bell className="h-5 w-5" />} tint="bg-rose-50 text-rose-500" />
</>
)}
</div>
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="card p-5 lg:col-span-1">
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold text-slate-800">Scan Trend</h3>
<span className="text-xs text-slate-500 border border-slate-200 rounded-lg px-2 py-1">This Week</span>
</div>
<ScanTrendChart data={trend} />
</div>
<div className="card p-5">
<h3 className="font-bold text-slate-800 mb-4">Top Products (By Scans)</h3>
<div className="space-y-3">
{topP.map((p) => (
<div key={p.name} className="flex items-center gap-3">
<div className="w-28 text-sm text-slate-600 truncate">{p.name}</div>
<div className="flex-1 h-3 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full rounded-full bg-blue-400"
style={{ width: `${(p.scans / maxP) * 100}%` }} />
</div>
<div className="w-8 text-sm font-semibold text-slate-700 text-right">{p.scans}</div>
</div>
))}
{topP.length === 0 && <div className="text-sm text-slate-400">No scan data yet.</div>}
</div>
<Link href="/dashboard/products" className="text-blue-600 text-sm font-medium mt-4 inline-block">
View All Products
</Link>
</div>
<div className="card p-5">
<h3 className="font-bold text-slate-800 mb-4">Top Locations (By Scans)</h3>
<div className="flex gap-4">
<div className="relative w-24 h-32 rounded-lg bg-blue-50/70 shrink-0">
{INDIA_DOTS.map(([x, y], i) => (
<span key={i} className="absolute h-2 w-2 rounded-full bg-blue-500"
style={{ left: `${x}%`, top: `${y}%` }} />
))}
</div>
<ol className="flex-1 space-y-2 text-sm">
{topL.map((l, i) => (
<li key={l.city} className="flex justify-between">
<span className="text-slate-600">{i + 1}. {l.city}</span>
<span className="font-semibold text-slate-700">{l.scans}</span>
</li>
))}
{topL.length === 0 && <li className="text-slate-400">No locations yet.</li>}
</ol>
</div>
</div>
</div>
{/* Activity + Quick actions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-slate-800">Recent Activity</h3>
<Link href="/dashboard/analytics" className="text-blue-600 text-sm font-medium">View All</Link>
</div>
<div className="divide-y divide-slate-100">
{activity.map((a, i) => (
<div key={i} className="flex items-center gap-3 py-3">
<div className="h-8 w-8 rounded-lg bg-blue-50 text-blue-600 grid place-items-center">
<Layers className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-slate-800 capitalize">
{a.action?.replace(".", " ")}
</div>
<div className="text-xs text-slate-500 truncate">{a.details}</div>
</div>
<div className="text-right text-xs text-slate-400">
<div className="text-slate-600">{a.user}</div>
<div>{a.at ? new Date(a.at).toLocaleString() : ""}</div>
</div>
</div>
))}
{activity.length === 0 && <div className="text-sm text-slate-400 py-6">No recent activity.</div>}
</div>
</div>
<div className="card p-5">
<h3 className="font-bold text-slate-800 mb-4">Quick Actions</h3>
<div className="grid grid-cols-3 gap-3">
<QuickAction href="/dashboard/products/new" icon={<Plus className="h-5 w-5" />} label="Add Product" tint="bg-blue-600 text-white" />
<QuickAction href="/dashboard/qr" icon={<QrCode className="h-5 w-5" />} label="Generate QR" tint="bg-emerald-50 text-emerald-600" />
<QuickAction href="/dashboard/products" icon={<Boxes className="h-5 w-5" />} label="Add Batch" tint="bg-violet-50 text-violet-600" />
<QuickAction href="/dashboard/analytics" icon={<BarChart3 className="h-5 w-5" />} label="View Analytics" tint="bg-amber-50 text-amber-600" />
<QuickAction href="/dashboard/compliance" icon={<ShieldCheck className="h-5 w-5" />} label="Compliance" tint="bg-rose-50 text-rose-500" />
<QuickAction href="/dashboard/settings" icon={<ArrowUpRight className="h-5 w-5" />} label="Settings" tint="bg-slate-100 text-slate-600" />
</div>
</div>
</div>
</div>
);
}
function QuickAction({ href, icon, label, tint }: any) {
return (
<Link href={href}
className="rounded-xl border border-slate-100 p-4 flex flex-col items-center gap-2 hover:shadow-sm transition text-center">
<div className={`h-12 w-12 rounded-full grid place-items-center ${tint}`}>{icon}</div>
<span className="text-xs font-medium text-slate-600">{label}</span>
</Link>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
import { useRef, useState } from "react";
import { useParams } from "next/navigation";
import { Boxes, Layers, Check, Download } from "lucide-react";
import { api, apiBase } from "@/lib/api";
import QrDesigner, { defaultStyle, type QrStyle, type QrDesignerHandle } from "@/components/QrDesigner";
const VERIFY_BASE =
(process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000") + "/v/";
export default function BatchQR() {
const { id } = useParams<{ id: string }>();
const [step, setStep] = useState<"batch" | "customize" | "done">("batch");
const [batch, setBatch] = useState<any>({
batch_number: "", mfg_date: "", expiry_date: "", quantity: 100, qr_type: "batch", notes: "",
});
const [batchId, setBatchId] = useState("");
const [style, setStyle] = useState<QrStyle>(defaultStyle);
const [result, setResult] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const designerRef = useRef<QrDesignerHandle>(null);
// preview encodes a representative URL; real codes are assigned on generate
const previewData = result?.codes?.[0]?.url ?? VERIFY_BASE + "preview";
async function createBatch(e: React.FormEvent) {
e.preventDefault();
setErr("");
try {
const b = await api("/batches", {
method: "POST",
body: JSON.stringify({ ...batch, product_id: id, quantity: Number(batch.quantity) }),
});
setBatchId(b.id);
setStep("customize");
} catch (e: any) { setErr(e.detail || "Failed"); }
}
// map the rich client style onto the backend QRStyle shape
function styleForApi(): any {
return {
dot_shape: style.dot_shape,
corner_square_shape: style.corner_square_shape,
corner_dot_shape: style.corner_dot_shape,
fg_color: style.fg_color,
bg_color: style.bg_color,
transparent_bg: style.transparent_bg,
gradient: style.gradient,
gradient_color: style.gradient_color,
logo_size: style.logo_size,
error_correction: style.error_correction,
quiet_zone: style.quiet_zone,
};
}
async function generate() {
setBusy(true);
setErr("");
try {
const res = await api("/qr/generate", {
method: "POST",
body: JSON.stringify({ batch_id: batchId, style: styleForApi() }),
});
setResult(res);
setStep("done");
// Render the WYSIWYG image for the first code and upload it.
// (For per-pack batches we upload the representative design; each code
// still resolves to its own verify URL.)
const dataUrl = await designerRef.current?.getDataUrl("png");
if (dataUrl && res.codes?.[0]) {
await api(`/qr/${res.codes[0].id}/image`, {
method: "POST",
body: JSON.stringify({ data_url: dataUrl, format: "png" }),
}).catch(() => {});
}
} catch (e: any) {
setErr(typeof e.detail === "string" ? e.detail : "Generation failed");
} finally { setBusy(false); }
}
async function downloadClient(fmt: "png" | "svg") {
const blob = await designerRef.current?.getRawData(fmt);
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${result?.codes?.[0]?.code ?? "qr"}.${fmt}`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="max-w-5xl space-y-5">
<h2 className="text-xl font-bold">Create Batch & QR</h2>
{err && <div className="card p-3 bg-rose-50 text-rose-700 text-sm">{err}</div>}
{step === "batch" && (
<form onSubmit={createBatch} className="card p-5 space-y-4 max-w-2xl">
<div className="grid grid-cols-2 gap-3">
<Input label="Batch number (blank = auto)" value={batch.batch_number}
onChange={(e: any) => setBatch({ ...batch, batch_number: e.target.value })} />
<Input label="Quantity" type="number" value={batch.quantity}
onChange={(e: any) => setBatch({ ...batch, quantity: e.target.value })} />
<Input label="Mfg date" type="date" value={batch.mfg_date}
onChange={(e: any) => setBatch({ ...batch, mfg_date: e.target.value })} />
<Input label="Expiry date" type="date" value={batch.expiry_date}
onChange={(e: any) => setBatch({ ...batch, expiry_date: e.target.value })} />
</div>
<div>
<span className="text-sm font-medium">QR Type</span>
<div className="grid grid-cols-2 gap-3 mt-2">
<TypeCard active={batch.qr_type === "batch"} onClick={() => setBatch({ ...batch, qr_type: "batch" })}
icon={<Boxes className="h-5 w-5" />} title="Batch QR"
desc="One QR for the entire batch. All packs share the same code." note="1 QR generated" />
<TypeCard active={batch.qr_type === "per_pack"} onClick={() => setBatch({ ...batch, qr_type: "per_pack" })}
icon={<Layers className="h-5 w-5" />} title="Per-Pack QR"
desc="Unique QR per pack. Maximum anti-counterfeit protection." note={`${batch.quantity} QRs → ZIP`} />
</div>
</div>
<button className="bg-brand-600 text-white px-6 py-2.5 rounded-lg font-semibold">Continue to designer</button>
</form>
)}
{step === "customize" && (
<>
<h3 className="font-bold text-slate-700">Design the QR</h3>
<QrDesigner ref={designerRef} value={style} onChange={setStyle} data={previewData} />
<div className="flex justify-end">
<button onClick={generate} disabled={busy}
className="bg-brand-600 text-white px-8 py-2.5 rounded-lg font-semibold disabled:opacity-60">
{busy ? "Generating…" : "Generate QR"}
</button>
</div>
</>
)}
{step === "done" && result && (
<div className="grid md:grid-cols-2 gap-5 items-start">
<div className="card p-6 text-center space-y-4">
<div className="h-14 w-14 mx-auto rounded-full bg-emerald-100 text-emerald-600 grid place-items-center">
<Check className="h-7 w-7" />
</div>
<h3 className="font-bold text-lg">
{result.generated} QR code{result.generated > 1 ? "s" : ""} generated
</h3>
<div className="flex gap-3 justify-center flex-wrap">
<button onClick={() => downloadClient("png")}
className="flex items-center gap-2 border border-slate-200 px-4 py-2 rounded-lg text-sm font-medium">
<Download className="h-4 w-4" /> PNG
</button>
<button onClick={() => downloadClient("svg")}
className="flex items-center gap-2 border border-slate-200 px-4 py-2 rounded-lg text-sm font-medium">
<Download className="h-4 w-4" /> SVG
</button>
{result.download_zip && (
<a href={`${apiBase}/qr/batch/${batchId}/download-zip?format=png`}
className="flex items-center gap-2 bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-semibold">
<Download className="h-4 w-4" /> Download all (ZIP)
</a>
)}
</div>
{result.codes?.[0] && (
<p className="text-sm text-slate-500">
Verify URL:{" "}
<a className="text-brand-600" href={result.codes[0].url} target="_blank" rel="noreferrer">
{result.codes[0].url}
</a>
</p>
)}
</div>
{/* keep the ref'd designer mounted so client PNG/SVG downloads work */}
<div className="card p-6 grid place-items-center">
<span className="text-sm text-slate-500 mb-3">Your QR</span>
<QrDesigner ref={designerRef} value={style} onChange={setStyle}
data={result.codes?.[0]?.url ?? previewData} />
</div>
</div>
)}
</div>
);
}
function Input({ label, ...props }: any) {
return (
<label className="block">
<span className="text-sm font-medium">{label}</span>
<input {...props} className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm" />
</label>
);
}
function TypeCard({ active, onClick, icon, title, desc, note }: any) {
return (
<button type="button" onClick={onClick}
className={`text-left rounded-xl border p-4 ${active ? "border-brand-500 ring-2 ring-brand-500/20 bg-brand-50/40" : "border-slate-200"}`}>
<div className="flex items-center gap-2 font-semibold text-slate-800">{icon}{title}</div>
<p className="text-xs text-slate-500 mt-1">{desc}</p>
<p className="text-xs font-semibold text-brand-600 mt-2">{note}</p>
</button>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
export default function NewProduct() {
const r = useRouter();
const [brands, setBrands] = useState<any[]>([]);
const [form, setForm] = useState<any>({
brand_id: "", name: "", sku: "", category: "", description: "",
manufacturer: { company: "", country: "India", plant: "" },
details: { net_weight: "", shelf_life: "", storage_condition: "" },
status: "active",
});
const [err, setErr] = useState("");
const [newBrand, setNewBrand] = useState("");
useEffect(() => { api("/brands").then(setBrands).catch(() => {}); }, []);
async function createBrand() {
if (!newBrand) return;
const b = await api("/brands", { method: "POST", body: JSON.stringify({ name: newBrand }) });
setBrands([...brands, b]);
setForm({ ...form, brand_id: b.id });
setNewBrand("");
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setErr("");
try {
const p = await api("/products", { method: "POST", body: JSON.stringify(form) });
r.push(`/dashboard/products/${p.id}/batch`);
} catch (e: any) {
const d = e.detail;
setErr(typeof d === "object" ? d.message : d || "Failed to create product");
}
}
const set = (path: string) => (e: any) => {
const v = e.target.value;
if (path.includes(".")) {
const [g, k] = path.split(".");
setForm({ ...form, [g]: { ...form[g], [k]: v } });
} else setForm({ ...form, [path]: v });
};
return (
<div className="max-w-2xl space-y-5">
<h2 className="text-xl font-bold">Add Product</h2>
{err && <div className="card p-3 bg-rose-50 border-rose-200 text-rose-700 text-sm">{err}</div>}
<form onSubmit={submit} className="space-y-5">
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-800">Basic Info</h3>
<div className="flex gap-2 items-end">
<label className="flex-1">
<span className="text-sm font-medium">Brand</span>
<select value={form.brand_id} onChange={set("brand_id")} required
className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm">
<option value="">Select brand</option>
{brands.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</label>
<input placeholder="+ New brand" value={newBrand} onChange={(e) => setNewBrand(e.target.value)}
className="px-3 py-2 rounded-lg border border-slate-200 text-sm w-32" />
<button type="button" onClick={createBrand}
className="px-3 py-2 rounded-lg bg-slate-100 text-sm font-medium">Add</button>
</div>
<Input label="Product name" value={form.name} onChange={set("name")} required />
<div className="grid grid-cols-2 gap-3">
<Input label="SKU" value={form.sku} onChange={set("sku")} />
<Input label="Category" value={form.category} onChange={set("category")} />
</div>
</div>
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-800">Manufacturing Info</h3>
<Input label="Manufacturer" value={form.manufacturer.company} onChange={set("manufacturer.company")} />
<div className="grid grid-cols-2 gap-3">
<Input label="Country" value={form.manufacturer.country} onChange={set("manufacturer.country")} />
<Input label="Plant" value={form.manufacturer.plant} onChange={set("manufacturer.plant")} />
</div>
</div>
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-800">Product Details</h3>
<div className="grid grid-cols-2 gap-3">
<Input label="Net weight" value={form.details.net_weight} onChange={set("details.net_weight")} />
<Input label="Shelf life" value={form.details.shelf_life} onChange={set("details.shelf_life")} />
</div>
<Input label="Storage condition" value={form.details.storage_condition} onChange={set("details.storage_condition")} />
</div>
<button className="bg-brand-600 text-white px-6 py-2.5 rounded-lg font-semibold">
Save & create batch
</button>
</form>
</div>
);
}
function Input({ label, ...props }: any) {
return (
<label className="block">
<span className="text-sm font-medium">{label}</span>
<input {...props} className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm" />
</label>
);
}

View File

@@ -0,0 +1,544 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
Plus, Package, AlertTriangle, MoreVertical, Eye, Pencil, Copy, QrCode, Archive, RotateCcw,
} from "lucide-react";
import { api, apiBase } from "@/lib/api";
import ProductDialog from "@/components/ProductDialog";
import { Download } from "lucide-react";
type Mode = "add" | "edit" | "duplicate" | "view";
// data-URLs and absolute URLs pass through; relative /uploads paths get the API host
function imgSrc(url: string) {
if (!url) return "";
if (url.startsWith("data:") || url.startsWith("http")) return url;
return `${apiBase}${url}`;
}
export default function Products() {
const router = useRouter();
const [data, setData] = useState<any>(null);
const [showArchived, setShowArchived] = useState(false);
const [dialog, setDialog] = useState<{ mode: Mode; id?: string } | null>(null);
const [menuFor, setMenuFor] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [brandId, setBrandId] = useState("");
const [category, setCategory] = useState("");
const [status, setStatus] = useState("");
const [qrStatus, setQrStatus] = useState("");
const [trustFrom, setTrustFrom] = useState("");
const [trustTo, setTrustTo] = useState("");
const [createdFrom, setCreatedFrom] = useState("");
const [createdTo, setCreatedTo] = useState("");
const [brands, setBrands] = useState<any[]>([]);
const [page, setPage] = useState(1);
const pageSize = 20;
const [selected, setSelected] = useState<string[]>([]);
const toggleSelectAll = () => {
if (selected.length === (data?.items?.length ?? 0)) {
setSelected([]);
} else {
setSelected((data?.items ?? []).map((p: any) => p.id));
}
};
const toggleSelect = (id: string) => {
setSelected((prev) =>
prev.includes(id)
? prev.filter((x) => x !== id)
: [...prev, id]
);
};
useEffect(() => {
api("/brands")
.then((res) => {
setBrands(res.items ?? res);
})
.catch(() => setBrands([]));
}, []);
function load() {
const params = new URLSearchParams();
if (search) params.append("search", search);
if (brandId) params.append("brand_id", brandId);
if (category) params.append("category", category);
if (showArchived) {
params.append("status_filter", "archived");
} else if (status) {
params.append("status_filter", status);
}
if (qrStatus) params.append("qr_status", qrStatus);
if (trustFrom) params.append("trust_from", trustFrom);
if (trustTo) params.append("trust_to", trustTo);
if (createdFrom) params.append("created_from", createdFrom);
if (createdTo) params.append("created_to", createdTo);
params.append("page", page.toString());
params.append("page_size", pageSize.toString());
api(`/products?${params.toString()}`)
.then(setData)
.catch(() => setData({ items: [] }));
}
useEffect(() => { load(); }, [showArchived,search,
brandId,
category,
status,
qrStatus,
trustFrom,
trustTo,
createdFrom,
createdTo,
page,]);
const limitReached = data?.limit_reached;
async function archive(id: string) {
await api(`/products/${id}/archive`, { method: "PUT" }); setMenuFor(null); load();
}
async function restore(id: string) {
try { await api(`/products/${id}/restore`, { method: "PUT" }); } catch (e: any) { alert(e.detail?.message || "Restore failed"); }
setMenuFor(null); load();
}
async function duplicate(id: string) {
try { await api(`/products/${id}/duplicate`, { method: "POST" }); load(); }
catch (e: any) { alert(e.detail?.message || "Duplicate failed"); }
setMenuFor(null);
}
function exportSelected() {
const rows = (data?.items ?? []).filter((p: any) =>
selected.includes(p.id)
);
const csv = [
[
"Name",
"SKU",
"Brand",
"Category",
"Trust Score",
"Status",
"QR Count",
"Create At"
],
...rows.map((p: any) => [
p.name,
p.sku,
p.brand_name,
p.category,
p.trust_score,
p.status,
p.qr_count,
p.created_at,
]),
]
.map((r) => r.join(","))
.join("\n");
const blob = new Blob([csv], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "products.csv";
a.click();
URL.revokeObjectURL(url);
}
async function archiveSelected() {
if (selected.length === 0) return;
if (!confirm(`Archive ${selected.length} products?`))
return;
await Promise.all(
selected.map((id) =>
api(`/products/${id}/archive`, {
method: "PUT",
})
)
);
setSelected([]);
load();
}
const totalPages = data?.pages ?? 1;
const totalItems = data?.total ?? 0;
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, startPage + 4);
const pages = [];
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return (
<div className="space-y-5" onClick={() => setMenuFor(null)}>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">Products</h2>
{data && <p className="text-sm text-slate-500">{data.count} / {data.limit ?? "∞"} products used</p>}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" checked={showArchived} onChange={(e) => setShowArchived(e.target.checked)} /> Archived
</label>
<button onClick={() => !limitReached && setDialog({ mode: "add" })}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-semibold text-sm text-white ${limitReached ? "bg-slate-300 cursor-not-allowed" : "bg-brand-600"}`}>
<Plus className="h-4 w-4" /> Add Product
</button>
</div>
</div>
<div className="card p-4 mb-4">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-8 gap-3">
{/* Search */}
<input
placeholder="Search Product / SKU / Brand / Trust"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border rounded-lg px-3 py-2"
/>
{/* Brand */}
<select
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
className="border rounded-lg px-3 py-2"
>
<option value="">All Brands</option>
{brands.map((b: any) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
{/* Category */}
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="border rounded-lg px-3 py-2"
>
<option value="">All Categories</option>
{[...new Set((data?.items ?? []).map((p: any) => p.category).filter(Boolean))]
.map((c: any) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
{/* Status */}
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="border rounded-lg px-3 py-2"
>
<option value="">All Status</option>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="archived">Archived</option>
</select>
{/* QR Status */}
<select
value={qrStatus}
onChange={(e) => setQrStatus(e.target.value)}
className="border rounded-lg px-3 py-2"
>
<option value="">QR Status</option>
<option value="generated">Generated</option>
<option value="not_generated">Not Generated</option>
</select>
{/* Created From */}
<input
type="date"
value={createdFrom}
onChange={(e) => setCreatedFrom(e.target.value)}
className="border rounded-lg px-3 py-2"
/>
{/* Created To */}
<input
type="date"
value={createdTo}
onChange={(e) => setCreatedTo(e.target.value)}
className="border rounded-lg px-3 py-2"
/>
{/* Search Button */}
{/* Reset */}
<button
onClick={() => {
setSearch("");
setBrandId("");
setCategory("");
setStatus("");
setQrStatus("");
setCreatedFrom("");
setCreatedTo("");
setPage(1);
}}
className="rounded-lg bg-slate-200 px-4 py-2"
>
Reset
</button>
</div>
</div>
{limitReached && !showArchived && (
<div className="card p-4 border-amber-200 bg-amber-50 flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500" />
<div className="flex-1 text-sm text-amber-800">You&apos;ve reached your plan&apos;s product limit. Upgrade to add more.</div>
<a href="/dashboard/billing" className="text-sm font-semibold text-amber-700 underline">Upgrade</a>
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-b bg-slate-50">
{/* Left */}
<div className="flex items-center gap-3">
<span className="text-sm font-semibold">
Selected:
<span className="ml-1 text-brand-600">
{selected.length}
</span>
</span>
<button
disabled={selected.length === 0}
onClick={exportSelected}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
${
selected.length === 0
? "bg-slate-200 text-slate-400 cursor-not-allowed"
: "bg-blue-600 text-white hover:bg-blue-700"
}`}
>
<Download className="h-4 w-4" />
Export
</button>
{!showArchived && (
<button
disabled={selected.length === 0}
onClick={archiveSelected}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
${
selected.length === 0
? "bg-slate-200 text-slate-400 cursor-not-allowed"
: "bg-rose-600 text-white hover:bg-rose-700"
}`}
>
<Archive className="h-4 w-4" />
Archive
</button>
)}
</div>
{/* Right */}
<div className="text-sm text-slate-600">
Total Products
<span className="ml-2 font-bold">
{data?.total ?? data?.items?.length ?? 0}
</span>
</div>
</div>
<div className="card overflow-visible">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-left">
<tr>
<th className="px-4 py-3 w-10">
<input
type="checkbox"
checked={
selected.length > 0 &&
selected.length === (data?.items?.length ?? 0)
}
onChange={toggleSelectAll}
/>
</th>
<th className="px-4 py-3 font-medium">Image</th>
<th className="px-4 py-3 font-medium">Product</th>
<th className="px-4 py-3 font-medium">SKU</th>
<th className="px-4 py-3 font-medium">Category</th>
<th className="px-4 py-3 font-medium">Brand</th>
<th className="px-4 py-3 font-medium">QR Status</th>
<th className="px-4 py-3 font-medium">Trust</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">QRs</th>
<th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(data?.items ?? []).map((p: any) => (
<tr key={p.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<input
type="checkbox"
checked={selected.includes(p.id)}
onChange={() => toggleSelect(p.id)}
/>
</td>
<td className="px-4 py-3">
{p.image_url
? <img src={imgSrc(p.image_url)} alt="" className="h-11 w-11 rounded-lg object-contain border border-slate-100" />
: <div className="h-11 w-11 rounded-lg bg-slate-100 grid place-items-center"><Package className="h-4 w-4 text-slate-300" /></div>}
</td>
<td className="px-4 py-3 font-medium text-slate-800">{p.name}</td>
<td className="px-4 py-3 text-slate-500">{p.sku ?? "—"}</td>
<td className="px-4 py-3 text-slate-600">
{p.category || "—"}
</td>
<td className="px-4 py-3 text-slate-500">{p.brand_name ?? "—"}</td>
<td className="px-4 py-3">
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
p.qr_count > 0
? "bg-blue-50 text-blue-700"
: "bg-slate-100 text-slate-500"
}`}
>
{p.qr_count > 0 ? "Generated" : "Not Generated"}
</span>
</td>
<td className="px-4 py-3">{p.trust_score}%</td>
<td className="px-4 py-3">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full capitalize ${p.status === "archived" ? "bg-slate-100 text-slate-500" : "bg-emerald-50 text-emerald-700"}`}>{p.status}</span>
</td>
<td className="px-4 py-3">{p.qr_count}</td>
<td className="px-4 py-3 text-slate-500">
{new Date(p.created_at).toLocaleString()}
</td>
<td className="px-4 py-3 text-right relative">
<div className="flex items-center justify-end gap-1">
<button title="View" onClick={() => setDialog({ mode: "view", id: p.id })}
className="h-8 w-8 grid place-items-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50">
<Eye className="h-4 w-4" />
</button>
<button onClick={(e) => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}
className="h-8 w-8 grid place-items-center rounded-lg border border-slate-200 hover:bg-slate-50">
<MoreVertical className="h-4 w-4 text-slate-500" />
</button>
</div>
{menuFor === p.id && (
<div onClick={(e) => e.stopPropagation()}
className="absolute right-4 top-12 z-20 w-44 bg-white rounded-xl border border-slate-200 shadow-lg py-1 text-left">
{p.status === "archived" ? (
<MenuItem icon={<RotateCcw className="h-4 w-4" />} label="Restore" onClick={() => restore(p.id)} />
) : (
<>
<MenuItem icon={<Pencil className="h-4 w-4" />} label="Edit" onClick={() => { setDialog({ mode: "edit", id: p.id }); setMenuFor(null); }} />
<MenuItem icon={<Copy className="h-4 w-4" />} label="Duplicate" onClick={() => duplicate(p.id)} />
<MenuItem icon={<QrCode className="h-4 w-4" />} label="Generate QR" onClick={() => router.push(`/dashboard/products/${p.id}/batch`)} />
<div className="border-t border-slate-100 my-1" />
<MenuItem icon={<Archive className="h-4 w-4" />} label="Archive" danger onClick={() => archive(p.id)} />
</>
)}
</div>
)}
</td>
</tr>
))}
{data?.items?.length === 0 && (
<tr><td colSpan={12} className="px-4 py-12 text-center text-slate-400">
<Package className="h-8 w-8 mx-auto mb-2 opacity-50" />
{showArchived ? "No archived products." : "No products yet. Add your first product."}
</td></tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between px-4 py-3 border-t text-sm text-slate-500">
<span>
Showing{" "}
{totalItems === 0
? 0
: (page - 1) * pageSize + 1}{" "}
to{" "}
{Math.min(page * pageSize, totalItems)}{" "}
of {totalItems}
</span>
<div className="flex items-center gap-1">
<button
disabled={page === 1}
onClick={() => setPage(page - 1)}
className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40"
>
</button>
{pages.map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`px-3 py-1 rounded ${
page === p
? "bg-brand-600 text-white"
: "border border-slate-200 hover:bg-slate-50"
}`}
>
{p}
</button>
))}
<button
disabled={page === totalPages}
onClick={() => setPage(page + 1)}
className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40"
>
</button>
</div>
</div>
{dialog && (
<ProductDialog mode={dialog.mode} productId={dialog.id}
onClose={() => setDialog(null)} onSaved={load} />
)}
</div>
);
}
function MenuItem({ icon, label, onClick, danger }: any) {
return (
<button onClick={onClick}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-slate-50 ${danger ? "text-rose-600" : "text-slate-700"}`}>
{icon} {label}
</button>
);
}

View File

@@ -0,0 +1,463 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
QrCode, CheckCircle2, XCircle, TrendingUp, Calendar, Search,
Eye, Download, RefreshCw, MoreVertical, Archive, Power, Trash2, Plus,
} from "lucide-react";
import { api, apiBase } from "@/lib/api";
import QrDetailDrawer from "@/components/QrDetailDrawer";
export default function QRCenter() {
const router = useRouter();
const [rows, setRows] = useState<any[]>([]);
const [summary, setSummary] = useState<any>(null);
const [brands, setBrands] = useState<any[]>([]);
const [products, setProducts] = useState<any[]>([]);
const [filters, setFilters] = useState({ search: "", product_id: "", brand_id: "", status_filter: "", qr_type: "" ,start_date: "",
end_date: ""});
const [selected, setSelected] = useState<Set<string>>(new Set());
const [openId, setOpenId] = useState<string | null>(null);
const [menuFor, setMenuFor] = useState<string | null>(null);
const [page, setPage] = useState(1);
const pageSize = 25;
const [showProductPicker, setShowProductPicker] = useState(false);
const [productSearch, setProductSearch] = useState("");
const [brandFilter, setBrandFilter] = useState("");
const filteredProducts = products.filter((p) => {
const matchSearch =
p.name.toLowerCase().includes(productSearch.toLowerCase()) ||
(p.sku ?? "").toLowerCase().includes(productSearch.toLowerCase());
const matchBrand =
!brandFilter || p.brand_id === brandFilter;
return matchSearch && matchBrand;
});
function load() {
const qs = new URLSearchParams(Object.entries(filters).filter(([, v]) => v) as any).toString();
api(`/qr${qs ? "?" + qs : ""}`).then(setRows).catch(() => setRows([]));
api("/qr/summary").then(setSummary).catch(() => {});
}
useEffect(() => { load(); }, [filters]);
useEffect(() => {
api("/brands").then(setBrands).catch(() => {});
api("/products").then((d) => setProducts(d.items ?? [])).catch(() => {});
}, []);
function handleExport() {
const exportRows =
selected.size > 0
? rows.filter((r) => selected.has(r.id))
: rows;
const headers = [
"QR Code",
"Product",
"SKU",
"Brand",
"Batch Number",
"Serial",
"QR Type",
"Status",
"Scan Count",
"Last Scan",
"Last Scan Location",
"Created By",
"Created Date",
"Verification URL",
"QR Image",
];
const csv = [
headers.join(","),
...exportRows.map((r) =>
[
`"${r.code ?? ""}"`,
`"${r.product_name ?? ""}"`,
`"${r.sku ?? ""}"`,
`"${r.brand_name ?? ""}"`,
`"${r.batch_number ?? ""}"`,
`"${r.serial ?? ""}"`,
`"${r.qr_type ?? ""}"`,
`"${r.status ?? ""}"`,
`"${r.scan_count ?? 0}"`,
`"${r.last_scan_at ? new Date(r.last_scan_at).toLocaleString("en-IN") : ""}"`,
`"${r.last_scan_location ?? ""}"`,
`"${r.created_by ?? ""}"`,
`"${r.created_at ? new Date(r.created_at).toLocaleString("en-IN") : ""}"`,
`"${r.url ?? ""}"`,
`"${r.image_url ? apiBase + r.image_url : apiBase + `/qr/${r.id}/download?format=png`}"`,
].join(",")
),
].join("\n");
const blob = new Blob([csv], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download =
selected.size > 0
? `selected_qr_${selected.size}.csv`
: `qr_report_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
const paged = useMemo(() => rows.slice((page - 1) * pageSize, page * pageSize), [rows, page]);
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
function toggle(id: string) {
setSelected((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
}
function toggleAll() {
setSelected((s) => s.size === paged.length ? new Set() : new Set(paged.map((r) => r.id)));
}
async function bulk(action: string) {
if (selected.size === 0) return;
if (action === "delete" && !confirm(`Delete ${selected.size} QR code(s)?`)) return;
await api("/qr/bulk", { method: "POST", body: JSON.stringify({ ids: [...selected], action }) });
setSelected(new Set()); load();
}
const set = (k: string, v: string) => { setPage(1); setFilters((f) => ({ ...f, [k]: v })); };
return (
<div className="space-y-5" onClick={() => setMenuFor(null)}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">QR Management Center</h2>
<button onClick={() => setShowProductPicker(true)}
className="flex items-center gap-2 bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-semibold">
<Plus className="h-4 w-4" /> Generate New QR
</button>
</div>
{/* KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<Kpi label="Total QR Codes" value={summary?.total ?? 0} sub="All time" icon={<QrCode className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="Active QR Codes" value={summary?.active ?? 0} sub={`${summary?.active_pct ?? 0}% of total`} subColor="text-emerald-600" icon={<CheckCircle2 className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<Kpi label="Inactive QR Codes" value={summary?.inactive ?? 0} sub={`${summary?.inactive_pct ?? 0}% of total`} subColor="text-rose-600" icon={<XCircle className="h-5 w-5" />} tint="bg-rose-50 text-rose-500" />
<Kpi label="Total Scans" value={(summary?.total_scans ?? 0).toLocaleString()} sub="All time" icon={<TrendingUp className="h-5 w-5" />} tint="bg-violet-50 text-violet-600" />
<Kpi label="Today's Scans" value={summary?.today_scans ?? 0} sub="↑ vs yesterday" subColor="text-emerald-600" icon={<Calendar className="h-5 w-5" />} tint="bg-cyan-50 text-cyan-600" />
</div>
{/* Filters */}
<div className="card p-4 flex flex-wrap items-end gap-3">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<input value={filters.search} onChange={(e) => set("search", e.target.value)} placeholder="Search QR…"
className="pl-9 pr-3 py-2 w-44 rounded-lg border border-slate-200 text-sm" />
</div>
<Filter label="Product" value={filters.product_id} onChange={(v) => set("product_id", v)} opts={products.map((p) => ({ value: p.id, label: p.name }))} all="All Products" />
<Filter label="Brand" value={filters.brand_id} onChange={(v) => set("brand_id", v)} opts={brands.map((b) => ({ value: b.id, label: b.name }))} all="All Brands" />
<Filter label="Status" value={filters.status_filter} onChange={(v) => set("status_filter", v)} opts={[{ value: "active", label: "Active" }, { value: "inactive", label: "Inactive" }, { value: "disabled", label: "Disabled" }]} all="All Status" />
<Filter label="QR Type" value={filters.qr_type} onChange={(v) => set("qr_type", v)} opts={[{ value: "batch", label: "Batch" }, { value: "per_pack", label: "Per-Pack" }]} all="All Types" />
<div className="block">
<span className="text-xs text-slate-500">Created On</span>
<div className="mt-1 flex items-center gap-2">
<input
type="date"
value={filters.start_date}
onChange={(e) => set("start_date", e.target.value)}
className="px-3 py-2 rounded-lg border border-slate-200 text-sm"
/>
<span className="text-slate-400">to</span>
<input
type="date"
value={filters.end_date}
onChange={(e) => set("end_date", e.target.value)}
className="px-3 py-2 rounded-lg border border-slate-200 text-sm"
/>
</div>
</div>
</div>
{/* Bulk actions bar */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-slate-500">{selected.size} selected</span>
{[["disable", Power, "Disable"], ["archive", Archive, "Archive"], ["delete", Trash2, "Delete"]].map(([a, Icon, label]: any) => (
<button key={a} onClick={() => bulk(a)} disabled={selected.size === 0}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg border text-xs font-medium ${selected.size === 0 ? "border-slate-100 text-slate-300" : a === "delete" ? "border-rose-200 text-rose-600" : "border-slate-200 text-slate-600"}`}>
<Icon className="h-3.5 w-3.5" /> {label}
</button>
))}
<button
onClick={handleExport}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50"
>
<Download className="h-4 w-4" />
Export
</button>
</div>
<span className="text-slate-500">Total: {rows.length}</span>
</div>
<div>
{/* Table */}
<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-3 py-3"><input type="checkbox" checked={selected.size > 0 && selected.size === paged.length} onChange={toggleAll} /></th>
<th className="px-3 py-3 font-medium">QR</th>
<th className="px-3 py-3 font-medium">Product / SKU</th>
<th className="px-3 py-3 font-medium">Batch</th>
<th className="px-3 py-3 font-medium">Type</th>
<th className="px-3 py-3 font-medium">Status</th>
<th className="px-3 py-3 font-medium">Scans</th>
<th className="px-3 py-3 font-medium">Last Scan</th>
<th className="px-3 py-3 font-medium">Created</th>
<th className="px-3 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{paged.map((q) => (
<tr key={q.id} className={`hover:bg-slate-50 ${openId === q.id ? "bg-brand-50/40" : ""}`}>
<td className="px-3 py-3"><input type="checkbox" checked={selected.has(q.id)} onChange={() => toggle(q.id)} /></td>
<td className="px-3 py-3">
{q.image_url
? <img src={`${apiBase}${q.image_url}`} alt="" className="h-11 w-11 rounded object-contain border border-slate-100" />
: <img src={`${apiBase}/qr/${q.id}/download?format=png`} alt="" className="h-11 w-11 rounded object-contain border border-slate-100" />}
</td>
<td className="px-3 py-3">
<div className="font-medium text-slate-800">{q.product_name ?? "—"}</div>
<div className="text-xs text-slate-400">{q.sku ?? ""}</div>
</td>
<td className="px-3 py-3 text-slate-500">{q.batch_number ?? "—"}</td>
<td className="px-3 py-3"><span className="text-xs font-semibold bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full capitalize">{q.qr_type === "per_pack" ? "Per-Pack" : "Batch"}</span></td>
<td className="px-3 py-3">
<span className="flex items-center gap-1.5 text-sm capitalize">
<span className={`h-2 w-2 rounded-full ${q.status === "active" ? "bg-emerald-500" : "bg-rose-500"}`} />
{q.status}
</span>
</td>
<td className="px-3 py-3 font-medium">{q.scan_count.toLocaleString()}</td>
<td className="px-3 py-3 text-xs text-slate-500">
{q.last_scan_at ? (
<>
<div>
{new Date(q.last_scan_at).toLocaleDateString("en-IN")}
</div>
<div className="text-slate-400">
{new Date(q.last_scan_at).toLocaleTimeString("en-IN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</div>
<div className="text-brand-600">
{q.last_scan_location ?? ""}
</div>
</>
) : (
"—"
)}
</td>
<td className="px-3 py-3 text-xs text-slate-500">
<div>
{new Date(q.created_at).toLocaleDateString("en-IN")}
</div>
<div className="text-slate-400">
{new Date(q.created_at).toLocaleTimeString("en-IN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</div>
</td>
<td className="px-3 py-3 relative">
<div className="flex gap-1">
<IconBtn title="View" onClick={() => setOpenId(q.id)}><Eye className="h-4 w-4" /></IconBtn>
<button onClick={(e) => { e.stopPropagation(); setMenuFor(menuFor === q.id ? null : q.id); }}
className="h-8 w-8 grid place-items-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50">
<MoreVertical className="h-4 w-4" />
</button>
</div>
{menuFor === q.id && (
<div onClick={(e) => e.stopPropagation()}
className="absolute right-3 top-12 z-20 w-44 bg-white rounded-xl border border-slate-200 shadow-lg py-1 text-left">
<a href={`${apiBase}/qr/${q.id}/download?format=png`} className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"><Download className="h-4 w-4" /> Download PNG</a>
<a href={`${apiBase}/qr/${q.id}/download?format=svg`} className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"><Download className="h-4 w-4" /> Download SVG</a>
<a href={`${apiBase}/qr/${q.id}/download?format=pdf`} className="flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"><Download className="h-4 w-4" /> Download PDF</a>
<div className="border-t border-slate-100 my-1" />
<button onClick={async () => { await api(`/qr/${q.id}/regenerate`, { method: "POST" }); setMenuFor(null); load(); }} className="w-full flex items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"><RefreshCw className="h-4 w-4" /> Regenerate</button>
<button onClick={async () => { await api(`/qr/${q.id}/disable`, { method: "PUT" }); setMenuFor(null); load(); }} className="w-full flex items-center gap-2 px-3 py-2 text-sm text-rose-600 hover:bg-slate-50"><Power className="h-4 w-4" /> Disable</button>
</div>
)}
</td>
</tr>
))}
{paged.length === 0 && <tr><td colSpan={10} className="px-4 py-12 text-center text-slate-400">No QR codes match.</td></tr>}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 text-sm text-slate-500">
<span>Showing {(page - 1) * pageSize + 1} to {Math.min(page * pageSize, rows.length)} of {rows.length}</span>
<div className="flex gap-1">
<button disabled={page === 1} onClick={() => setPage(page - 1)} className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40"></button>
{Array.from({ length: totalPages }).slice(0, 5).map((_, i) => (
<button key={i} onClick={() => setPage(i + 1)} className={`px-3 py-1 rounded ${page === i + 1 ? "bg-brand-600 text-white" : "border border-slate-200"}`}>{i + 1}</button>
))}
<button disabled={page === totalPages} onClick={() => setPage(page + 1)} className="px-3 py-1 rounded border border-slate-200 disabled:opacity-40"></button>
</div>
</div>
</div>
</div>
{showProductPicker && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg">
<div className="p-5 border-b">
<h2 className="text-lg font-bold">
Generate QR
</h2>
<p className="text-sm text-slate-500">
Select a product.
</p>
</div>
<div className="p-4 border-b flex gap-3">
<input
value={productSearch}
onChange={(e) => setProductSearch(e.target.value)}
placeholder="Search product..."
className="flex-1 rounded-lg border px-3 py-2"
/>
<select
value={brandFilter}
onChange={(e) => setBrandFilter(e.target.value)}
className="rounded-lg border px-3 py-2"
>
<option value="">All Brands</option>
{brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
<div className="max-h-96 overflow-y-auto">
{filteredProducts.map((p) => (
<button
key={p.id}
onClick={() => {
setShowProductPicker(false);
router.push(`/dashboard/products/${p.id}/batch`);
}}
className="w-full flex items-center justify-between p-4 hover:bg-slate-50 border-b"
>
<div className="flex items-center gap-4">
<img
src={p.image_url}
className="h-14 w-14 rounded-lg border object-cover"
alt=""
/>
<div className="text-left">
<div className="font-semibold text-slate-800">
{p.name}
</div>
<div className="text-sm text-slate-500 mt-1">
SKU: <span className="font-medium">{p.sku || "—"}</span>
<span className="mx-2"></span>
Brand:
<span className="font-medium ml-1">
{p.brand_name || "—"}
</span>
</div>
</div>
</div>
<QrCode className="h-5 w-5 text-slate-400" />
</button>
))}
</div>
<div className="p-4 border-t flex justify-end">
<button
onClick={() => setShowProductPicker(false)}
className="px-4 py-2 rounded-lg border"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* QR Details modal */}
{openId && (
<QrDetailDrawer qrId={openId} onClose={() => setOpenId(null)} onChanged={load} />
)}
</div>
);
}
function Kpi({ label, value, sub, subColor = "text-slate-400", icon, tint }: any) {
return (
<div className="card p-5">
<div className="flex items-start gap-4">
<div className={`h-12 w-12 rounded-xl grid place-items-center shrink-0 ${tint}`}>{icon}</div>
<div className="min-w-0">
<div className="text-sm text-slate-500">{label}</div>
<div className="text-2xl font-extrabold text-slate-900 mt-0.5">{value}</div>
<div className={`text-xs font-medium mt-1 ${subColor}`}>{sub}</div>
</div>
</div>
</div>
);
}
function Filter({ label, value, onChange, opts, all }: {
label: string; value: string; onChange: (v: string) => void;
opts: { value: string; label: string }[]; all: string;
}) {
return (
<label className="block">
<span className="text-xs text-slate-500">{label}</span>
<select value={value} onChange={(e) => onChange(e.target.value)}
className="mt-1 block px-3 py-2 rounded-lg border border-slate-200 text-sm min-w-[130px]">
<option value="">{all}</option>
{opts.map((o: any) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
);
}
function IconBtn({ children, onClick, title }: any) {
return (
<button title={title} onClick={onClick} className="h-8 w-8 grid place-items-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50">
{children}
</button>
);
}

View File

@@ -0,0 +1,544 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import {
Building2, Bell, CreditCard, FileText, Save, CheckCircle2, Users as UsersIcon,
} from "lucide-react";
import { api } from "@/lib/api";
import { Package } from "lucide-react";
import UsersPanel from "@/components/UsersPanel";
import BrandsPanel from "@/components/BrandsPanel";
import countryList from "react-select-country-list";
import TimezoneSelect from "react-timezone-select";
import { T, detectLang } from "@/lib/i18n";
import { useLanguage } from "@/contexts/LanguageContext";
import { apiBase } from "@/lib/api";
import { Download } from "lucide-react";
const countries = countryList().getData();
export const LANGS = [
{ code: "en", label: "English" },
{ code: "hi", label: "हिन्दी" },
{ code: "kn", label: "ಕನ್ನಡ" },
{ code: "ta", label: "தமிழ்" },
{ code: "bn", label: "বাংলা" },
];
function SettingsInner() {
const sp = useSearchParams();
const initial = sp.get("section") ?? "org";
const [active, setActive] = useState(initial);
const [org, setOrg] = useState<any>(null);
const [form, setForm] = useState<any>({});
const [saved, setSaved] = useState(false);
const [sub, setSub] = useState<any>(null);
const [logs, setLogs] = useState<any[]>([]);
const [notif, setNotif] = useState({ email: true, system: true, compliance: true, security: true });
const { t, setLang } = useLanguage();
const SECTIONS = [
{ id: "org", label: t.organization, icon: Building2 },
{ id: "users", label: t.users, icon: UsersIcon },
{ id: "brands", label: t.brands, icon: Package },
{ id: "notifications", label: t.notifications, icon: Bell },
{ id: "subscription", label: t.subscription, icon: CreditCard },
{ id: "audit", label: t.audit, icon: FileText },
];
const [logoPreview, setLogoPreview] = useState("");
const [logoFileName, setLogoFileName] = useState("");
const [filters, setFilters] = useState({
user: "",
module: "",
from: "",
to: "",
});
function downloadAuditLogs() {
if (!logs.length) return;
const rows = logs.map((l) => ({
Time: l.at
? new Date(l.at).toLocaleString(form.language || "en", {
timeZone: form.timezone || "Asia/Kolkata",
})
: "",
User: l.user,
Role: l.role,
Action: l.action,
Module: l.module,
Details: l.details,
IP: l.ip ?? "",
}));
const headers = Object.keys(rows[0]);
const csv = [
headers.join(","),
...rows.map((r) =>
headers
.map((h) => `"${String((r as any)[h] ?? "").replace(/"/g, '""')}"`)
.join(",")
),
].join("\n");
const blob = new Blob([csv], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `audit_logs_${new Date()
.toISOString()
.slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
}
async function loadLogs() {
const params = new URLSearchParams();
if (filters.user) params.append("user", filters.user);
if (filters.module) params.append("module", filters.module);
if (filters.from) params.append("from_date", filters.from);
if (filters.to) params.append("to_date", filters.to);
const data = await api(`/audit-logs?${params.toString()}`);
setLogs(data);
}
useEffect(() => {
api("/organization").then((o) => {
setOrg(o);
setLogoPreview(o.logo_url || "");
localStorage.setItem("timezone", o.timezone);
setForm({ name: o.name, gstin: o.gstin ?? "", pan: o.pan ?? "",address: o.address ?? "", phone: o.phone ?? "",logo_url:o.logo_url ?? "",
website: o.website ?? "", timezone: o.timezone ?? "Asia/Kolkata", language: o.language ?? "en", contact_email: o.contact_email ?? "",country: o.country ?? "",});
}).catch(() => {});
api("/billing/subscription").then(setSub).catch(() => {});
loadLogs();
}, []);
async function uploadLogo(file: File) {
const fd = new FormData();
fd.append("file", file);
const token = localStorage.getItem("vp_token");
const res = await fetch(`${apiBase}/brands/upload-logo`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: fd,
});
const data = await res.json();
setLogoPreview(data.url);
setLogoFileName(file.name);
setForm({
...form,
logo_url: data.url,
});
}
async function saveOrg() {
await api("/organization", { method: "PUT", body: JSON.stringify(form) });
setLang(form.language);
localStorage.setItem("timezone", form.timezone);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
useEffect(() => {
loadLogs();
}, [filters]);
return (
<div className="space-y-5">
<h2 className="text-xl font-bold">{t.settings}</h2>
<div className="grid md:grid-cols-[200px_1fr] gap-5">
<nav className="space-y-1">
{SECTIONS.map(({ id, label, icon: Icon }) => (
<button key={id} onClick={() => setActive(id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium ${active === id ? "bg-brand-50 text-brand-700" : "text-slate-600 hover:bg-slate-50"}`}>
<Icon className="h-4 w-4" /> {label}
</button>
))}
</nav>
<div>
{active === "users" && <UsersPanel />}
{active === "brands" && <BrandsPanel />}
{active === "org" && (
<div className="card p-5 space-y-4 max-w-3xl">
<h3 className="font-bold">{t.organization}</h3>
{saved && <div className="text-sm text-emerald-600 flex items-center gap-1"><CheckCircle2 className="h-4 w-4" /> Saved</div>}
<div className="flex items-center gap-6 mb-6">
<div className="grid lg:grid-cols-[260px_1fr] gap-8">
{/* LEFT SIDE */}
<div className="border rounded-xl p-6 bg-slate-50 flex flex-col items-center">
<div className="w-40 h-40 rounded-xl border bg-white overflow-hidden flex items-center justify-center">
{logoPreview ? (
<img
src={logoPreview}
alt="Organization Logo"
className="w-full h-full object-contain"
/>
) : (
<span className="text-slate-400 text-sm">
No Logo
</span>
)}
</div>
<label className="mt-5 w-full">
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
if (e.target.files?.[0]) {
uploadLogo(e.target.files[0]);
}
}}
/>
<div className="cursor-pointer text-center bg-brand-600 text-white rounded-lg py-2 font-medium hover:bg-brand-700">
Upload Logo
</div>
</label>
{logoFileName && (
<p className="mt-3 text-xs text-slate-500 break-all text-center">
{logoFileName}
</p>
)}
</div>
{/* RIGHT SIDE */}
<div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field
label="Organization Name"
value={form.name}
onChange={(v:string)=>setForm({...form,name:v})}
/>
<Field
label="Website"
value={form.website}
onChange={(v:string)=>setForm({...form,website:v})}
/>
<Field
label="GSTIN"
value={form.gstin}
onChange={(v:string)=>setForm({...form,gstin:v})}
/>
<Field
label="PAN"
value={form.pan}
onChange={(v:string)=>setForm({...form,pan:v})}
/>
<Field
label="Phone"
value={form.phone}
onChange={(v:string)=>setForm({...form,phone:v})}
/>
<Field
label="Contact Email"
value={form.contact_email}
onChange={(v:string)=>setForm({...form,contact_email:v})}
/>
<label>
<span className="text-sm font-medium">Country</span>
<select
value={form.country}
onChange={(e)=>setForm({...form,country:e.target.value})}
className="mt-1 w-full border rounded-lg px-3 py-2"
>
{countries.map((c)=>(
<option key={c.value} value={c.label}>
{c.label}
</option>
))}
</select>
</label>
<label>
<span className="text-sm font-medium">Language</span>
<select
value={form.language}
onChange={(e)=>setForm({...form,language:e.target.value})}
className="mt-1 w-full border rounded-lg px-3 py-2"
>
{LANGS.map((l)=>(
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>
</label>
<div className="md:col-span-2">
<label>
<span className="text-sm font-medium">Timezone</span>
<TimezoneSelect
value={form.timezone}
onChange={(tz:any)=>
setForm({
...form,
timezone:tz.value,
})
}
/>
</label>
</div>
<div className="md:col-span-2">
<Field
label="Address"
value={form.address}
onChange={(v:string)=>setForm({...form,address:v})}
/>
</div>
</div>
</div>
</div>
</div>
<button onClick={saveOrg} className="flex items-center gap-2 bg-brand-600 text-white px-5 py-2 rounded-lg text-sm font-semibold">
<Save className="h-4 w-4" /> {t.save}
</button>
</div>
)}
{active === "notifications" && (
<div className="card p-5 space-y-4 max-w-xl">
<h3 className="font-bold">{t.notifications}</h3>
{[
["email", "Email notifications"],
["system", "System alerts"],
["compliance", "Compliance expiry alerts"],
["security", "Security alerts"],
].map(([key, label]) => (
<div key={key} className="flex items-center justify-between py-2 border-b border-slate-100">
<span className="text-sm text-slate-700">{label}</span>
<button onClick={() => setNotif({ ...notif, [key]: !(notif as any)[key] })}
className={`w-11 h-6 rounded-full relative transition ${(notif as any)[key] ? "bg-emerald-500" : "bg-slate-300"}`}>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition ${(notif as any)[key] ? "left-5" : "left-0.5"}`} />
</button>
</div>
))}
<p className="text-xs text-slate-400">Preferences are illustrative in this build.</p>
</div>
)}
{active === "subscription" && (
<div className="card p-5 max-w-xl">
<h3 className="font-bold mb-3">{t.subscription}</h3>
<div className="space-y-2 text-sm">
<Row label="Current plan" value={sub?.plan?.name ?? "—"} />
<Row label="Status" value={sub?.status ?? "—"} />
<Row label="Days remaining" value={sub?.days_remaining ?? "—"} />
<Row label="Auto-renewal" value={sub?.auto_renew ? "On" : "Off"} />
</div>
<Link href="/dashboard/billing" className="inline-block mt-4 bg-brand-600 text-white px-5 py-2 rounded-lg text-sm font-semibold">
Manage billing
</Link>
</div>
)}
{active === "audit" && (
<div className="card overflow-x-auto">
<div className="flex justify-between items-center p-5 pb-3">
<h3 className="font-bold">Recent Activity</h3>
<button
onClick={downloadAuditLogs}
className="flex items-center gap-2 bg-brand-600 text-white px-4 py-2 rounded-lg hover:bg-brand-700"
>
<Download className="h-4 w-4" />
Download CSV
</button>
</div>
<div className="flex flex-wrap gap-3 p-5 border-b">
<input
type="date"
value={filters.from}
onChange={(e) =>
setFilters({ ...filters, from: e.target.value })
}
className="border rounded-lg px-3 py-2"
/>
<input
type="date"
value={filters.to}
onChange={(e) =>
setFilters({ ...filters, to: e.target.value })
}
className="border rounded-lg px-3 py-2"
/>
<select
value={filters.module}
onChange={(e) =>
setFilters({ ...filters, module: e.target.value })
}
className="border rounded-lg px-3 py-2"
>
<option value="">All Modules</option>
<option value="organization">Organization</option>
<option value="products">Product</option>
<option value="branding">Branding</option>
<option value="users">Users</option>
<option value="compliance">Compliance</option>
</select>
<input
placeholder="Search User"
value={filters.user}
onChange={(e) =>
setFilters({ ...filters, user: e.target.value })
}
className="border rounded-lg px-3 py-2"
/>
<button
onClick={() =>
setFilters({
user: "",
module: "",
from: "",
to: "",
})
}
className="px-4 py-2 rounded-lg bg-slate-200"
>
Clear
</button>
</div>
<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">Time</th>
<th className="px-4 py-3 font-medium">User</th>
<th className="px-4 py-3 font-medium">Action</th>
<th className="px-4 py-3 font-medium">Details</th>
<th className="px-4 py-3 font-medium">Module</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{logs.map((l, i) => (
<tr key={i}>
<td className="px-4 py-3 text-slate-500">
{l.at
? new Date(l.at).toLocaleString(form.language || "en", {
timeZone: form.timezone || "Asia/Kolkata",
dateStyle: "medium",
timeStyle: "short",
})
: ""}
</td>
<td className="px-4 py-3">
<div className="font-medium">
{l.user ?? "—"}
</div>
<div className="text-xs text-slate-500 capitalize">
{l.role ?? "User"}
</div>
</td>
<td className="px-4 py-3 capitalize">{l.action?.replace(".", " ")}</td>
<td className="px-4 py-3 text-slate-500">{l.details}</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 rounded-full text-xs font-medium
${
l.module === "Product"
? "bg-blue-100 text-blue-700"
: l.module === "Organization"
? "bg-green-100 text-green-700"
: l.module === "Branding"
? "bg-purple-100 text-purple-700"
: l.module === "Notification"
? "bg-yellow-100 text-yellow-700"
: "bg-slate-100 text-slate-700"
}`}
>
{l.module}
</span>
</td>
</tr>
))}
{logs.length === 0 && <tr><td colSpan={4} className="px-4 py-10 text-center text-slate-400">No activity yet.</td></tr>}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
export default function Settings() {
return (
<Suspense fallback={<div className="text-slate-400">Loading</div>}>
<SettingsInner />
</Suspense>
);
}
function Field({ label, value, onChange }: any) {
return (
<label className="block">
<span className="text-sm font-medium">{label}</span>
<input value={value ?? ""} onChange={(e) => onChange(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm" />
</label>
);
}
function Row({ label, value }: any) {
return (
<div className="flex justify-between border-b border-slate-100 py-2">
<span className="text-slate-500">{label}</span>
<span className="font-medium text-slate-800 capitalize">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,10 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
// Users management moved into Settings → Users.
export default function UsersRedirect() {
const r = useRouter();
useEffect(() => { r.replace("/dashboard/settings?section=users"); }, [r]);
return <div className="min-h-[40vh] grid place-items-center text-slate-400">Redirecting</div>;
}

26
frontend/app/error.tsx Normal file
View File

@@ -0,0 +1,26 @@
"use client";
import { AlertTriangle } from "lucide-react";
export default function Error({ reset }: { error: Error; reset: () => void }) {
return (
<div className="min-h-screen grid place-items-center p-8">
<div className="text-center max-w-md">
<div className="h-14 w-14 mx-auto rounded-full bg-rose-50 text-rose-500 grid place-items-center">
<AlertTriangle className="h-7 w-7" />
</div>
<h1 className="text-xl font-bold mt-4">Something went wrong</h1>
<p className="text-slate-500 text-sm mt-2">
An unexpected error occurred. You can try again or head back to the dashboard.
</p>
<div className="flex gap-3 justify-center mt-6">
<button onClick={reset} className="bg-brand-600 text-white px-5 py-2 rounded-lg text-sm font-semibold">
Try again
</button>
<a href="/dashboard" className="border border-slate-200 px-5 py-2 rounded-lg text-sm font-medium">
Go to dashboard
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import AuthShell, { Field } from "@/components/AuthShell";
import { api } from "@/lib/api";
export default function Forgot() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
await api("/auth/forgot-password", { method: "POST", body: JSON.stringify({ email }) });
setSent(true);
}
return (
<AuthShell title="Forgot password" subtitle="We'll email you a reset link">
{sent ? (
<p className="text-sm text-slate-600">
If that email exists, a reset link is on its way. (Dev: check backend console.)
</p>
) : (
<form onSubmit={submit}>
<Field label="Email" type="email" value={email} onChange={(e: any) => setEmail(e.target.value)} required />
<button className="w-full bg-brand-600 text-white py-2.5 rounded-lg font-semibold">Send reset link</button>
</form>
)}
<Link href="/login" className="text-brand-600 text-sm mt-4 inline-block">Back to login</Link>
</AuthShell>
);
}

59
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap");
:root { --radius: 14px; }
body {
@apply bg-slate-50 text-slate-900 antialiased;
font-family: Inter, system-ui, sans-serif;
}
.card {
@apply bg-white rounded-2xl border border-slate-100 shadow-sm;
}
.skeleton {
@apply animate-pulse bg-slate-200 rounded;
}
@media print {
@page {
size: A4 portrait;
margin: 10mm;
}
html,
body {
background: white !important;
zoom: 0.85;
}
.no-print {
display: none !important;
}
.print-container {
width: 100%;
max-width: 100%;
margin: 0;
padding: 0;
}
.print-card {
break-inside: avoid;
page-break-inside: avoid;
margin-top: 10px !important;
box-shadow: none !important;
}
img {
max-height: 90px !important;
}
button {
display: none !important;
}
}

21
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import "./globals.css";
import { LanguageProvider } from "@/contexts/LanguageContext";
export const metadata: Metadata = {
title: "VerifyPack — Digital Trust for Every Product",
description: "QR code generation & product verification SaaS",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body suppressHydrationWarning><LanguageProvider>
{children}
</LanguageProvider></body>
</html>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import { Suspense, useState } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import AuthShell from "@/components/AuthShell";
import { api, setToken } from "@/lib/api";
import { useLanguage } from "@/contexts/LanguageContext";
function Inner() {
const sp = useSearchParams();
const router = useRouter();
const { setLang } = useLanguage();
const email = sp.get("email") ?? "";
const [otp, setOtp] = useState("");
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setErr("");
try {
const res = await api("/auth/verify-login-otp", {
method: "POST",
body: JSON.stringify({
email,
otp,
}),
});
setToken(res.access_token);
const org = await api("/organization");
if (org.language) {
setLang(org.language);
localStorage.setItem("language", org.language);
}
if (org.timezone) {
localStorage.setItem("timezone", org.timezone);
}
const me = await api("/me");
if (me.is_product_admin) {
router.push("/admin");
} else if (!me.has_org) {
router.push("/onboarding");
} else {
router.push("/dashboard");
}
} catch (e: any) {
setErr(
typeof e.detail === "string"
? e.detail
: "Invalid or expired OTP"
);
} finally {
setBusy(false);
}
}
async function resend() {
setErr("");
setMsg("");
try {
await api("/auth/login", {
method: "POST",
body: JSON.stringify({
email,
resend: true,
}),
});
setMsg("A new OTP has been sent.");
} catch {
setErr("Unable to resend OTP.");
}
}
return (
<AuthShell
title="Verify Login"
subtitle={`Enter the 6-digit OTP sent to ${email}`}
>
<form onSubmit={submit}>
<label className="block mb-4">
<span className="text-sm font-medium text-slate-700">
Login OTP
</span>
<input
value={otp}
onChange={(e) =>
setOtp(
e.target.value
.replace(/\D/g, "")
.slice(0, 6)
)
}
inputMode="numeric"
placeholder="123456"
className="mt-1 w-full text-center tracking-[0.5em] text-lg px-3 py-3 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-brand-500/30"
/>
</label>
{err && (
<p className="text-sm text-rose-600 mb-3">
{err}
</p>
)}
{msg && (
<p className="text-sm text-emerald-600 mb-3">
{msg}
</p>
)}
<button
disabled={busy || otp.length < 6}
className="w-full bg-brand-600 text-white py-3 rounded-full font-semibold disabled:opacity-50"
>
{busy ? "Verifying..." : "Verify OTP"}
</button>
</form>
<div className="flex justify-between mt-4 text-sm">
<button
onClick={resend}
className="text-brand-600"
>
Resend OTP
</button>
<Link
href="/login"
className="text-slate-500"
>
Back to login
</Link>
</div>
<p className="text-xs text-slate-400 mt-4">
Check your email inbox (and spam folder) for the login OTP.
</p>
</AuthShell>
);
}
export default function LoginOTP() {
return (
<Suspense>
<Inner />
</Suspense>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import AuthShell, { Field } from "@/components/AuthShell";
import { GoogleButton, OrDivider } from "@/components/GoogleAuth";
import { api, setToken } from "@/lib/api";
import { useLanguage } from "@/contexts/LanguageContext";
export default function Login() {
const r = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const { setLang } = useLanguage();
async function submit(e: React.FormEvent) {
e.preventDefault();
setErr(""); setLoading(true);
try {
const res = await api("/auth/login", {
method: "POST", body: JSON.stringify({ email, password }),
});
r.push(`/login-otp?email=${encodeURIComponent(email)}`);
return;
} catch (e: any) {
setErr(
typeof e.detail === "string"
? e.detail
: "Invalid email or password"
);
} finally {
setLoading(false);
}
}
return (
<AuthShell title="Log in" subtitle="Welcome back to VerifyPack">
<GoogleButton label="Log in with Google" />
<OrDivider text="Or, log in with your email" />
<form onSubmit={submit}>
<Field label="Email" type="email" value={email} onChange={(e: any) => setEmail(e.target.value)} required />
<Field label="Password" type="password" value={password} onChange={(e: any) => setPassword(e.target.value)} required />
{err && <p className="text-sm text-rose-600 mb-3">{err}</p>}
<button disabled={loading}
className="w-full bg-brand-600 text-white py-3 rounded-full font-semibold disabled:opacity-60">
{loading ? "Signing in..." : "Log in"}
</button>
</form>
<div className="flex justify-between mt-5 text-sm">
<Link href="/forgot-password" className="text-brand-600">Forgot password?</Link>
<Link href="/signup" className="text-brand-600 font-semibold">Create account</Link>
</div>
</AuthShell>
);
}

View File

@@ -0,0 +1,19 @@
import Link from "next/link";
import { Compass } from "lucide-react";
export default function NotFound() {
return (
<div className="min-h-screen grid place-items-center p-8">
<div className="text-center max-w-md">
<div className="h-14 w-14 mx-auto rounded-full bg-blue-50 text-blue-500 grid place-items-center">
<Compass className="h-7 w-7" />
</div>
<h1 className="text-2xl font-extrabold mt-4">Page not found</h1>
<p className="text-slate-500 text-sm mt-2">The page you&apos;re looking for doesn&apos;t exist.</p>
<Link href="/" className="inline-block mt-6 bg-brand-600 text-white px-5 py-2 rounded-lg text-sm font-semibold">
Back home
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ShieldHalf } from "lucide-react";
import { api, clearToken } from "@/lib/api";
export default function Onboarding() {
const r = useRouter();
const [form, setForm] = useState({ name: "", gstin: "", phone: "",address: "" });
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const [ready, setReady] = useState(false);
// If the user already has an org (or is a product admin), don't show onboarding.
useEffect(() => {
api("/me")
.then((me) => {
if (me.is_product_admin) r.replace("/admin");
else if (me.has_org) r.replace("/dashboard");
else setReady(true);
})
.catch(() => { clearToken(); r.replace("/login"); });
}, [r]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim()) { setErr("Organization name is required."); return; }
setErr(""); setBusy(true);
try {
await api("/organization", { method: "POST", body: JSON.stringify(form) });
r.push("/dashboard");
} catch (e: any) {
// Already has an org → just go to the dashboard.
if (e.status === 409) { r.replace("/dashboard"); return; }
setErr(typeof e.detail === "string" ? e.detail : "Failed to create organization");
} finally { setBusy(false); }
}
const set = (k: string) => (e: any) => setForm({ ...form, [k]: e.target.value });
if (!ready)
return <div className="min-h-screen grid place-items-center text-slate-400">Loading</div>;
return (
<div className="min-h-screen grid place-items-center bg-slate-50 px-4">
<div className="w-full max-w-md">
<div className="flex items-center justify-center gap-2 font-extrabold text-xl mb-2">
<span className="h-9 w-9 rounded-lg bg-brand-600 text-white grid place-items-center">
<ShieldHalf className="h-5 w-5" />
</span>
VerifyPack
</div>
<div className="card p-8">
<h1 className="text-2xl font-extrabold text-center">Create your organization</h1>
<p className="text-slate-500 text-sm text-center mt-1">
Set up your brand workspace to get started.
</p>
<form onSubmit={submit} className="mt-6 space-y-4">
<Field
label="Organization name"
value={form.name}
onChange={set("name")}
required
placeholder="e.g. ABC Pharma Pvt Ltd"
/>
<Field
label="GSTIN (optional)"
value={form.gstin}
onChange={set("gstin")}
placeholder="29ABCDE1234F1Z5"
/>
<Field
label="Phone (optional)"
value={form.phone}
onChange={set("phone")}
/>
<Field
label="Address (optional)"
value={form.address}
onChange={set("address")}
placeholder="Enter organization address"
/>
{err && <p className="text-sm text-rose-600">{err}</p>}
<button
disabled={busy}
className="w-full bg-brand-600 text-white py-3 rounded-full font-semibold disabled:opacity-60"
>
{busy ? "Creating…" : "Create & continue"}
</button>
</form>
</div>
</div>
</div>
);
}
function Field({ label, ...props }: any) {
return (
<label className="block">
<span className="text-sm font-medium text-slate-700">{label}</span>
<input {...props}
className="mt-1 w-full px-3 py-2.5 rounded-lg border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500/30" />
</label>
);
}

94
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,94 @@
import Link from "next/link";
import { ShieldHalf, QrCode, BarChart3, CheckCircle2 } from "lucide-react";
import { inr } from "@/lib/utils";
const PLANS = [
{ name: "Free", price: 0, products: "1 product", users: "1 user" },
{ name: "Starter", price: 999, products: "100 products", users: "3 users", popular: true },
{ name: "Growth", price: 2999, products: "1,000 products", users: "15 users" },
{ name: "Business", price: 5999, products: "Unlimited products", users: "50 users" },
];
export default function Landing() {
return (
<div className="min-h-screen bg-white">
<header className="flex items-center justify-between px-6 md:px-12 py-5 border-b border-slate-100">
<div className="flex items-center gap-2 font-extrabold text-xl">
<ShieldHalf className="h-6 w-6 text-brand-600" /> VerifyPack
</div>
<div className="flex items-center gap-3">
<Link href="/login" className="text-sm font-medium text-slate-600">Login</Link>
<Link href="/signup" className="text-sm font-semibold bg-brand-600 text-white px-4 py-2 rounded-lg">
Get Started
</Link>
</div>
</header>
<section className="max-w-5xl mx-auto text-center px-6 py-20">
<span className="inline-block text-xs font-semibold text-brand-600 bg-brand-50 px-3 py-1 rounded-full">
Digital Trust for Every Product
</span>
<h1 className="text-4xl md:text-6xl font-extrabold mt-6 leading-tight">
Verify product authenticity<br />with a single scan
</h1>
<p className="text-slate-500 text-lg mt-5 max-w-2xl mx-auto">
Generate unlimited QR codes for your product batches. Let consumers verify
authenticity instantly no app, no login.
</p>
<div className="flex gap-3 justify-center mt-8">
<Link href="/signup" className="bg-brand-600 text-white px-6 py-3 rounded-xl font-semibold">
Start free
</Link>
<Link href="/v/demo" className="border border-slate-200 px-6 py-3 rounded-xl font-semibold">
See a verification page
</Link>
</div>
</section>
<section className="max-w-6xl mx-auto grid md:grid-cols-3 gap-6 px-6 pb-16">
{[
{ icon: QrCode, t: "Unlimited QR codes", d: "Batch QR or unique per-pack serialized QRs. Zero per-code cost." },
{ icon: ShieldHalf, t: "Anti-counterfeit", d: "Consumers scan to confirm genuine, recalled, or unverified status." },
{ icon: BarChart3, t: "Live analytics", d: "Track scans by location, device and time across every product." },
].map(({ icon: Icon, t, d }) => (
<div key={t} className="card p-6">
<Icon className="h-8 w-8 text-brand-600" />
<h3 className="font-bold mt-4">{t}</h3>
<p className="text-slate-500 text-sm mt-2">{d}</p>
</div>
))}
</section>
<section className="max-w-6xl mx-auto px-6 py-16">
<h2 className="text-3xl font-extrabold text-center">Simple, transparent pricing</h2>
<p className="text-center text-slate-500 mt-2">QR generation is always unlimited & free. You only pay for product capacity.</p>
<div className="grid md:grid-cols-4 gap-5 mt-10">
{PLANS.map((p) => (
<div key={p.name}
className={`card p-6 ${p.popular ? "ring-2 ring-brand-500" : ""}`}>
{p.popular && <span className="text-xs font-bold text-brand-600">MOST POPULAR</span>}
<h3 className="font-bold text-lg mt-1">{p.name}</h3>
<div className="text-3xl font-extrabold mt-2">
{p.price === 0 ? "Free" : inr(p.price)}
{p.price > 0 && <span className="text-sm font-normal text-slate-400">/mo</span>}
</div>
<ul className="mt-4 space-y-2 text-sm text-slate-600">
<li className="flex gap-2"><CheckCircle2 className="h-4 w-4 text-emerald-500" />{p.products}</li>
<li className="flex gap-2"><CheckCircle2 className="h-4 w-4 text-emerald-500" />{p.users}</li>
<li className="flex gap-2"><CheckCircle2 className="h-4 w-4 text-emerald-500" />Unlimited QR codes</li>
</ul>
<Link href="/signup"
className="block text-center mt-6 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold">
Choose {p.name}
</Link>
</div>
))}
</div>
</section>
<footer className="border-t border-slate-100 py-8 text-center text-sm text-slate-400">
© {new Date().getFullYear()} VerifyPack. All rights reserved.
</footer>
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { Suspense, useState } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import AuthShell, { Field } from "@/components/AuthShell";
import { api } from "@/lib/api";
function Inner() {
const sp = useSearchParams();
const r = useRouter();
const token = sp.get("token") || "";
const [password, setPassword] = useState("");
const [done, setDone] = useState(false);
const [err, setErr] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
try {
await api("/auth/reset-password", { method: "POST", body: JSON.stringify({ token, password }) });
setDone(true);
setTimeout(() => r.push("/login"), 1500);
} catch (e: any) {
setErr(typeof e.detail === "string" ? e.detail : "Reset failed");
}
}
return (
<AuthShell title="Reset password">
{done ? (
<p className="text-emerald-600"> Password updated. Redirecting to login</p>
) : (
<form onSubmit={submit}>
<Field label="New password" type="password" value={password}
onChange={(e: any) => setPassword(e.target.value)} required />
{err && <p className="text-sm text-rose-600 mb-3">{err}</p>}
<button className="w-full bg-brand-600 text-white py-2.5 rounded-lg font-semibold">Reset password</button>
</form>
)}
<Link href="/login" className="text-brand-600 text-sm mt-4 inline-block">Back to login</Link>
</AuthShell>
);
}
export default function Reset() {
return <Suspense><Inner /></Suspense>;
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import AuthShell, { Field } from "@/components/AuthShell";
import { GoogleButton, OrDivider } from "@/components/GoogleAuth";
import { api, setToken } from "@/lib/api";
export default function Signup() {
const r = useRouter();
const [form, setForm] = useState({ name: "", email: "", password: "" });
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setErr(""); setLoading(true);
try {
const res = await api("/auth/signup", { method: "POST", body: JSON.stringify(form) });
setToken(res.access_token);
r.push(`/verify-email?email=${encodeURIComponent(form.email)}`);
} catch (e: any) {
setErr(typeof e.detail === "string" ? e.detail : "Signup failed");
} finally { setLoading(false); }
}
const set = (k: string) => (e: any) => setForm({ ...form, [k]: e.target.value });
return (
<AuthShell title="Sign up" subtitle="Create a free account">
<GoogleButton label="Sign up with Google" />
<OrDivider text="Or, sign up with your email" />
<form onSubmit={submit}>
<Field label="Full name" value={form.name} onChange={set("name")} required />
<Field label="Email" type="email" value={form.email} onChange={set("email")} required />
<Field label="Password" type="password" value={form.password} onChange={set("password")} required />
{err && <p className="text-sm text-rose-600 mb-3">{err}</p>}
<button disabled={loading}
className="w-full bg-brand-600 text-white py-3 rounded-full font-semibold disabled:opacity-60">
{loading ? "Creating..." : "Sign up"}
</button>
</form>
<p className="mt-5 text-sm text-center text-slate-500">
Already have an account? <Link href="/login" className="text-brand-600 font-semibold">Log In</Link>
</p>
</AuthShell>
);
}

View File

@@ -0,0 +1,513 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
import {
ShieldCheck, AlertTriangle, XCircle, Ban, Star, Globe, Building2,
Recycle, Flag, Phone, Share2, Download, ShieldHalf,Info,
} from "lucide-react";
import { T, LANGS, detectLang, type Lang } from "@/lib/i18n";
import { apiBase } from "@/lib/api";
import { Pill, Package } from "lucide-react";
import { Mail } from "lucide-react";
type State = "genuine" | "unverified" | "not_found" | "recalled";
const STYLES: Record<State, { bg: string; ring: string; icon: any; color: string }> = {
genuine: { bg: "bg-emerald-500", ring: "ring-emerald-200", icon: ShieldCheck, color: "text-emerald-600" },
unverified: { bg: "bg-orange-500", ring: "ring-orange-200", icon: AlertTriangle, color: "text-orange-600" },
not_found: { bg: "bg-rose-500", ring: "ring-rose-200", icon: XCircle, color: "text-rose-600" },
recalled: { bg: "bg-rose-600", ring: "ring-rose-200", icon: Ban, color: "text-rose-700" },
};
export default function VerifyPage() {
const { qr_code } = useParams<{ qr_code: string }>();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [lang, setLang] = useState<Lang>("en");
const logged = useRef(false);
const now = new Date();
const scanDate = now.toLocaleDateString();
const scanTime = now.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
useEffect(() => { setLang(detectLang()); }, []);
useEffect(() => {
fetch(`${apiBase}/verify/${qr_code}`)
.then((r) => r.json())
.then((d) => {
setData(d);
// Log exactly one scan per real page view. The ref guard prevents
// React Strict Mode's double-mount from logging twice in dev.
if (!logged.current && d?.state && d.state !== "not_found") {
logged.current = true;
fetch(`${apiBase}/scans/log?qr_code=${encodeURIComponent(qr_code)}`, {
method: "POST",
}).catch(() => {});
}
})
.catch(() => setData({ state: "not_found" }))
.finally(() => setLoading(false));
}, [qr_code]);
const t = T[lang];
const state: State = data?.state ?? "not_found";
const cfg = STYLES[state];
const Icon = cfg.icon;
const product = data?.product ?? {};
const brand = data?.brand;
const batch = data?.batch;
const trust = data?.trust_score ?? product.trust_score ?? 0;
const stars = Math.round((trust / 100) * 5);
const compliance = (product.compliance ?? []).filter((c: any) => c?.type);
const stateMsg: Record<State, string> = {
genuine: t.genuine_msg, unverified: t.unverified_msg,
not_found: t.not_found_msg, recalled: t.recalled_msg,
};
if (loading)
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="w-full max-w-md p-6 space-y-4">
<div className="skeleton h-40 rounded-2xl" />
<div className="skeleton h-24 rounded-2xl" />
<div className="skeleton h-24 rounded-2xl" />
</div>
</div>
);
return (
<div className="min-h-screen bg-slate-50">
<div className="max-w-md mx-auto pb-10 print-container">
{/* Brand header + language selector */}
<div className="flex items-center justify-between px-5 py-4 bg-white">
<div className="flex items-center gap-2">
{brand?.logo_url
? <img src={brand.logo_url} alt="" className="h-8 w-8 rounded-lg object-cover" />
: <div className="h-8 w-8 rounded-lg bg-brand-600 grid place-items-center text-white"><ShieldHalf className="h-4 w-4" /></div>}
<span className="font-bold text-slate-800">{brand?.name ?? "VerifyPack"}</span>
</div>
<div className="no-print">
<div className="flex items-center gap-1">
<Globe className="h-4 w-4 text-slate-400" />
<select value={lang} onChange={(e) => setLang(e.target.value as Lang)}
className="text-sm bg-transparent focus:outline-none text-slate-600">
{LANGS.map((l) => <option key={l.code} value={l.code}>{l.label}</option>)}
</select>
</div></div>
</div>
{/* Status hero */}
<div className={`mx-4 mt-4 rounded-2xl p-6 text-white ${cfg.bg}`}>
<div className="flex items-center gap-4">
{/* Icon */}
<div
className={`h-16 w-16 flex-shrink-0 rounded-full bg-white/20 grid place-items-center ring-4 ${cfg.ring}`}
>
<Icon className="h-8 w-8" />
</div>
{/* Content */}
<div className="flex-1">
<h1 className="text-xl font-extrabold">{t[state]}</h1>
<p className="text-white/90 text-sm mt-1">
{stateMsg[state]}
</p>
{/* Scan Date & Time */}
<div className="mt-3 inline-flex items-center rounded-lg border border-green-300 px-3 py-1 text-xs font-medium text-green-100">
{t.scan_on}: {scanDate} {scanTime}
</div>
</div>
</div>
</div>
{state !== "not_found" && (
<>
{/* Product */}
<div className="mx-4 mt-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex gap-4">
{/* Product Image */}
<div className="h-28 w-28 flex-shrink-0 rounded-xl border border-slate-200 bg-slate-50 p-2 flex items-center justify-center">
{product.image_url ? (
<img
src={product.image_url}
alt={product.name}
className="h-full w-full object-contain"
/>
) : (
<div className="h-full w-full rounded-lg bg-slate-100" />
)}
</div>
{/* Product Details */}
<div className="flex flex-1 flex-col justify-center">
{/* Product Name */}
<h2 className="text-2xl font-bold text-slate-900">
{product.name}
</h2>
{/* Brand */}
<p className="mt-1 text-base font-semibold text-blue-600">
{brand?.name ?? "VerifyPack"}
</p>
{/* Category */}
<div className="mt-3">
<span className="inline-flex rounded-lg border border-blue-200 bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700">
{product.category}
</span>
</div>
{/* Bottom Details */}
<div className="mt-4 flex items-center gap-8 text-slate-600">
<div className="flex items-center gap-2">
<Pill className="h-4 w-4" />
<span className="text-sm">
{product.details?.form_type || "-"}
</span>
</div>
<div className="flex items-center gap-2">
<Package className="h-4 w-4" />
<span className="text-sm font-medium">
{product.details?.net_weight || "-"}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Trust score */}
{trust > 0 && (
<div className="mx-4 mt-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex items-center gap-4">
{/* Score Circle */}
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-green-50 border border-green-200">
<div className="text-center">
<div className="text-3xl font-bold text-green-600">{trust}</div>
<div className="text-xs text-slate-500">/100</div>
</div>
</div>
{/* Score Details */}
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-800">{t.trust_score}</h3>
<Info className="h-4 w-4 text-slate-400" />
</div>
<div className="mt-2 flex items-center gap-4">
<span className="font-semibold text-green-600">
{trust >= 90
? t.excellent
: trust >= 75
? t.good
: trust >= 50
? t.average
: t.low}
</span>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`h-5 w-5 ${
i < stars
? "fill-green-500 text-green-500"
: "text-slate-300"
}`}
/>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* Batch info */}
{batch && (
<div className="mx-4 mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="grid grid-cols-3">
<div className="border-r border-b p-3">
<p className="text-[11px] text-slate-500">{t.sku}</p>
<p className="mt-1 text-sm font-semibold text-slate-900">
{product.sku || "-"}
</p>
</div>
<div className="border-r border-b p-3">
<p className="text-[11px] text-slate-500">{t.batch_no}</p>
<p className="mt-1 text-sm font-semibold text-slate-900 truncate">
{batch.number}
</p>
</div>
<div className="border-b p-3">
<p className="text-[11px] text-slate-500">{t.mfg_date}
</p>
<p className="mt-1 text-sm font-semibold text-slate-900">
{fmt(batch.mfg_date)}
</p>
</div>
<div className="border-r p-3">
<p className="text-[11px] text-slate-500">{t.expiry_date}
</p>
<p className="mt-1 text-sm font-semibold text-slate-900">
{fmt(batch.expiry_date)}
</p>
</div>
<div className="border-r p-3">
<p className="text-[11px] text-slate-500">{t.net_weight}</p>
<p className="mt-1 text-sm font-semibold text-slate-900">
{product.details?.net_weight || "-"}
</p>
</div>
<div className="p-3">
<p className="text-[11px] text-slate-500">{t.gtin}</p>
<p className="mt-1 text-sm font-semibold text-slate-900 truncate">
{product.details?.gtin || "-"}
</p>
</div>
</div>
</div>
)}
{/* Manufacturer */}
{product.manufacturer?.company && (
<div className="mx-4 mt-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-start gap-4">
{/* Icon */}
<div className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-blue-50">
<Building2 className="h-7 w-7 text-blue-600" />
</div>
{/* Details */}
<div className="flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{t.manufactured_by}
</p>
<div className="mt-1 flex items-center gap-2">
<h3 className="text-lg font-bold text-slate-900">
{product.manufacturer.company}
</h3>
<ShieldCheck className="h-5 w-5 text-green-500 fill-green-500" />
</div>
{product.manufacturer.plant && (
<p className="mt-2 text-sm text-slate-700">
<span className="font-medium">{t.plant}</span>{" "}
{product.manufacturer.plant}
</p>
)}
{product.manufacturer.country && (
<p className="mt-1 text-sm text-slate-700">
<span className="font-medium">{t.country_origin}</span>{" "}
{product.manufacturer.country}
</p>
)}
</div>
</div>
{/* Actions */}
<div className="mt-4 grid grid-cols-3 gap-2">
<button
disabled={!product.manufacturer?.website}
onClick={() =>
window.open(product.manufacturer.website, "_blank")
}
className="flex items-center justify-center gap-2 rounded-lg border py-2 text-sm disabled:opacity-40"
>
<Globe className="h-4 w-4 text-blue-600" />
{t.website}
</button>
<button
disabled={!product.manufacturer?.email}
onClick={() =>
window.location.href = `mailto:${product.manufacturer.email}`
}
className="flex items-center justify-center gap-2 rounded-lg border py-2 text-sm disabled:opacity-40"
>
<Mail className="h-4 w-4 text-indigo-600" />
{t.email}
</button>
<button
disabled={!product.manufacturer?.phone}
onClick={() =>
window.location.href = `tel:${product.manufacturer.phone}`
}
className="flex items-center justify-center gap-2 rounded-lg border py-2 text-sm disabled:opacity-40"
>
<Phone className="h-4 w-4 text-green-600" />
{t.call}
</button>
</div>
</div>
)}
{/* Compliance badges */}
{compliance.length > 0 && (
<Section title={t.compliance}>
<div className="flex flex-wrap gap-2">
{compliance.map((c: any, i: number) => (
<span key={i} className="text-xs font-semibold bg-emerald-50 text-emerald-700 px-3 py-1.5 rounded-full">
{c.type}{c.number ? ` · ${c.number}` : ""}
</span>
))}
</div>
</Section>
)}
{/* Recycling */}
<div className="mx-4 mt-4 rounded-2xl border border-green-200 bg-green-50 p-4">
<div className="flex items-start gap-3">
<Recycle className="mt-1 h-6 w-6 text-green-600" />
<div>
<h3 className="font-semibold text-green-800">
{t.recycling_info}
</h3>
<p className="mt-1 text-sm text-green-700">
{product.recycling_info || t.recycling_info}
</p>
</div>
</div>
</div>
{/* Actions */}
<div className="mx-4 mt-5 grid grid-cols-2 gap-3">
<Action
icon={<Flag className="h-5 w-5 text-red-500" />}
label="Report Issue"
/>
<Action
icon={<Phone className="h-5 w-5 text-blue-500" />}
label="Contact Brand"
/>
<Action
icon={<Share2 className="h-5 w-5 text-purple-600" />}
label="Share Product"
onClick={() =>
navigator.share?.({
title: product.name,
url: location.href,
})
}
/>
<Action
icon={<Download className="h-5 w-5 text-green-600" />}
label="Download Info"
onClick={() => {
document.title = `${product.name}-${batch?.number || "Product"}`;
window.print();
}}
/>
</div>
</>
)}
<div className="mt-8 border-t border-slate-200 pt-5">
<div className="flex items-center justify-center gap-2 text-sm text-slate-500">
<span>Powered by</span>
<ShieldCheck className="h-5 w-5 text-blue-600 fill-blue-600" />
<span className="font-bold text-blue-700">
VerifyPack
</span>
</div>
<div className="mt-3 flex justify-center gap-3 text-xs text-slate-500">
<button className="hover:text-blue-600">
{t.privacy_policy}
</button>
<span>|</span>
<button className="hover:text-blue-600">
{t.terms_of_use}
</button>
<span>|</span>
<button className="hover:text-blue-600">
{t.help}
</button>
</div>
<p className="mt-3 text-center text-xs text-slate-400">
© {new Date().getFullYear()} VerifyPack. All rights reserved.
</p>
</div>
</div>
</div>
);
}
function fmt(d?: string) {
if (!d || d === "None") return "—";
const dt = new Date(d);
return isNaN(+dt) ? d : dt.toLocaleDateString();
}
function Section({ title, icon, children }: any) {
return (
<div className="mx-4 mt-4 card p-5">
<h3 className="font-semibold text-slate-800 text-sm flex items-center gap-2 mb-3">{icon}{title}</h3>
<div className="space-y-2">{children}</div>
</div>
);
}
function Row({ label, value }: { label: string; value?: string }) {
if (!value || value === "None") return null;
return (
<div className="flex justify-between text-sm">
<span className="text-slate-500">{label}</span>
<span className="font-medium text-slate-800">{value}</span>
</div>
);
}
function Action({ icon, label, onClick }: any) {
return (
<button
onClick={onClick}
className="flex flex-col items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white py-4 shadow-sm transition hover:border-blue-300 hover:shadow-md"
>
{icon}
<span className="text-sm font-medium text-slate-700">
{label}
</span>
</button>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import AuthShell from "@/components/AuthShell";
import { api } from "@/lib/api";
function Inner() {
const sp = useSearchParams();
const r = useRouter();
const token = sp.get("token");
const email = sp.get("email") ?? "";
const [code, setCode] = useState("");
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false);
const [linkState, setLinkState] = useState<"idle" | "ok" | "err">("idle");
// If arriving via the email link, verify by token automatically.
useEffect(() => {
if (token) {
api(`/auth/verify-email?token=${token}`)
.then(() => { setLinkState("ok"); setTimeout(() => r.push("/onboarding"), 1200); })
.catch(() => setLinkState("err"));
}
}, [token, r]);
async function submit(e: React.FormEvent) {
e.preventDefault();
setErr(""); setBusy(true);
try {
await api("/auth/verify-code", { method: "POST", body: JSON.stringify({ email, code }) });
r.push("/onboarding");
} catch (e: any) {
setErr(typeof e.detail === "string" ? e.detail : "Verification failed");
} finally { setBusy(false); }
}
async function resend() {
setMsg(""); setErr("");
await api("/auth/resend-code", { method: "POST", body: JSON.stringify({ email }) }).catch(() => {});
setMsg("A new code has been sent.");
}
// Token-link flow display
if (token) {
return (
<AuthShell title="Verifying your email">
{linkState === "ok" && <p className="text-emerald-600 font-medium"> Verified. Redirecting</p>}
{linkState === "err" && <p className="text-rose-600">This link is invalid or expired. Enter your code below instead.</p>}
{linkState === "idle" && <p className="text-slate-500">Verifying</p>}
<Link href="/login" className="text-brand-600 text-sm mt-4 inline-block">Back to login</Link>
</AuthShell>
);
}
return (
<AuthShell title="Verify your email" subtitle={email ? `We sent a 6-digit code to ${email}` : "Enter the 6-digit code we emailed you"}>
<form onSubmit={submit}>
<label className="block mb-4">
<span className="text-sm font-medium text-slate-700">Verification code</span>
<input value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
inputMode="numeric" placeholder="123456"
className="mt-1 w-full text-center tracking-[0.5em] text-lg px-3 py-3 rounded-lg border border-slate-200 focus:outline-none focus:ring-2 focus:ring-brand-500/30" />
</label>
{err && <p className="text-sm text-rose-600 mb-3">{err}</p>}
{msg && <p className="text-sm text-emerald-600 mb-3">{msg}</p>}
<button disabled={busy || code.length < 6}
className="w-full bg-brand-600 text-white py-3 rounded-full font-semibold disabled:opacity-50">
{busy ? "Verifying…" : "Verify email"}
</button>
</form>
<div className="flex justify-between mt-4 text-sm">
<button onClick={resend} className="text-brand-600">Resend code</button>
<Link href="/login" className="text-slate-500">Back to login</Link>
</div>
<p className="text-xs text-slate-400 mt-4">
In local dev (MOCK_EMAIL), the code is printed in the backend console.
</p>
</AuthShell>
);
}
export default function VerifyEmail() {
return <Suspense><Inner /></Suspense>;
}