Files
verify/frontend/app/dashboard/analytics/page.tsx
Mohamed Mathar Irfan ed6610d5d8 Initial project upload
2026-07-28 17:57:02 +05:30

474 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}