1802 lines
70 KiB
TypeScript
1802 lines
70 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/common/card";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/common/tabs";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/common/select";
|
|
import { Button } from "@/components/common/button";
|
|
import {
|
|
AlertCircle,
|
|
BarChart,
|
|
RefreshCw,
|
|
Users,
|
|
ShoppingCart,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
DollarSign,
|
|
Package,
|
|
Trophy,
|
|
PieChart as PieChartIcon,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/common/alert";
|
|
import { fetchClient } from "@/lib/api/api-client";
|
|
import {
|
|
BarChart as RechartsBarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
LineChart,
|
|
Line,
|
|
ComposedChart,
|
|
AreaChart,
|
|
Area,
|
|
} from "recharts";
|
|
import { formatGBP, formatNumber, formatCurrency } from "@/lib/utils/format";
|
|
import { PieChart, Pie, Cell, Legend, Sector } from "recharts";
|
|
import { AdminStatCard } from "./AdminStatCard";
|
|
import { TrendIndicator } from "./TrendIndicator";
|
|
import {
|
|
ChartSkeleton,
|
|
CustomerInsightsSkeleton,
|
|
MetricsCardSkeleton,
|
|
TableSkeleton
|
|
} from "../analytics/SkeletonLoaders";
|
|
|
|
// Format currency for admin analytics
|
|
const formatAdminCurrency = (value: number) => {
|
|
return new Intl.NumberFormat("en-GB", {
|
|
style: "currency",
|
|
currency: "GBP",
|
|
maximumFractionDigits: 0,
|
|
}).format(value);
|
|
};
|
|
|
|
const renderActiveShape = (props: any) => {
|
|
const RADIAN = Math.PI / 180;
|
|
const {
|
|
cx,
|
|
cy,
|
|
midAngle,
|
|
innerRadius,
|
|
outerRadius,
|
|
startAngle,
|
|
endAngle,
|
|
fill,
|
|
payload,
|
|
percent,
|
|
value,
|
|
} = props;
|
|
const sin = Math.sin(-RADIAN * midAngle);
|
|
const cos = Math.cos(-RADIAN * midAngle);
|
|
const sx = cx + (outerRadius + 10) * cos;
|
|
const sy = cy + (outerRadius + 10) * sin;
|
|
const mx = cx + (outerRadius + 30) * cos;
|
|
const my = cy + (outerRadius + 30) * sin;
|
|
const ex = mx + (cos >= 0 ? 1 : -1) * 22;
|
|
const ey = my;
|
|
const textAnchor = cos >= 0 ? "start" : "end";
|
|
|
|
return (
|
|
<g>
|
|
<text x={cx} y={cy} dy={8} textAnchor="middle" fill={fill} className="text-xl font-bold">
|
|
{payload.name ? payload.name.split(" ")[0] : "Product"}
|
|
</text>
|
|
<Sector
|
|
cx={cx}
|
|
cy={cy}
|
|
innerRadius={innerRadius}
|
|
outerRadius={outerRadius + 6}
|
|
startAngle={startAngle}
|
|
endAngle={endAngle}
|
|
fill={fill}
|
|
/>
|
|
<Sector
|
|
cx={cx}
|
|
cy={cy}
|
|
startAngle={startAngle}
|
|
endAngle={endAngle}
|
|
innerRadius={outerRadius + 6}
|
|
outerRadius={outerRadius + 10}
|
|
fill={fill}
|
|
/>
|
|
<path
|
|
d={`M${sx},${sy}L${mx},${my}L${ex},${ey}`}
|
|
stroke={fill}
|
|
fill="none"
|
|
/>
|
|
<circle cx={ex} cy={ey} r={2} fill={fill} stroke="none" />
|
|
<text
|
|
x={ex + (cos >= 0 ? 1 : -1) * 12}
|
|
y={ey}
|
|
textAnchor={textAnchor}
|
|
fill="#888"
|
|
fontSize={12}
|
|
>{`Count ${value}`}</text>
|
|
<text
|
|
x={ex + (cos >= 0 ? 1 : -1) * 12}
|
|
y={ey}
|
|
dy={18}
|
|
textAnchor={textAnchor}
|
|
fill="#999"
|
|
fontSize={10}
|
|
>
|
|
{`(${(percent * 100).toFixed(0)}%)`}
|
|
</text>
|
|
</g>
|
|
);
|
|
};
|
|
|
|
const getSmartInsights = (data?: AnalyticsData | null, growth?: GrowthData | null) => {
|
|
const insights: Array<{
|
|
type: 'positive' | 'neutral' | 'information';
|
|
message: string;
|
|
icon: any;
|
|
color: string;
|
|
bg: string;
|
|
}> = [];
|
|
|
|
if (!data) return insights;
|
|
|
|
// 1. AI Forecast (Always high priority if available)
|
|
if (data.predictions && data.predictions.predicted > 0) {
|
|
insights.push({
|
|
type: 'positive',
|
|
message: `AI Forecast: Projected ${formatAdminCurrency(data.predictions.predicted)} revenue over the next 7 days.`,
|
|
icon: Zap,
|
|
color: 'text-purple-500',
|
|
bg: 'bg-purple-500/10'
|
|
});
|
|
}
|
|
|
|
// 2. Comprehensive Comparisons Data
|
|
if (data.comparisons) {
|
|
const rawInsights: Array<{
|
|
score: number; // Importance score
|
|
type: 'positive' | 'neutral' | 'information';
|
|
message: string;
|
|
icon: any;
|
|
color: string;
|
|
bg: string;
|
|
}> = [];
|
|
|
|
const periods = Object.entries(data.comparisons);
|
|
|
|
periods.forEach(([label, metrics]) => {
|
|
const timeframeName = label === '1w' ? 'last week' :
|
|
label === '2w' ? 'last 2 weeks' :
|
|
label === '1m' ? 'last month' :
|
|
label === '3m' ? 'last 3 months' :
|
|
label === '6m' ? 'last 6 months' :
|
|
label === '1y' ? 'last year' : 'last 6 months';
|
|
|
|
// --- Revenue Insight ---
|
|
if (metrics.revenue.previous > 0) {
|
|
const revDiff = metrics.revenue.current - metrics.revenue.previous;
|
|
const revPercent = (revDiff / metrics.revenue.previous) * 100;
|
|
|
|
if (Math.abs(revPercent) > 5) {
|
|
rawInsights.push({
|
|
score: Math.abs(revPercent) * (label === '1w' ? 1.5 : 1), // Weight recent changes higher
|
|
type: revPercent > 0 ? 'positive' : 'neutral',
|
|
message: `Revenue is ${revPercent > 0 ? 'up' : 'down'} ${Math.abs(revPercent).toFixed(1)}% vs ${timeframeName} (${formatAdminCurrency(Math.abs(revDiff))} difference).`,
|
|
icon: revPercent > 0 ? TrendingUp : TrendingDown,
|
|
color: revPercent > 0 ? 'text-green-500' : 'text-orange-500',
|
|
bg: revPercent > 0 ? 'bg-green-500/10' : 'bg-orange-500/10'
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Order Volume Insight ---
|
|
if (metrics.orders.previous > 0) {
|
|
const ordDiff = metrics.orders.current - metrics.orders.previous;
|
|
const ordPercent = (ordDiff / metrics.orders.previous) * 100;
|
|
|
|
if (Math.abs(ordPercent) > 10) {
|
|
rawInsights.push({
|
|
score: Math.abs(ordPercent) * 0.8,
|
|
type: ordPercent > 0 ? 'positive' : 'neutral',
|
|
message: `Order volume has ${ordPercent > 0 ? 'increased' : 'decreased'} by ${Math.abs(ordPercent).toFixed(0)}% compared to ${timeframeName}.`,
|
|
icon: ShoppingCart,
|
|
color: ordPercent > 0 ? 'text-blue-500' : 'text-slate-400',
|
|
bg: ordPercent > 0 ? 'bg-blue-500/10' : 'bg-slate-400/10'
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- New Customer Insight ---
|
|
if (metrics.customers.previous > 0) {
|
|
const custDiff = metrics.customers.current - metrics.customers.previous;
|
|
const custPercent = (custDiff / metrics.customers.previous) * 100;
|
|
|
|
if (custPercent > 5) {
|
|
rawInsights.push({
|
|
score: custPercent * 1.2,
|
|
type: 'positive',
|
|
message: `New customer acquisition is up ${custPercent.toFixed(1)}% vs ${timeframeName}.`,
|
|
icon: Users,
|
|
color: 'text-emerald-500',
|
|
bg: 'bg-emerald-500/10'
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Sort by importance (score) and pick top ones
|
|
rawInsights.sort((a, b) => b.score - a.score);
|
|
|
|
// Add up to 3 most interesting comparison insights
|
|
rawInsights.slice(0, 3).forEach(ri => {
|
|
insights.push({
|
|
type: ri.type,
|
|
message: ri.message,
|
|
icon: ri.icon,
|
|
color: ri.color,
|
|
bg: ri.bg
|
|
});
|
|
});
|
|
}
|
|
|
|
// 3. Fallback / AI Trends if we have space
|
|
if (insights.length < 3 && data.predictions?.features?.trends) {
|
|
const { growthRate } = data.predictions.features.trends;
|
|
if (growthRate > 0.05) {
|
|
insights.push({
|
|
type: 'positive',
|
|
message: `AI confirms a steady growth trend of ${(growthRate * 100).toFixed(1)}% in the current market.`,
|
|
icon: Zap,
|
|
color: 'text-purple-500',
|
|
bg: 'bg-purple-500/10'
|
|
});
|
|
}
|
|
}
|
|
|
|
// 4. Customer Loyalty Insight (from growth data)
|
|
if (insights.length < 4 && growth?.customers?.segments) {
|
|
const segments = growth.customers.segments;
|
|
const totalActive = segments.new + segments.returning + segments.loyal + segments.vip;
|
|
if (totalActive > 0) {
|
|
const returningRate = (segments.returning + segments.loyal + segments.vip) / totalActive;
|
|
if (returningRate > 0.4) {
|
|
insights.push({
|
|
type: 'information',
|
|
message: `${(returningRate * 100).toFixed(0)}% of your audience consists of returning customers.`,
|
|
icon: Trophy,
|
|
color: 'text-amber-500',
|
|
bg: 'bg-amber-500/10'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure we have at least one message
|
|
if (insights.length === 0) {
|
|
insights.push({
|
|
type: 'neutral',
|
|
message: 'Dashboard data calibrated. Marketplace performance is stable.',
|
|
icon: RefreshCw,
|
|
color: 'text-muted-foreground',
|
|
bg: 'bg-muted/10'
|
|
});
|
|
}
|
|
|
|
return insights.slice(0, 4);
|
|
};
|
|
|
|
interface ComparisonPeriod {
|
|
orders: { current: number; previous: number };
|
|
revenue: { current: number; previous: number };
|
|
customers: { current: number; previous: number };
|
|
}
|
|
|
|
interface GrowthData {
|
|
launchDate: string;
|
|
generatedAt: string;
|
|
daily: Array<{
|
|
date: string;
|
|
orders: number;
|
|
revenue: number;
|
|
customers: number;
|
|
avgOrderValue: number;
|
|
}>;
|
|
monthly: Array<{
|
|
month: string;
|
|
orders: number;
|
|
revenue: number;
|
|
customers: number;
|
|
avgOrderValue: number;
|
|
newVendors: number;
|
|
newCustomers: number;
|
|
}>;
|
|
customers: {
|
|
total: number;
|
|
segments: {
|
|
new: number;
|
|
returning: number;
|
|
loyal: number;
|
|
vip: number;
|
|
};
|
|
segmentDetails: {
|
|
[key: string]: {
|
|
count: number;
|
|
totalRevenue: number;
|
|
avgOrderCount: number;
|
|
avgSpent: number;
|
|
};
|
|
};
|
|
segmentPercentages: {
|
|
new: number;
|
|
returning: number;
|
|
loyal: number;
|
|
vip: number;
|
|
};
|
|
};
|
|
|
|
cumulative: {
|
|
orders: number;
|
|
revenue: number;
|
|
customers: number;
|
|
vendors: number;
|
|
products: number;
|
|
avgOrderValue: number;
|
|
};
|
|
}
|
|
|
|
interface AnalyticsData {
|
|
meta?: {
|
|
year: number;
|
|
currentYear: number;
|
|
availableYears: number[];
|
|
range: string;
|
|
isCurrentYear: boolean;
|
|
};
|
|
vendors?: {
|
|
total?: number;
|
|
newToday?: number;
|
|
newThisWeek?: number;
|
|
activeToday?: number;
|
|
active?: number;
|
|
stores?: number;
|
|
activeStores?: number;
|
|
dailyGrowth?: { date: string; count: number }[];
|
|
topVendors?: Array<{
|
|
vendorId: string;
|
|
vendorName: string;
|
|
totalRevenue: number;
|
|
orderCount: number;
|
|
}>;
|
|
};
|
|
orders?: {
|
|
total?: number;
|
|
totalToday?: number;
|
|
totalThisWeek?: number;
|
|
totalPreviousWeek?: number;
|
|
totalThisMonth?: number;
|
|
totalLastMonth?: number;
|
|
pending?: number;
|
|
completed?: number;
|
|
dailyOrders?: { date: string; count: number }[];
|
|
};
|
|
revenue?: {
|
|
total?: number;
|
|
today?: number;
|
|
thisWeek?: number;
|
|
previousWeek?: number;
|
|
thisMonth?: number;
|
|
lastMonth?: number;
|
|
dailyRevenue?: {
|
|
date: string;
|
|
amount: number;
|
|
orders?: number;
|
|
avgOrderValue?: number;
|
|
}[];
|
|
};
|
|
engagement?: {
|
|
totalChats?: number;
|
|
activeChats?: number;
|
|
totalMessages?: number;
|
|
dailyMessages?: { date: string; count: number }[];
|
|
};
|
|
products?: {
|
|
total?: number;
|
|
recent?: number;
|
|
};
|
|
stores?: {
|
|
total?: number;
|
|
active?: number;
|
|
};
|
|
sessions?: {
|
|
total?: number;
|
|
active?: number;
|
|
};
|
|
comparisons?: {
|
|
[key: string]: ComparisonPeriod;
|
|
};
|
|
predictions?: {
|
|
predicted: number;
|
|
confidence: "high" | "medium" | "low";
|
|
confidenceScore: number;
|
|
dailyPredictions: Array<{ date: string; predicted: number; confidence: string }>;
|
|
message?: string;
|
|
features?: {
|
|
trends: {
|
|
growthRate: number;
|
|
momentum: number;
|
|
volatility: number;
|
|
};
|
|
seasonality: {
|
|
weekly: number[];
|
|
monthly: number[];
|
|
};
|
|
};
|
|
};
|
|
}
|
|
|
|
export default function AdminAnalytics() {
|
|
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(
|
|
null,
|
|
);
|
|
const [loading, setLoading] = useState(true);
|
|
const [dateRange, setDateRange] = useState("7days");
|
|
const [selectedYear, setSelectedYear] = useState<number>(
|
|
new Date().getFullYear(),
|
|
);
|
|
const [availableYears, setAvailableYears] = useState<number[]>([
|
|
new Date().getFullYear(),
|
|
]);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [showDebug, setShowDebug] = useState(false);
|
|
const [growthData, setGrowthData] = useState<GrowthData | null>(null);
|
|
const [growthLoading, setGrowthLoading] = useState(false);
|
|
|
|
const insights = React.useMemo(() => getSmartInsights(analyticsData, growthData), [analyticsData, growthData]);
|
|
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
|
|
const onPieEnter = (_: any, index: number) => {
|
|
setActiveIndex(index);
|
|
};
|
|
|
|
const currentYear = new Date().getFullYear();
|
|
|
|
// Segment colors for pie chart
|
|
const SEGMENT_COLORS = {
|
|
new: "#3b82f6", // blue
|
|
returning: "#10b981", // green
|
|
loyal: "#f59e0b", // amber
|
|
vip: "#8b5cf6", // purple
|
|
};
|
|
const isViewingCurrentYear = selectedYear === currentYear;
|
|
|
|
const fetchAnalyticsData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setErrorMessage(null);
|
|
|
|
// Build query params - include year if not current year
|
|
const params = new URLSearchParams();
|
|
params.set("range", dateRange);
|
|
if (selectedYear !== currentYear) {
|
|
params.set("year", selectedYear.toString());
|
|
}
|
|
|
|
const data = await fetchClient<AnalyticsData>(
|
|
`/admin/analytics?${params.toString()}`,
|
|
);
|
|
setAnalyticsData(data);
|
|
|
|
// Update available years from response if provided
|
|
if (data.meta?.availableYears && data.meta.availableYears.length > 0) {
|
|
setAvailableYears(data.meta.availableYears);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching analytics data:", error);
|
|
setErrorMessage("Failed to load analytics data. Please try again.");
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchAnalyticsData();
|
|
}, [dateRange, selectedYear]);
|
|
|
|
// Fetch growth data (cached, since launch)
|
|
const fetchGrowthData = async (forceRefresh = false) => {
|
|
try {
|
|
setGrowthLoading(true);
|
|
const url = forceRefresh ? "/admin/growth?refresh=true" : "/admin/growth";
|
|
const data = await fetchClient<GrowthData>(url);
|
|
setGrowthData(data);
|
|
} catch (error) {
|
|
console.error("Error fetching growth data:", error);
|
|
} finally {
|
|
setGrowthLoading(false);
|
|
}
|
|
};
|
|
|
|
// Fetch growth data on mount
|
|
useEffect(() => {
|
|
fetchGrowthData();
|
|
}, []);
|
|
|
|
const handleRefresh = () => {
|
|
setRefreshing(true);
|
|
fetchAnalyticsData();
|
|
};
|
|
|
|
const handleGrowthRefresh = () => {
|
|
fetchGrowthData(true);
|
|
};
|
|
|
|
if (loading && !analyticsData) {
|
|
return (
|
|
<div className="flex justify-center my-8">
|
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
|
</div>
|
|
);
|
|
}
|
|
// Helper to transform data for recharts
|
|
const transformChartData = (
|
|
data: Array<{ date: string;[key: string]: any }>,
|
|
valueKey: string = "count",
|
|
) => {
|
|
if (!data || data.length === 0) return [];
|
|
|
|
return data.map((item) => {
|
|
const dateStr = item.date;
|
|
|
|
if (!dateStr) {
|
|
return {
|
|
date: "Unknown",
|
|
formattedDate: "Unknown",
|
|
value: Number(item[valueKey]) || 0,
|
|
[valueKey]: Number(item[valueKey]) || 0,
|
|
};
|
|
}
|
|
|
|
// Parse YYYY-MM-DD format
|
|
const parts = dateStr.split("-");
|
|
const date =
|
|
parts.length === 3
|
|
? new Date(
|
|
parseInt(parts[0]),
|
|
parseInt(parts[1]) - 1,
|
|
parseInt(parts[2]),
|
|
)
|
|
: new Date(dateStr);
|
|
|
|
// Format with day of week: "Mon, Nov 21"
|
|
const dayOfWeek = date.toLocaleDateString("en-GB", { weekday: "short" });
|
|
const monthDay = date.toLocaleDateString("en-GB", {
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
|
|
return {
|
|
date: dateStr,
|
|
formattedDate: `${dayOfWeek}, ${monthDay}`,
|
|
value: Number(item[valueKey]) || 0,
|
|
[valueKey]: Number(item[valueKey]) || 0,
|
|
};
|
|
});
|
|
};
|
|
|
|
// Helper to combine orders and revenue data for dual-axis chart
|
|
const combineOrdersAndRevenue = (
|
|
orders: Array<{ date: string; count: number }>,
|
|
revenue: Array<{
|
|
date: string;
|
|
amount: number;
|
|
orders?: number;
|
|
avgOrderValue?: number;
|
|
}>,
|
|
) => {
|
|
if (!orders || orders.length === 0) return [];
|
|
|
|
// Create a map of revenue and AOV by date for quick lookup
|
|
const revenueMap = new Map<
|
|
string,
|
|
{ amount: number; avgOrderValue: number }
|
|
>();
|
|
if (revenue && revenue.length > 0) {
|
|
revenue.forEach((r) => {
|
|
if (r.date) {
|
|
revenueMap.set(r.date, {
|
|
amount: r.amount || 0,
|
|
avgOrderValue: r.avgOrderValue || 0,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return orders.map((order) => {
|
|
const dateStr = order.date;
|
|
|
|
if (!dateStr) {
|
|
return {
|
|
date: "Unknown",
|
|
formattedDate: "Unknown",
|
|
orders: order.count || 0,
|
|
revenue: 0,
|
|
avgOrderValue: 0
|
|
};
|
|
}
|
|
|
|
const parts = dateStr.split("-");
|
|
const date =
|
|
parts.length === 3
|
|
? new Date(
|
|
parseInt(parts[0]),
|
|
parseInt(parts[1]) - 1,
|
|
parseInt(parts[2]),
|
|
)
|
|
: new Date(dateStr);
|
|
|
|
// Format with day of week: "Mon, Nov 21"
|
|
const dayOfWeek = date.toLocaleDateString("en-GB", { weekday: "short" });
|
|
const monthDay = date.toLocaleDateString("en-GB", {
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
|
|
const revenueData = revenueMap.get(dateStr) || {
|
|
amount: 0,
|
|
avgOrderValue: 0,
|
|
};
|
|
|
|
return {
|
|
date: dateStr,
|
|
formattedDate: `${dayOfWeek}, ${monthDay}`,
|
|
orders: order.count || 0,
|
|
revenue: revenueData.amount,
|
|
avgOrderValue: revenueData.avgOrderValue,
|
|
};
|
|
});
|
|
};
|
|
|
|
|
|
// Custom tooltip for charts
|
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
|
if (active && payload && payload.length) {
|
|
const data = payload[0].payload;
|
|
const dataKey = payload[0].dataKey;
|
|
const isDualAxis =
|
|
data.orders !== undefined && data.revenue !== undefined;
|
|
|
|
// Determine if this is a currency amount or a count
|
|
// transformChartData creates both 'value' and the original key (count/amount)
|
|
// So we check the original key to determine the type
|
|
const isAmount =
|
|
dataKey === "amount" ||
|
|
dataKey === "revenue" ||
|
|
(dataKey === "value" &&
|
|
data.amount !== undefined &&
|
|
data.count === undefined);
|
|
|
|
return (
|
|
<div className="bg-[#050505]/90 p-4 rounded-xl shadow-xl border border-white/10 backdrop-blur-md ring-1 ring-white/5">
|
|
<p className="font-bold text-[10px] uppercase tracking-wide text-muted-foreground mb-3 border-b border-white/5 pb-2">
|
|
{data.formattedDate || label}
|
|
</p>
|
|
{isDualAxis ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-8">
|
|
<span className="text-[11px] font-semibold text-blue-400">Orders</span>
|
|
<span className="text-[11px] font-bold text-foreground tabular-nums">{data.orders}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-8">
|
|
<span className="text-[11px] font-semibold text-emerald-400">Revenue</span>
|
|
<span className="text-[11px] font-bold text-foreground tabular-nums">{formatGBP(data.revenue)}</span>
|
|
</div>
|
|
{data.avgOrderValue !== undefined && data.avgOrderValue > 0 && (
|
|
<div className="flex items-center justify-between gap-8 pt-2 border-t border-white/5">
|
|
<span className="text-[10px] font-medium text-purple-400">Avg Order Value</span>
|
|
<span className="text-[10px] font-bold text-foreground tabular-nums">{formatGBP(data.avgOrderValue)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-between gap-8">
|
|
<span className={`text-[11px] font-semibold ${isAmount ? "text-emerald-400" : "text-blue-400"}`}>
|
|
{isAmount ? "Revenue" : "Count"}
|
|
</span>
|
|
<span className="text-[11px] font-bold text-foreground tabular-nums">
|
|
{isAmount
|
|
? formatGBP(data.value || data.amount || 0)
|
|
: `${data.value || data.count || 0}`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate best month from daily data (for YTD, full year, or previous years)
|
|
const calculateBestMonth = (): { month: string; revenue: number; orders: number } | null => {
|
|
// Show best month for YTD, full year view, or when viewing previous years
|
|
const showBestMonth =
|
|
dateRange === "ytd" || dateRange === "year" || !isViewingCurrentYear;
|
|
|
|
if (
|
|
!showBestMonth ||
|
|
!analyticsData?.revenue?.dailyRevenue ||
|
|
analyticsData.revenue.dailyRevenue.length === 0
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// Group daily revenue by month
|
|
const monthlyTotals = new Map<
|
|
string,
|
|
{ revenue: number; orders: number }
|
|
>();
|
|
|
|
analyticsData.revenue.dailyRevenue.forEach((day) => {
|
|
const date = new Date(day.date);
|
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
const current = monthlyTotals.get(monthKey) || { revenue: 0, orders: 0 };
|
|
monthlyTotals.set(monthKey, {
|
|
revenue: current.revenue + (day.amount || 0),
|
|
orders: current.orders,
|
|
});
|
|
});
|
|
|
|
// Also group orders by month
|
|
if (analyticsData.orders?.dailyOrders) {
|
|
analyticsData.orders.dailyOrders.forEach((day) => {
|
|
const date = new Date(day.date);
|
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
const current = monthlyTotals.get(monthKey) || {
|
|
revenue: 0,
|
|
orders: 0,
|
|
};
|
|
monthlyTotals.set(monthKey, {
|
|
revenue: current.revenue,
|
|
orders: current.orders + (day.count || 0),
|
|
});
|
|
});
|
|
}
|
|
|
|
// Find the month with highest revenue
|
|
let bestMonth: { month: string; revenue: number; orders: number } | null =
|
|
null;
|
|
monthlyTotals.forEach((totals, monthKey) => {
|
|
if (!bestMonth || totals.revenue > bestMonth.revenue) {
|
|
const [year, month] = monthKey.split("-");
|
|
const monthName = new Date(
|
|
parseInt(year),
|
|
parseInt(month) - 1,
|
|
1,
|
|
).toLocaleDateString("en-GB", { month: "long", year: "numeric" });
|
|
bestMonth = {
|
|
month: monthName,
|
|
revenue: totals.revenue,
|
|
orders: totals.orders,
|
|
};
|
|
}
|
|
});
|
|
|
|
return bestMonth;
|
|
};
|
|
|
|
const bestMonth = calculateBestMonth();
|
|
|
|
return (
|
|
<div className="space-y-8 animate-in fade-in duration-500">
|
|
{errorMessage && (
|
|
<Alert variant="destructive" className="animate-in slide-in-from-top-2 border-destructive/50 bg-destructive/10 text-destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Error</AlertTitle>
|
|
<AlertDescription>{errorMessage}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
|
|
Dashboard Analytics
|
|
{!isViewingCurrentYear && (
|
|
<span className="ml-2 text-xl font-normal text-muted-foreground/60">
|
|
({selectedYear})
|
|
</span>
|
|
)}
|
|
</h2>
|
|
<p className="text-muted-foreground mt-1">
|
|
{isViewingCurrentYear
|
|
? "Overview of your marketplace performance"
|
|
: `Historical data for ${selectedYear}`}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 bg-background/40 p-1 rounded-lg border border-border/40 backdrop-blur-md">
|
|
{/* Year selector */}
|
|
<Select
|
|
value={selectedYear.toString()}
|
|
onValueChange={(value) => setSelectedYear(parseInt(value, 10))}
|
|
>
|
|
<SelectTrigger className="w-[100px] border-0 bg-transparent focus:ring-0">
|
|
<SelectValue placeholder="Year" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableYears.map((year) => (
|
|
<SelectItem key={year} value={year.toString()}>
|
|
{year}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<div className="h-4 w-px bg-border/40" />
|
|
|
|
{/* Date range selector - only show options for current year */}
|
|
<Select
|
|
value={isViewingCurrentYear ? dateRange : "year"}
|
|
onValueChange={setDateRange}
|
|
disabled={!isViewingCurrentYear}
|
|
>
|
|
<SelectTrigger className="w-[140px] border-0 bg-transparent focus:ring-0">
|
|
<SelectValue placeholder="Last 7 days" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{isViewingCurrentYear ? (
|
|
<>
|
|
<SelectItem value="24hours">Last 24 hours</SelectItem>
|
|
<SelectItem value="7days">Last 7 days</SelectItem>
|
|
<SelectItem value="30days">Last 30 days</SelectItem>
|
|
<SelectItem value="90days">Last 90 days</SelectItem>
|
|
<SelectItem value="180days">Last 180 days</SelectItem>
|
|
<SelectItem value="ytd">Year to Date</SelectItem>
|
|
<SelectItem value="year">Full Year</SelectItem>
|
|
</>
|
|
) : (
|
|
<SelectItem value="year">Full Year</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<div className="h-4 w-px bg-border/40" />
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
className="h-8 w-8 hover:bg-background/60"
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
|
/>
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowDebug(!showDebug)}
|
|
className="px-2 text-xs hover:bg-background/60"
|
|
>
|
|
{showDebug ? "Hide" : "Show"} Debug
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{showDebug && analyticsData && (
|
|
<Card className="mt-4 border-yellow-500/20 bg-yellow-500/5 backdrop-blur-sm">
|
|
<CardHeader>
|
|
<CardTitle className="text-yellow-600">Debug: Raw Data</CardTitle>
|
|
<CardDescription>Date Range: {dateRange}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4 text-xs font-mono">
|
|
<div>
|
|
<div className="font-semibold mb-2">Orders:</div>
|
|
<div className="pl-4 space-y-1">
|
|
<div>Total: {analyticsData?.orders?.total || "N/A"}</div>
|
|
<div>Today: {analyticsData?.orders?.totalToday || "N/A"}</div>
|
|
<div>
|
|
Daily Orders Array Length:{" "}
|
|
{analyticsData?.orders?.dailyOrders?.length || 0}
|
|
</div>
|
|
{/* ... Existing Debug details kept for brevity ... */}
|
|
<div>First 3 Daily Orders:</div>
|
|
<pre className="pl-4 bg-muted/50 p-2 rounded overflow-auto max-h-32">
|
|
{JSON.stringify(
|
|
analyticsData?.orders?.dailyOrders?.slice(0, 3),
|
|
null,
|
|
2,
|
|
)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
{/* Simplified debug view for code brevity in replacement, focusing on style changes */}
|
|
<details className="mt-4">
|
|
<summary className="font-semibold cursor-pointer hover:text-primary transition-colors">
|
|
Full JSON Response
|
|
</summary>
|
|
<pre className="mt-2 bg-muted/50 p-4 rounded overflow-auto max-h-96 text-[10px] backdrop-blur-sm">
|
|
{JSON.stringify(analyticsData, null, 2)}
|
|
</pre>
|
|
</details>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Best Month Card (show for YTD, full year, or previous years) */}
|
|
{/* Best Month Card (show for YTD, full year, or previous years) */}
|
|
{bestMonth && (
|
|
<Card className="border-green-500/20 bg-green-500/5 backdrop-blur-sm overflow-hidden relative">
|
|
<div className="absolute inset-0 bg-gradient-to-r from-green-500/10 to-transparent opacity-50" />
|
|
<CardContent className="pt-6 relative">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-3 rounded-full bg-green-500/20 border border-green-500/20 shadow-[0_0_15px_rgba(34,197,94,0.2)]">
|
|
<Trophy className="h-6 w-6 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Best Month{" "}
|
|
{!isViewingCurrentYear
|
|
? `of ${selectedYear}`
|
|
: dateRange === "year"
|
|
? "(Full Year)"
|
|
: "(YTD)"}
|
|
</div>
|
|
<div className="text-2xl font-bold text-green-600 mt-1">
|
|
{bestMonth.month}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Revenue
|
|
</div>
|
|
<div className="text-2xl font-bold bg-gradient-to-r from-green-600 to-green-400 bg-clip-text text-transparent">
|
|
{formatAdminCurrency(bestMonth.revenue)}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground mt-1">
|
|
{formatNumber(bestMonth.orders)} orders
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{/* Orders Card */}
|
|
<AdminStatCard
|
|
title="Total Orders"
|
|
icon={ShoppingCart}
|
|
iconColorClass="text-blue-500"
|
|
iconBgClass="bg-blue-500/10"
|
|
value={formatNumber(analyticsData?.orders?.total)}
|
|
subtext={
|
|
<span className="bg-muted/50 px-1.5 py-0.5 rounded">
|
|
Today: {analyticsData?.orders?.totalToday || 0}
|
|
</span>
|
|
}
|
|
trend={{
|
|
current: analyticsData?.orders?.totalToday || 0,
|
|
previous: (analyticsData?.orders?.total || 0) / 30, // Approx simple moving average
|
|
}}
|
|
loading={loading || refreshing}
|
|
chartData={transformChartData(
|
|
analyticsData?.orders?.dailyOrders || [],
|
|
"count"
|
|
)}
|
|
chartColor="#3b82f6"
|
|
chartGradientId="colorOrdersStat"
|
|
/>
|
|
|
|
{/* Revenue Card */}
|
|
<AdminStatCard
|
|
title="Total Revenue"
|
|
icon={DollarSign}
|
|
iconColorClass="text-green-500"
|
|
iconBgClass="bg-green-500/10"
|
|
value={formatAdminCurrency(analyticsData?.revenue?.total || 0)}
|
|
subtext={
|
|
<span className="bg-muted/50 px-1.5 py-0.5 rounded">
|
|
Today: {formatAdminCurrency(analyticsData?.revenue?.today || 0)}
|
|
</span>
|
|
}
|
|
trend={{
|
|
current: analyticsData?.revenue?.today || 0,
|
|
previous: (analyticsData?.revenue?.total || 0) / 30,
|
|
}}
|
|
loading={loading || refreshing}
|
|
chartData={transformChartData(
|
|
analyticsData?.revenue?.dailyRevenue || [],
|
|
"amount"
|
|
)}
|
|
chartColor="#10b981"
|
|
chartGradientId="colorRevenueStat"
|
|
tooltipPrefix="£"
|
|
/>
|
|
|
|
{/* Vendors Card */}
|
|
<AdminStatCard
|
|
title="Vendors"
|
|
icon={Users}
|
|
iconColorClass="text-purple-500"
|
|
iconBgClass="bg-purple-500/10"
|
|
value={analyticsData?.vendors?.total?.toLocaleString() || "0"}
|
|
subtext={<span>New: {analyticsData?.vendors?.newToday || 0}</span>}
|
|
trend={{
|
|
current: analyticsData?.vendors?.newToday || 0,
|
|
previous: (analyticsData?.vendors?.newThisWeek || 0) / 7,
|
|
}}
|
|
loading={loading || refreshing}
|
|
chartData={transformChartData(
|
|
analyticsData?.vendors?.dailyGrowth || [],
|
|
"count"
|
|
)}
|
|
chartColor="#8b5cf6"
|
|
chartGradientId="colorVendorsStat"
|
|
>
|
|
<div className="flex items-center text-xs text-muted-foreground gap-2">
|
|
<span className="bg-muted/50 px-1.5 py-0.5 rounded">
|
|
Active: {analyticsData?.vendors?.active || 0}
|
|
</span>
|
|
<span className="bg-muted/50 px-1.5 py-0.5 rounded">
|
|
Stores: {analyticsData?.vendors?.activeStores || 0}
|
|
</span>
|
|
</div>
|
|
</AdminStatCard>
|
|
|
|
{/* Products Card */}
|
|
<AdminStatCard
|
|
title="Products"
|
|
icon={Package}
|
|
iconColorClass="text-amber-500"
|
|
iconBgClass="bg-amber-500/10"
|
|
value={formatNumber(analyticsData?.products?.total)}
|
|
loading={loading || refreshing}
|
|
chartColor="#f59e0b"
|
|
chartGradientId="colorProductsStat"
|
|
hideChart={true}
|
|
/>
|
|
</div>
|
|
|
|
{/* AI Performance Insights */}
|
|
{insights.length > 0 && !loading && !refreshing && (
|
|
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{insights.map((insight, index) => (
|
|
<Card key={index} className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm hover:bg-background/80 transition-all duration-300">
|
|
<CardContent className="p-4 flex items-start gap-4">
|
|
<div className={`p-2 rounded-lg ${insight.bg} shrink-0`}>
|
|
<insight.icon className={`h-5 w-5 ${insight.color}`} />
|
|
</div>
|
|
<div>
|
|
<h4 className="font-semibold text-sm mb-1 flex items-center gap-2">
|
|
Insight
|
|
{insight.type === 'positive' && <span className="flex h-2 w-2 rounded-full bg-green-500 animate-pulse" />}
|
|
</h4>
|
|
<p className="text-sm text-muted-foreground leading-snug">
|
|
{insight.message}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<Tabs defaultValue="orders" className="mt-8">
|
|
<TabsList className="bg-background/40 backdrop-blur-md border border-border/40 p-1 w-full sm:w-auto h-auto grid grid-cols-3 sm:flex">
|
|
<TabsTrigger
|
|
value="orders"
|
|
className="data-[state=active]:bg-background/60 data-[state=active]:shadow-sm data-[state=active]:text-primary transition-all duration-300"
|
|
>
|
|
Orders
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="vendors"
|
|
className="data-[state=active]:bg-background/60 data-[state=active]:shadow-sm data-[state=active]:text-primary transition-all duration-300"
|
|
>
|
|
Vendors
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="growth"
|
|
className="data-[state=active]:bg-background/60 data-[state=active]:shadow-sm data-[state=active]:text-primary transition-all duration-300"
|
|
>
|
|
Growth Since Launch
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="orders" className="mt-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
|
{loading || refreshing ? (
|
|
<ChartSkeleton
|
|
title="Order Trends"
|
|
description="Daily order volume and revenue processed over the selected time period"
|
|
icon={BarChart}
|
|
showStats={true}
|
|
/>
|
|
) : (
|
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
|
<CardHeader>
|
|
<CardTitle className="w-fit">
|
|
Order Trends
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Daily order volume and revenue processed over the selected time
|
|
period
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{analyticsData?.orders?.dailyOrders &&
|
|
analyticsData.orders.dailyOrders.length > 0 ? (
|
|
<div className="h-80">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart
|
|
data={combineOrdersAndRevenue(
|
|
analyticsData.orders.dailyOrders,
|
|
analyticsData.revenue?.dailyRevenue || [],
|
|
)}
|
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
>
|
|
<defs>
|
|
<linearGradient id="colorOrdersTrends" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="colorRevenueTrends" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" opacity={0.4} />
|
|
<XAxis
|
|
dataKey="formattedDate"
|
|
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
|
angle={-45}
|
|
textAnchor="end"
|
|
height={60}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
/>
|
|
<YAxis
|
|
yAxisId="left"
|
|
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
label={{
|
|
value: "Orders",
|
|
angle: -90,
|
|
position: "insideLeft",
|
|
style: { fill: 'hsl(var(--muted-foreground))' }
|
|
}}
|
|
/>
|
|
<YAxis
|
|
yAxisId="right"
|
|
orientation="right"
|
|
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tickFormatter={(value) =>
|
|
`£${(value / 1000).toFixed(0)}k`
|
|
}
|
|
label={{
|
|
value: "Revenue",
|
|
angle: 90,
|
|
position: "insideRight",
|
|
style: { fill: 'hsl(var(--muted-foreground))' }
|
|
}}
|
|
/>
|
|
<Tooltip content={<CustomTooltip />} cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} />
|
|
<Area
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="orders"
|
|
stroke="#3b82f6"
|
|
fill="url(#colorOrdersTrends)"
|
|
strokeWidth={2}
|
|
name="Orders"
|
|
activeDot={{ r: 4, strokeWidth: 0, fill: "#3b82f6" }}
|
|
/>
|
|
<Area
|
|
yAxisId="right"
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke="#10b981"
|
|
fill="url(#colorRevenueTrends)"
|
|
strokeWidth={2}
|
|
name="Revenue"
|
|
activeDot={{ r: 4, strokeWidth: 0, fill: "#10b981" }}
|
|
/>
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
|
No order data available for the selected time period
|
|
</div>
|
|
)}
|
|
|
|
{/* Calculate totals for the selected period */}
|
|
{analyticsData?.orders?.dailyOrders &&
|
|
analyticsData?.revenue?.dailyRevenue && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
|
<div className="bg-muted/50 p-4 rounded-lg">
|
|
<div className="text-sm font-medium mb-1">
|
|
Total Revenue
|
|
</div>
|
|
<div className="text-2xl font-bold text-green-600">
|
|
{formatAdminCurrency(
|
|
analyticsData.revenue.dailyRevenue.reduce(
|
|
(sum, day) => sum + (day.amount || 0),
|
|
0,
|
|
),
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="bg-muted/50 p-4 rounded-lg">
|
|
<div className="text-sm font-medium mb-1">
|
|
Total Orders
|
|
</div>
|
|
<div className="text-2xl font-bold text-blue-600">
|
|
{analyticsData.orders.dailyOrders
|
|
.reduce((sum, day) => sum + (day.count || 0), 0)
|
|
.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="vendors" className="mt-4 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
|
{loading || refreshing ? (
|
|
<ChartSkeleton
|
|
title="Vendor Growth"
|
|
description="New vendor registrations over time"
|
|
icon={Users}
|
|
showStats={true}
|
|
/>
|
|
) : (
|
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
|
<CardHeader>
|
|
<CardTitle className="w-fit">
|
|
Vendor Growth
|
|
</CardTitle>
|
|
<CardDescription>New vendor registrations over time</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{analyticsData?.vendors?.dailyGrowth &&
|
|
analyticsData.vendors.dailyGrowth.length > 0 ? (
|
|
<div className="h-80">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart
|
|
data={transformChartData(
|
|
analyticsData.vendors.dailyGrowth,
|
|
"count",
|
|
)}
|
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
>
|
|
<defs>
|
|
<linearGradient
|
|
id="colorVendorsAdminChart"
|
|
x1="0"
|
|
y1="0"
|
|
x2="0"
|
|
y2="1"
|
|
>
|
|
<stop
|
|
offset="5%"
|
|
stopColor="#8b5cf6"
|
|
stopOpacity={0.3}
|
|
/>
|
|
<stop
|
|
offset="95%"
|
|
stopColor="#8b5cf6"
|
|
stopOpacity={0}
|
|
/>
|
|
</linearGradient>
|
|
</defs>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="value"
|
|
stroke="#8b5cf6"
|
|
fillOpacity={1}
|
|
fill="url(#colorVendorsAdminChart)"
|
|
strokeWidth={2}
|
|
activeDot={{ r: 4, strokeWidth: 0, fill: "#8b5cf6" }}
|
|
/>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis
|
|
dataKey="formattedDate"
|
|
tick={{ fontSize: 12 }}
|
|
angle={-45}
|
|
textAnchor="end"
|
|
height={60}
|
|
/>
|
|
<YAxis tick={{ fontSize: 12 }} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
|
No vendor data available for the selected time period
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-6">
|
|
<div className="bg-muted/50 p-4 rounded-lg">
|
|
<div className="text-sm font-medium mb-1">Total Vendors</div>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(analyticsData?.vendors?.total)}
|
|
</div>
|
|
</div>
|
|
<div className="bg-muted/50 p-4 rounded-lg">
|
|
<div className="text-sm font-medium mb-1">Active Vendors</div>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(analyticsData?.vendors?.active)}
|
|
</div>
|
|
</div>
|
|
<div className="bg-muted/50 p-4 rounded-lg">
|
|
<div className="text-sm font-medium mb-1">Active Stores</div>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(analyticsData?.vendors?.activeStores)}
|
|
</div>
|
|
</div>
|
|
<div className="bg-muted/50 p-4 rounded-lg">
|
|
<div className="text-sm font-medium mb-1">New This Week</div>
|
|
<div className="text-2xl font-bold">
|
|
{formatNumber(analyticsData?.vendors?.newThisWeek)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top Vendors by Revenue */}
|
|
{analyticsData?.vendors?.topVendors &&
|
|
analyticsData.vendors.topVendors.length > 0 && (
|
|
<div className="mt-6">
|
|
<h3 className="text-lg font-semibold mb-4">
|
|
Top Vendors by Revenue
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{analyticsData.vendors.topVendors.map((vendor, index) => (
|
|
<div
|
|
key={vendor.vendorId}
|
|
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-semibold text-sm">
|
|
{index + 1}
|
|
</div>
|
|
<div>
|
|
<div className="font-medium">
|
|
{vendor.vendorName}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{vendor.orderCount} orders
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="font-semibold text-green-600">
|
|
{formatAdminCurrency(vendor.totalRevenue)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="growth" className="mt-4 space-y-6">
|
|
{/* Growth Header */}
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h3 className="text-lg font-semibold">
|
|
Platform Growth Since Launch
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{growthData?.launchDate
|
|
? `Tracking since ${new Date(growthData.launchDate).toLocaleDateString("en-GB", { month: "long", year: "numeric" })}`
|
|
: "February 2025"}
|
|
{growthData?.generatedAt && (
|
|
<span className="ml-2">
|
|
• Last updated:{" "}
|
|
{new Date(growthData.generatedAt).toLocaleTimeString(
|
|
"en-GB",
|
|
{ hour: "2-digit", minute: "2-digit" },
|
|
)}
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleGrowthRefresh}
|
|
disabled={growthLoading}
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 mr-2 ${growthLoading ? "animate-spin" : ""}`}
|
|
/>
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Cumulative Stats Cards */}
|
|
{growthData?.cumulative && (
|
|
<div className="grid grid-cols-2 lg:grid-cols-6 gap-4">
|
|
<Card className="col-span-1 border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/60 transition-colors">
|
|
<CardContent className="pt-4 flex flex-col items-center justify-center text-center">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Total Orders</div>
|
|
<div className="text-2xl font-bold">{formatNumber(growthData.cumulative.orders)}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="col-span-1 border-green-500/20 bg-green-500/5 backdrop-blur-sm hover:bg-green-500/10 transition-colors">
|
|
<CardContent className="pt-4 flex flex-col items-center justify-center text-center">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Total Revenue</div>
|
|
<div className="text-2xl font-bold text-green-600">{formatAdminCurrency(growthData.cumulative.revenue)}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="col-span-1 border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/60 transition-colors">
|
|
<CardContent className="pt-4 flex flex-col items-center justify-center text-center">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Customers</div>
|
|
<div className="text-2xl font-bold">{formatNumber(growthData.cumulative.customers)}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="col-span-1 border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/60 transition-colors">
|
|
<CardContent className="pt-4 flex flex-col items-center justify-center text-center">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Vendors</div>
|
|
<div className="text-2xl font-bold">{formatNumber(growthData.cumulative.vendors)}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="col-span-1 border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/60 transition-colors">
|
|
<CardContent className="pt-4 flex flex-col items-center justify-center text-center">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Products</div>
|
|
<div className="text-2xl font-bold">{formatNumber(growthData.cumulative.products)}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="col-span-1 border-purple-500/20 bg-purple-500/5 backdrop-blur-sm hover:bg-purple-500/10 transition-colors">
|
|
<CardContent className="pt-4 flex flex-col items-center justify-center text-center">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Avg Order Value</div>
|
|
<div className="text-2xl font-bold text-purple-600">{formatAdminCurrency(growthData.cumulative.avgOrderValue)}</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Monthly Revenue & Orders Chart */}
|
|
<Card className="lg:col-span-2 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
|
<CardHeader>
|
|
<CardTitle className="w-fit">Monthly Revenue & Orders</CardTitle>
|
|
<CardDescription>
|
|
Platform performance by month since launch
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{growthLoading ? (
|
|
<ChartSkeleton
|
|
title="Monthly Revenue & Orders"
|
|
description="Platform performance by month since launch"
|
|
icon={BarChart}
|
|
showStats={false}
|
|
className="h-full border-0 shadow-none bg-transparent"
|
|
/>
|
|
) : growthData?.monthly && growthData.monthly.length > 0 ? (
|
|
<div className="h-80">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart
|
|
data={growthData.monthly.map((m) => ({
|
|
...m,
|
|
formattedMonth: new Date(
|
|
m.month + "-01",
|
|
).toLocaleDateString("en-GB", {
|
|
month: "short",
|
|
year: "2-digit",
|
|
}),
|
|
}))}
|
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
>
|
|
<defs>
|
|
<linearGradient id="colorGrowthOrders" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="colorGrowthRevenue" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" opacity={0.4} />
|
|
<XAxis dataKey="formattedMonth" tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }} axisLine={false} tickLine={false} />
|
|
<YAxis yAxisId="left" tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }} axisLine={false} tickLine={false} label={{ value: "Orders", angle: -90, position: "insideLeft", style: { fill: 'hsl(var(--muted-foreground))' } }} />
|
|
<YAxis
|
|
yAxisId="right"
|
|
orientation="right"
|
|
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
|
axisLine={false} tickLine={false}
|
|
tickFormatter={(value) =>
|
|
`£${(value / 1000).toFixed(0)}k`
|
|
}
|
|
label={{ value: "Revenue", angle: 90, position: "insideRight", style: { fill: 'hsl(var(--muted-foreground))' } }}
|
|
/>
|
|
<Tooltip
|
|
cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }}
|
|
content={({ active, payload }) => {
|
|
if (active && payload?.length) {
|
|
const data = payload[0].payload;
|
|
return (
|
|
<div className="bg-background/95 border border-border/50 p-4 rounded-xl shadow-xl backdrop-blur-md">
|
|
<p className="font-semibold mb-3 border-b border-border/50 pb-2">{data.month}</p>
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-blue-500" /> Orders
|
|
</span>
|
|
<span className="font-medium">{formatNumber(data.orders)}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-green-500" /> Revenue
|
|
</span>
|
|
<span className="font-medium text-green-600">{formatAdminCurrency(data.revenue)}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-purple-500" /> Customers
|
|
</span>
|
|
<span className="font-medium">{formatNumber(data.customers)}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-amber-500" /> New Vendors
|
|
</span>
|
|
<span className="font-medium">{data.newVendors}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
/>
|
|
<Area
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="orders"
|
|
stroke="#3b82f6"
|
|
fill="url(#colorGrowthOrders)"
|
|
strokeWidth={2}
|
|
name="Orders"
|
|
activeDot={{ r: 4, strokeWidth: 0, fill: "#3b82f6" }}
|
|
/>
|
|
<Area
|
|
yAxisId="right"
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke="#10b981"
|
|
fill="url(#colorGrowthRevenue)"
|
|
strokeWidth={2}
|
|
name="Revenue"
|
|
activeDot={{ r: 4, strokeWidth: 0, fill: "#10b981" }}
|
|
/>
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center h-80 text-muted-foreground bg-muted/20 rounded-lg border border-dashed border-border/50">
|
|
No growth data available
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Customer Segments Pie Chart */}
|
|
<Card className="lg:col-span-1 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm h-full flex flex-col">
|
|
<CardHeader>
|
|
<CardTitle className="w-fit">Customer Segments</CardTitle>
|
|
<CardDescription>By purchase behavior</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 flex flex-col justify-center">
|
|
{growthLoading ? (
|
|
<ChartSkeleton
|
|
title="Customer Segments"
|
|
description="By purchase behavior"
|
|
icon={PieChartIcon}
|
|
showStats={false}
|
|
className="h-full border-0 shadow-none bg-transparent"
|
|
/>
|
|
) : growthData?.customers ? (
|
|
<>
|
|
<div className="h-64 min-w-0">
|
|
<ResponsiveContainer key={growthData?.customers ? 'ready' : 'loading'} width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
activeIndex={activeIndex}
|
|
activeShape={renderActiveShape}
|
|
data={[
|
|
{
|
|
name: "New (1 order)",
|
|
value: growthData.customers.segments.new,
|
|
color: SEGMENT_COLORS.new,
|
|
},
|
|
{
|
|
name: "Returning (2+)",
|
|
value: growthData.customers.segments.returning,
|
|
color: SEGMENT_COLORS.returning,
|
|
},
|
|
{
|
|
name: "Loyal (£300+/4+)",
|
|
value: growthData.customers.segments.loyal,
|
|
color: SEGMENT_COLORS.loyal,
|
|
},
|
|
{
|
|
name: "VIP (£1k+/10+)",
|
|
value: growthData.customers.segments.vip,
|
|
color: SEGMENT_COLORS.vip,
|
|
},
|
|
]}
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={60}
|
|
outerRadius={80}
|
|
fill="#8884d8"
|
|
dataKey="value"
|
|
onMouseEnter={onPieEnter}
|
|
>
|
|
{[
|
|
{ color: SEGMENT_COLORS.new },
|
|
{ color: SEGMENT_COLORS.returning },
|
|
{ color: SEGMENT_COLORS.loyal },
|
|
{ color: SEGMENT_COLORS.vip },
|
|
].map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
))}
|
|
</Pie>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Segment Stats */}
|
|
<div className="grid grid-cols-2 gap-3 mt-4">
|
|
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/10 text-center hover:bg-blue-500/15 transition-colors">
|
|
<div className="text-xl font-bold text-blue-600">
|
|
{growthData.customers.segments.new}
|
|
</div>
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">New</div>
|
|
</div>
|
|
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/10 text-center hover:bg-green-500/15 transition-colors">
|
|
<div className="text-xl font-bold text-green-600">
|
|
{growthData.customers.segments.returning}
|
|
</div>
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
Returning
|
|
</div>
|
|
</div>
|
|
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/10 text-center hover:bg-amber-500/15 transition-colors">
|
|
<div className="text-xl font-bold text-amber-600">
|
|
{growthData.customers.segments.loyal}
|
|
</div>
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Loyal</div>
|
|
</div>
|
|
<div className="p-3 rounded-lg bg-purple-500/10 border border-purple-500/10 text-center hover:bg-purple-500/15 transition-colors">
|
|
<div className="text-xl font-bold text-purple-600">
|
|
{growthData.customers.segments.vip}
|
|
</div>
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">VIP</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center h-64 text-muted-foreground bg-muted/20 rounded-lg border border-dashed border-border/50">
|
|
No customer data available
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Monthly Growth Table */}
|
|
{growthData?.monthly && growthData.monthly.length > 0 && (
|
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
|
<CardHeader>
|
|
<CardTitle className="w-fit">Monthly Breakdown</CardTitle>
|
|
<CardDescription>Detailed metrics by month</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-md border border-border/40 overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-muted/40 border-b border-border/40">
|
|
<th className="text-left p-3 font-medium text-muted-foreground">Month</th>
|
|
<th className="text-right p-3 font-medium text-muted-foreground">Orders</th>
|
|
<th className="text-right p-3 font-medium text-muted-foreground">Revenue</th>
|
|
<th className="text-right p-3 font-medium text-muted-foreground">
|
|
Customers
|
|
</th>
|
|
<th className="text-right p-3 font-medium text-muted-foreground">
|
|
Avg Order
|
|
</th>
|
|
<th className="text-right p-3 font-medium text-muted-foreground">
|
|
New Vendors
|
|
</th>
|
|
<th className="text-right p-3 font-medium text-muted-foreground">
|
|
New Customers
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border/30">
|
|
{growthData.monthly.map((month, i) => (
|
|
<tr
|
|
key={month.month}
|
|
className="hover:bg-muted/30 transition-colors animate-in fade-in slide-in-from-bottom-2 duration-500 fill-mode-backwards"
|
|
style={{ animationDelay: `${i * 50}ms` }}
|
|
>
|
|
<td className="p-3 font-medium">
|
|
{new Date(month.month + "-01").toLocaleDateString(
|
|
"en-GB",
|
|
{ month: "long", year: "numeric" },
|
|
)}
|
|
</td>
|
|
<td className="text-right p-3">
|
|
<div className="inline-flex items-center px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-600 text-xs font-medium">
|
|
{month.orders.toLocaleString()}
|
|
</div>
|
|
</td>
|
|
<td className="text-right p-3 text-green-600 font-semibold">
|
|
{formatAdminCurrency(month.revenue)}
|
|
</td>
|
|
<td className="text-right p-3 text-muted-foreground">
|
|
{month.customers.toLocaleString()}
|
|
</td>
|
|
<td className="text-right p-3 text-muted-foreground">
|
|
{formatAdminCurrency(month.avgOrderValue)}
|
|
</td>
|
|
<td className="text-right p-3 text-muted-foreground">{month.newVendors}</td>
|
|
<td className="text-right p-3 text-muted-foreground">
|
|
{month.newCustomers}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|