- Add admin-next/: full Next.js + Prisma + shadcn admin panel (45 API routes, 76 components) - docker-compose: tg_shop_admin service (port 3000, shared db/shop.db, prisma), bot talks to it via ADMIN_CHAT_URL=http://tg_shop_admin:3000/api/chat - tor-proxy now proxies onion admin to tg_shop_admin:3000 (new panel) - chatbotService: ADMIN_CHAT_URL default = http://tg_shop_admin:3000/api/chat (removed localhost:3100 anachronism) - .env admin secrets gitignored (admin-next/.env)
910 lines
36 KiB
TypeScript
Executable File
910 lines
36 KiB
TypeScript
Executable File
"use client";
|
|
|
|
import { useEffect, useState, useCallback, useRef } from "react";
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { ActivityFeed } from "@/components/layout/activity-feed";
|
|
|
|
import {
|
|
Users,
|
|
Package,
|
|
ShoppingCart,
|
|
DollarSign,
|
|
TrendingUp,
|
|
Percent,
|
|
CheckCircle,
|
|
Clock,
|
|
XCircle,
|
|
Tag,
|
|
RefreshCw,
|
|
ShieldBan,
|
|
Wallet,
|
|
ArrowRight,
|
|
} from "lucide-react";
|
|
|
|
import {
|
|
ResponsiveContainer,
|
|
AreaChart,
|
|
Area,
|
|
BarChart,
|
|
Bar,
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
Legend,
|
|
} from "recharts";
|
|
|
|
// Chart colors
|
|
const CHART_1 = "#f97316";
|
|
const CHART_2 = "#06b6d4";
|
|
const CHART_3 = "#8b5cf6";
|
|
const CHART_4 = "#eab308";
|
|
const CHART_5 = "#ec4899";
|
|
const PIE_COLORS = [CHART_1, CHART_2, CHART_3, CHART_4, CHART_5];
|
|
|
|
// ─── Types ───────────────────────────────────────────────
|
|
|
|
interface RecentPurchase {
|
|
username: string;
|
|
productName: string;
|
|
totalPrice: number;
|
|
status: string;
|
|
purchaseDate: string;
|
|
}
|
|
|
|
interface DashboardStats {
|
|
totalUsers: number;
|
|
totalProducts: number;
|
|
totalPurchases: number;
|
|
totalRevenue: number;
|
|
totalSubcategories: number;
|
|
aov: number;
|
|
conversionRate: number;
|
|
completedPurchases: number;
|
|
pendingPurchases: number;
|
|
cancelledPurchases: number;
|
|
bannedUsers: number;
|
|
activeWallets: number;
|
|
}
|
|
|
|
interface ChartData {
|
|
days: string[];
|
|
revenueData: number[];
|
|
usersData: number[];
|
|
days30: string[];
|
|
revenueData30: number[];
|
|
}
|
|
|
|
interface TopProduct {
|
|
name: string;
|
|
qty: number;
|
|
revenue: number;
|
|
}
|
|
|
|
interface TopSpender {
|
|
username: string;
|
|
spent: number;
|
|
}
|
|
|
|
interface RevenueByCategory {
|
|
name: string;
|
|
value: number;
|
|
}
|
|
|
|
interface TopCountry {
|
|
country: string;
|
|
productCount: number;
|
|
}
|
|
|
|
interface WalletSummary {
|
|
walletType: string;
|
|
count: number;
|
|
totalBalance: number;
|
|
totalBalanceUsd: number;
|
|
}
|
|
|
|
interface DashboardData {
|
|
stats: DashboardStats;
|
|
chartData: ChartData;
|
|
topProducts: TopProduct[];
|
|
topSpenders: TopSpender[];
|
|
revenueByCategory: RevenueByCategory[];
|
|
topCountries: TopCountry[];
|
|
walletSummary: WalletSummary[];
|
|
recentPurchases: RecentPurchase[];
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────
|
|
|
|
function formatCurrency(val: number): string {
|
|
return `$${val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
}
|
|
|
|
function relativeTime(dateStr: string): string {
|
|
const now = Date.now();
|
|
const then = new Date(dateStr).getTime();
|
|
const diffMs = now - then;
|
|
const diffMin = Math.floor(diffMs / 60000);
|
|
const diffHr = Math.floor(diffMs / 3600000);
|
|
const diffDay = Math.floor(diffMs / 86400000);
|
|
if (diffMin < 1) return 'just now';
|
|
if (diffMin < 60) return `${diffMin}m ago`;
|
|
if (diffHr < 24) return `${diffHr}h ago`;
|
|
if (diffDay < 7) return `${diffDay}d ago`;
|
|
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
}
|
|
|
|
function statusBadge(status: string): { label: string; cls: string } {
|
|
switch (status) {
|
|
case 'completed':
|
|
return { label: 'Completed', cls: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' };
|
|
case 'pending':
|
|
return { label: 'Pending', cls: 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400' };
|
|
case 'cancelled':
|
|
return { label: 'Cancelled', cls: 'bg-red-500/15 text-red-600 dark:text-red-400' };
|
|
default:
|
|
return { label: status, cls: 'bg-muted text-muted-foreground' };
|
|
}
|
|
}
|
|
|
|
function formatCrypto(val: number): string {
|
|
return val.toFixed(8);
|
|
}
|
|
|
|
function shortDate(dateStr: string): string {
|
|
const d = new Date(dateStr + "T00:00:00");
|
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
}
|
|
|
|
// ─── Mini Sparkline ─────────────────────────────────────
|
|
|
|
function MiniSparkline({ data, color }: { data: number[]; color: string }) {
|
|
if (data.length < 2) return null;
|
|
const chartData = data.map((v, i) => ({ i, v }));
|
|
return (
|
|
<div className="absolute bottom-0 left-0 right-0 h-12 opacity-20 pointer-events-none">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
|
|
<YAxis domain={["dataMin - 2", "dataMax + 2"]} hide />
|
|
<Area type="monotone" dataKey="v" stroke={color} fill={color} strokeWidth={1.5} />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function generateSparkData(value: number, points: number = 8): number[] {
|
|
const data: number[] = [];
|
|
let current = value * 0.6;
|
|
for (let i = 0; i < points; i++) {
|
|
current += (value - current) * (0.2 + Math.random() * 0.3);
|
|
data.push(Math.round(current * 10) / 10);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// ─── KPI Card ────────────────────────────────────────────
|
|
|
|
function KpiCard({
|
|
title,
|
|
value,
|
|
icon: Icon,
|
|
color,
|
|
sparklineColor,
|
|
sparklineValue,
|
|
}: {
|
|
title: string;
|
|
value: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
color: string;
|
|
sparklineColor?: string;
|
|
sparklineValue?: number;
|
|
}) {
|
|
const sparkData = sparklineValue !== undefined ? generateSparkData(sparklineValue) : undefined;
|
|
return (
|
|
<Card className="card-hover kpi-shimmer border-l-4 transition-transform hover:scale-[1.02] relative overflow-hidden" style={{ borderLeftColor: color }}>
|
|
<div
|
|
className="h-[2px] w-full rounded-t-lg"
|
|
style={{
|
|
background: `linear-gradient(to right, ${color}, ${color}66, transparent)`,
|
|
}}
|
|
/>
|
|
<CardContent className="p-4 flex items-center gap-4">
|
|
<div
|
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
|
|
style={{ backgroundColor: `${color}15` }}
|
|
>
|
|
<Icon className="h-6 w-6" style={{ color }} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm text-muted-foreground truncate">{title}</p>
|
|
<p className="text-xl font-bold truncate tabular-nums stat-value count-up">{value}</p>
|
|
</div>
|
|
</CardContent>
|
|
{sparkData && sparklineColor && <MiniSparkline data={sparkData} color={sparklineColor} />}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── Skeleton Loader ─────────────────────────────────────
|
|
|
|
function DashboardSkeleton() {
|
|
return (
|
|
<div className="p-4 md:p-6 space-y-6">
|
|
<Skeleton className="h-8 w-48" />
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
{Array.from({ length: 10 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-24 rounded-xl" />
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-72 rounded-xl" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Chart Card wrapper ──────────────────────────────────
|
|
|
|
function ChartCard({
|
|
title,
|
|
children,
|
|
accentColor,
|
|
icon: ChartIcon,
|
|
}: {
|
|
title: string;
|
|
children: React.ReactNode;
|
|
accentColor?: string;
|
|
icon?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
|
}) {
|
|
return (
|
|
<Card className="card-hover overflow-hidden">
|
|
<div
|
|
className="h-1 w-full"
|
|
style={{
|
|
background: `linear-gradient(to right, ${accentColor ?? CHART_1}, ${accentColor ?? CHART_1}44, transparent)`,
|
|
}}
|
|
/>
|
|
<CardHeader className="p-4 pb-0">
|
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
{ChartIcon && <ChartIcon className="size-4" style={{ color: accentColor ?? CHART_1 }} />}
|
|
{title}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4 pt-2">
|
|
<div className="h-72">{children}</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── Main Component ──────────────────────────────────────
|
|
|
|
export function DashboardPage() {
|
|
const [data, setData] = useState<DashboardData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
|
const [lastUpdated, setLastUpdated] = useState<number>(Date.now());
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const autoRefreshRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const fetchDashboard = useCallback(async () => {
|
|
try {
|
|
setRefreshing(true);
|
|
setError(null);
|
|
const res = await fetch("/api/stats/dashboard");
|
|
if (!res.ok) {
|
|
throw new Error("Failed to load dashboard data");
|
|
}
|
|
const json = await res.json();
|
|
setData(json);
|
|
setLastUpdated(Date.now());
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Unknown error");
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchDashboard();
|
|
}, [fetchDashboard]);
|
|
|
|
// Auto-refresh toggle
|
|
useEffect(() => {
|
|
if (autoRefresh) {
|
|
autoRefreshRef.current = setInterval(fetchDashboard, 30000);
|
|
}
|
|
return () => {
|
|
if (autoRefreshRef.current) clearInterval(autoRefreshRef.current);
|
|
};
|
|
}, [autoRefresh, fetchDashboard]);
|
|
|
|
// "X seconds ago" ticker
|
|
const [secondsAgo, setSecondsAgo] = useState(0);
|
|
useEffect(() => {
|
|
const tick = setInterval(() => {
|
|
setSecondsAgo(Math.floor((Date.now() - lastUpdated) / 1000));
|
|
}, 1000);
|
|
return () => clearInterval(tick);
|
|
}, [lastUpdated]);
|
|
|
|
if (loading) return <DashboardSkeleton />;
|
|
if (error) {
|
|
return (
|
|
<div className="p-6">
|
|
<Card className="border-destructive">
|
|
<CardContent className="p-6">
|
|
<p className="text-destructive font-medium">{error}</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
if (!data) return null;
|
|
|
|
const { stats, chartData, topProducts, topSpenders, revenueByCategory, walletSummary, recentPurchases } = data;
|
|
|
|
// Prepare chart datasets
|
|
const revenue7Data = chartData.days.map((day, i) => ({
|
|
date: shortDate(day),
|
|
revenue: chartData.revenueData[i],
|
|
}));
|
|
|
|
const revenue30Data = chartData.days30.map((day, i) => ({
|
|
date: shortDate(day),
|
|
revenue: chartData.revenueData30[i],
|
|
}));
|
|
|
|
const users7Data = chartData.days.map((day, i) => ({
|
|
date: shortDate(day),
|
|
users: chartData.usersData[i],
|
|
}));
|
|
|
|
const productsData = [...topProducts].reverse(); // reverse for horizontal bar
|
|
|
|
const spendersData = [...topSpenders].reverse();
|
|
|
|
const walletChartData = walletSummary.map((w) => ({
|
|
name: w.walletType,
|
|
count: w.count,
|
|
}));
|
|
|
|
// KPI definitions
|
|
const kpis = [
|
|
{ title: "Total Users", value: stats.totalUsers.toLocaleString(), icon: Users, color: CHART_1, sparklineColor: "#64748b", sparklineValue: stats.totalUsers },
|
|
{ title: "Total Products", value: stats.totalProducts.toLocaleString(), icon: Package, color: CHART_2, sparklineColor: "#64748b", sparklineValue: stats.totalProducts },
|
|
{ title: "Total Purchases", value: stats.totalPurchases.toLocaleString(), icon: ShoppingCart, color: CHART_3, sparklineColor: "#64748b", sparklineValue: stats.totalPurchases },
|
|
{ title: "Pending", value: stats.pendingPurchases.toLocaleString(), icon: Clock, color: "#eab308", sparklineColor: "#eab308", sparklineValue: stats.pendingPurchases },
|
|
{ title: "Total Revenue", value: formatCurrency(stats.totalRevenue), icon: DollarSign, color: "#22c55e", sparklineColor: "#22c55e", sparklineValue: stats.totalRevenue },
|
|
{ title: "Avg Order Value", value: formatCurrency(stats.aov), icon: TrendingUp, color: CHART_1, sparklineColor: "#64748b", sparklineValue: stats.aov },
|
|
{ title: "Conversion Rate", value: `${stats.conversionRate.toFixed(1)}%`, icon: Percent, color: CHART_5, sparklineColor: "#64748b", sparklineValue: stats.conversionRate },
|
|
{ title: "Completed", value: stats.completedPurchases.toLocaleString(), icon: CheckCircle, color: "#22c55e", sparklineColor: "#64748b", sparklineValue: stats.completedPurchases },
|
|
{ title: "Cancelled", value: stats.cancelledPurchases.toLocaleString(), icon: XCircle, color: "#ef4444", sparklineColor: "#64748b", sparklineValue: stats.cancelledPurchases },
|
|
{ title: "Banned Users", value: stats.bannedUsers.toLocaleString(), icon: ShieldBan, color: "#ef4444", sparklineColor: "#ef4444", sparklineValue: stats.bannedUsers },
|
|
{ title: "Active Wallets", value: stats.activeWallets.toLocaleString(), icon: Wallet, color: CHART_2, sparklineColor: "#06b6d4", sparklineValue: stats.activeWallets },
|
|
{ title: "Subcategories", value: stats.totalSubcategories.toLocaleString(), icon: Tag, color: CHART_2, sparklineColor: "#64748b", sparklineValue: stats.totalSubcategories },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6 page-enter">
|
|
{/* ── Page Title ── */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<p className="text-sm text-muted-foreground">Overview of your Telegram Shop</p>
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-xs text-muted-foreground">
|
|
Last updated: {secondsAgo < 1 ? "just now" : `${secondsAgo} seconds ago`}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={fetchDashboard}
|
|
disabled={refreshing}
|
|
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-50"
|
|
aria-label="Refresh dashboard"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="auto-refresh"
|
|
checked={autoRefresh}
|
|
onCheckedChange={setAutoRefresh}
|
|
/>
|
|
<label
|
|
htmlFor="auto-refresh"
|
|
className="text-xs text-muted-foreground cursor-pointer select-none"
|
|
>
|
|
Auto-refresh
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── KPI Cards ── */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
{kpis.map((kpi) => (
|
|
<KpiCard key={kpi.title} {...kpi} icon={kpi.icon} />
|
|
))}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* ── Charts Grid ── */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{/* 1. Revenue 7 days */}
|
|
<ChartCard title="Revenue — Last 7 Days" accentColor={CHART_1} icon={TrendingUp}>
|
|
{revenue7Data.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={revenue7Data}>
|
|
<defs>
|
|
<linearGradient id="rev7grad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={CHART_1} stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor={CHART_1} stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
|
<YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: "8px",
|
|
fontSize: "12px",
|
|
}}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke={CHART_1}
|
|
fill="url(#rev7grad)"
|
|
strokeWidth={2}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
|
|
)}
|
|
</ChartCard>
|
|
|
|
{/* 2. Revenue 30 days */}
|
|
<ChartCard title="Revenue — Last 30 Days" accentColor={CHART_2} icon={TrendingUp}>
|
|
{revenue30Data.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={revenue30Data}>
|
|
<defs>
|
|
<linearGradient id="rev30grad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={CHART_2} stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor={CHART_2} stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
|
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval={4} />
|
|
<YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: "8px",
|
|
fontSize: "12px",
|
|
}}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke={CHART_2}
|
|
fill="url(#rev30grad)"
|
|
strokeWidth={2}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
|
|
)}
|
|
</ChartCard>
|
|
|
|
{/* 3. New Users 7 days */}
|
|
<ChartCard title="New Users — Last 7 Days" accentColor={CHART_3} icon={Users}>
|
|
{users7Data.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={users7Data}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
|
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: "8px",
|
|
fontSize: "12px",
|
|
}}
|
|
/>
|
|
<Bar dataKey="users" fill={CHART_3} radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
|
|
)}
|
|
</ChartCard>
|
|
|
|
{/* 4. Top 5 Products */}
|
|
<ChartCard title="Top 5 Products by Quantity Sold" accentColor={CHART_4} icon={Package}>
|
|
{productsData.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={productsData} layout="vertical" margin={{ left: 20 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
|
|
<XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
|
<YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: "8px",
|
|
fontSize: "12px",
|
|
}}
|
|
formatter={(value: number, name: string) => {
|
|
if (name === "qty") return [value, "Quantity"];
|
|
return [formatCurrency(value), "Revenue"];
|
|
}}
|
|
/>
|
|
<Bar dataKey="qty" fill={CHART_4} radius={[0, 4, 4, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
|
|
)}
|
|
</ChartCard>
|
|
|
|
{/* 5. Top 5 Spenders */}
|
|
<ChartCard title="Top 5 Spenders" accentColor={CHART_5} icon={DollarSign}>
|
|
{spendersData.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={spendersData} layout="vertical" margin={{ left: 20 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
|
|
<XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
|
<YAxis type="category" dataKey="username" width={120} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: "8px",
|
|
fontSize: "12px",
|
|
}}
|
|
formatter={(value: number) => [formatCurrency(value), "Spent"]}
|
|
/>
|
|
<Bar dataKey="spent" fill={CHART_5} radius={[0, 4, 4, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
|
|
)}
|
|
</ChartCard>
|
|
|
|
{/* 6. Revenue by Category (Pie/Donut) */}
|
|
<ChartCard title="Revenue by Category" accentColor={CHART_1} icon={Tag}>
|
|
{revenueByCategory.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={revenueByCategory}
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={50}
|
|
outerRadius={90}
|
|
paddingAngle={2}
|
|
dataKey="value"
|
|
nameKey="name"
|
|
label={({ name, percent }) =>
|
|
`${name} ${(percent * 100).toFixed(0)}%`
|
|
}
|
|
labelLine={true}
|
|
fontSize={11}
|
|
>
|
|
{revenueByCategory.map((_, index) => (
|
|
<Cell
|
|
key={`cell-${index}`}
|
|
fill={PIE_COLORS[index % PIE_COLORS.length]}
|
|
/>
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: "8px",
|
|
fontSize: "12px",
|
|
}}
|
|
formatter={(value: number) => [formatCurrency(value), "Revenue"]}
|
|
/>
|
|
<Legend />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
|
|
)}
|
|
</ChartCard>
|
|
|
|
{/* 7. Purchase Status Distribution */}
|
|
<ChartCard title="Purchase Status Distribution" accentColor="#eab308" icon={ShoppingCart}>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={[
|
|
{ name: 'Pending', value: stats.pendingPurchases },
|
|
{ name: 'Completed', value: stats.completedPurchases },
|
|
{ name: 'Cancelled', value: stats.cancelledPurchases },
|
|
]}
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={55}
|
|
outerRadius={90}
|
|
paddingAngle={3}
|
|
dataKey="value"
|
|
nameKey="name"
|
|
label={({ name, percent }) =>
|
|
`${name} ${(percent * 100).toFixed(0)}%`
|
|
}
|
|
labelLine={true}
|
|
fontSize={11}
|
|
>
|
|
<Cell fill="#eab308" />
|
|
<Cell fill="#10b981" />
|
|
<Cell fill="#ef4444" />
|
|
</Pie>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: "8px",
|
|
fontSize: "12px",
|
|
}}
|
|
formatter={(value: number) => [value, "Purchases"]}
|
|
/>
|
|
<Legend />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</ChartCard>
|
|
</div>
|
|
|
|
{/* ── Analytics Cards: Revenue Trend + User Funnel ── */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{/* Card A: Revenue Trend (30-day area chart) */}
|
|
<Card className="card-hover overflow-hidden md:col-span-2">
|
|
<div
|
|
className="h-1 w-full"
|
|
style={{
|
|
background: `linear-gradient(to right, ${CHART_2}, ${CHART_2}44, transparent)`,
|
|
}}
|
|
/>
|
|
<CardHeader className="p-4 pb-0 flex flex-row items-center gap-2">
|
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
|
<CardTitle className="text-sm font-medium">Revenue Trend</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4 pt-2">
|
|
<div className="h-80">
|
|
{revenue30Data.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={revenue30Data}>
|
|
<defs>
|
|
<linearGradient id="revTrendGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={CHART_2} stopOpacity={0.4} />
|
|
<stop offset="95%" stopColor={CHART_2} stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
|
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval={4} />
|
|
<YAxis tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" tickFormatter={(v: number) => `$${v}`} />
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: "8px",
|
|
fontSize: "12px",
|
|
}}
|
|
formatter={(value: number) => [formatCurrency(value), "Revenue"]}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke={CHART_2}
|
|
fill="url(#revTrendGrad)"
|
|
strokeWidth={2}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">No data</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Card B: Conversion Funnel (horizontal bar) */}
|
|
<Card className="card-hover overflow-hidden md:col-span-1">
|
|
<div
|
|
className="h-1 w-full"
|
|
style={{
|
|
background: `linear-gradient(to right, #64748b, #64748b44, transparent)`,
|
|
}}
|
|
/>
|
|
<CardHeader className="p-4 pb-0 flex flex-row items-center gap-2">
|
|
<Users className="h-4 w-4 text-muted-foreground" />
|
|
<CardTitle className="text-sm font-medium">User Funnel</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4 pt-2">
|
|
<div className="h-80">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart
|
|
data={[
|
|
{ name: "Total Users", value: stats.totalUsers },
|
|
{ name: "Users with Purchases", value: stats.totalPurchases },
|
|
{ name: "Users with Wallets", value: stats.activeWallets },
|
|
]}
|
|
layout="vertical"
|
|
margin={{ left: 10, right: 20 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" horizontal={false} />
|
|
<XAxis type="number" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
|
<YAxis
|
|
type="category"
|
|
dataKey="name"
|
|
width={120}
|
|
tick={{ fontSize: 11 }}
|
|
stroke="hsl(var(--muted-foreground))"
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: "8px",
|
|
fontSize: "12px",
|
|
}}
|
|
/>
|
|
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
|
<Cell fill="#64748b" />
|
|
<Cell fill="#10b981" />
|
|
<Cell fill="#06b6d4" />
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* ── Recent Purchases Table ── */}
|
|
<Card className="card-hover">
|
|
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
|
<CardTitle className="text-sm font-medium">Recent Purchases</CardTitle>
|
|
<button
|
|
type="button"
|
|
onClick={() => { window.location.hash = '#/purchases'; }}
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
View all
|
|
<ArrowRight className="h-3 w-3" />
|
|
</button>
|
|
</CardHeader>
|
|
<CardContent className="p-4">
|
|
{data.recentPurchases.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm alternate-rows table-header-gradient">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="text-left py-2 px-2 font-medium text-muted-foreground text-xs">Product</th>
|
|
<th className="text-left py-2 px-2 font-medium text-muted-foreground text-xs">User</th>
|
|
<th className="text-right py-2 px-2 font-medium text-muted-foreground text-xs">Amount</th>
|
|
<th className="text-center py-2 px-2 font-medium text-muted-foreground text-xs">Status</th>
|
|
<th className="text-right py-2 px-2 font-medium text-muted-foreground text-xs">Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.recentPurchases.map((p, i) => {
|
|
const badge = statusBadge(p.status);
|
|
return (
|
|
<tr key={i} className="border-b last:border-0">
|
|
<td className="py-2 px-2 text-xs font-medium truncate max-w-[140px]">{p.productName}</td>
|
|
<td className="py-2 px-2 text-xs text-muted-foreground truncate max-w-[100px]">{p.username}</td>
|
|
<td className="py-2 px-2 text-xs text-right font-mono tabular-nums">{formatCurrency(p.totalPrice)}</td>
|
|
<td className="py-2 px-2 text-center">
|
|
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${badge.cls}`}>
|
|
{badge.label}
|
|
</span>
|
|
</td>
|
|
<td className="py-2 px-2 text-xs text-right text-muted-foreground whitespace-nowrap">{relativeTime(p.purchaseDate)}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="h-24 flex items-center justify-center text-muted-foreground text-sm">No recent purchases</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Separator />
|
|
|
|
{/* ── Bottom Section: Wallet Summary + Wallet Chart ── */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{/* Wallet Summary Table */}
|
|
<Card className="card-hover">
|
|
<CardHeader className="p-4 pb-0">
|
|
<CardTitle className="text-sm font-medium">Wallet Summary</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4">
|
|
{walletSummary.length > 0 ? (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Type</th>
|
|
<th className="text-right py-2 px-3 font-medium text-muted-foreground">Count</th>
|
|
<th className="text-right py-2 px-3 font-medium text-muted-foreground">Balance</th>
|
|
<th className="text-right py-2 px-3 font-medium text-muted-foreground">USD (mock)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{walletSummary.map((w) => (
|
|
<tr key={w.walletType} className="border-b last:border-0">
|
|
<td className="py-2 px-3 font-medium">{w.walletType}</td>
|
|
<td className="py-2 px-3 text-right text-muted-foreground">{w.count}</td>
|
|
<td className="py-2 px-3 text-right font-mono text-xs">{formatCrypto(w.totalBalance)}</td>
|
|
<td className="py-2 px-3 text-right">{formatCurrency(w.totalBalanceUsd)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="h-48 flex items-center justify-center text-muted-foreground text-sm">No wallet data</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Wallet Count by Type Chart */}
|
|
<ChartCard title="Wallet Count by Type" accentColor={CHART_2} icon={Wallet}>
|
|
{walletChartData.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={walletChartData}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
|
<XAxis dataKey="name" tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
|
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} stroke="hsl(var(--muted-foreground))" />
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: "8px",
|
|
fontSize: "12px",
|
|
}}
|
|
formatter={(value: number) => [value, "Wallets"]}
|
|
/>
|
|
<Bar dataKey="count" fill={CHART_2} radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">No wallet data</div>
|
|
)}
|
|
</ChartCard>
|
|
</div>
|
|
|
|
{/* ── Activity Feed (full width) ── */}
|
|
<ActivityFeed />
|
|
</div>
|
|
);
|
|
}
|