feat(admin): integrate Next.js admin panel (admin-next/) from feat/nextjs-admin
- 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)
This commit is contained in:
909
admin-next/src/components/dashboard/dashboard-page.tsx
Executable file
909
admin-next/src/components/dashboard/dashboard-page.tsx
Executable file
@@ -0,0 +1,909 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user