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