474 lines
15 KiB
TypeScript
474 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|