All checks were successful
Build Frontend / build (push) Successful in 1m11s
Enhanced the AnalyticsDashboard layout with a premium glassmorphism UI, improved toolbar, and reorganized tabs for better clarity. MetricsCard now features dynamic color coding and trend badges. PredictionsChart received scenario simulation UI upgrades, disabled future ranges based on available history, and improved chart tooltips and visuals. ProfitAnalyticsChart added error handling for product images and minor UI refinements. Updated globals.css with new premium utility classes and improved dark mode color variables.
1558 lines
57 KiB
TypeScript
1558 lines
57 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
AlertCircle,
|
|
BarChart,
|
|
RefreshCw,
|
|
Users,
|
|
ShoppingCart,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
DollarSign,
|
|
Package,
|
|
Trophy,
|
|
} from "lucide-react";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import { fetchClient } from "@/lib/api-client";
|
|
import {
|
|
BarChart as RechartsBarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
LineChart,
|
|
Line,
|
|
ComposedChart,
|
|
} from "recharts";
|
|
import { formatGBP } from "@/utils/format";
|
|
import { PieChart, Pie, Cell, Legend } from "recharts";
|
|
|
|
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;
|
|
pending?: number;
|
|
completed?: number;
|
|
dailyOrders?: { date: string; count: number }[];
|
|
};
|
|
revenue?: {
|
|
total?: number;
|
|
today?: number;
|
|
thisWeek?: 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;
|
|
};
|
|
}
|
|
|
|
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 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;
|
|
// 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) => {
|
|
revenueMap.set(r.date, {
|
|
amount: r.amount || 0,
|
|
avgOrderValue: r.avgOrderValue || 0,
|
|
});
|
|
});
|
|
}
|
|
|
|
return orders.map((order) => {
|
|
const dateStr = order.date;
|
|
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-background border border-border p-3 rounded-lg shadow-lg">
|
|
<p className="text-sm font-medium mb-2">
|
|
{data.formattedDate || label}
|
|
</p>
|
|
{isDualAxis ? (
|
|
<div className="space-y-1">
|
|
<p className="text-sm text-blue-600">
|
|
Orders: <span className="font-semibold">{data.orders}</span>
|
|
</p>
|
|
<p className="text-sm text-green-600">
|
|
Revenue:{" "}
|
|
<span className="font-semibold">{formatGBP(data.revenue)}</span>
|
|
</p>
|
|
{data.avgOrderValue !== undefined && data.avgOrderValue > 0 && (
|
|
<p className="text-sm text-purple-600">
|
|
Avg Order Value:{" "}
|
|
<span className="font-semibold">
|
|
{formatGBP(data.avgOrderValue)}
|
|
</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-primary">
|
|
{isAmount
|
|
? formatGBP(data.value || data.amount || 0)
|
|
: `${data.value || data.count || 0}`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Trend indicator component for metric cards
|
|
const TrendIndicator = ({
|
|
current,
|
|
previous,
|
|
}: {
|
|
current: number;
|
|
previous: number;
|
|
}) => {
|
|
if (!current || !previous) return null;
|
|
|
|
const percentChange = ((current - previous) / previous) * 100;
|
|
|
|
if (Math.abs(percentChange) < 0.1) return null;
|
|
|
|
return (
|
|
<div
|
|
className={`flex items-center text-xs font-medium ${percentChange >= 0 ? "text-green-500" : "text-red-500"}`}
|
|
>
|
|
{percentChange >= 0 ? (
|
|
<TrendingUp className="h-3 w-3 mr-1" />
|
|
) : (
|
|
<TrendingDown className="h-3 w-3 mr-1" />
|
|
)}
|
|
{Math.abs(percentChange).toFixed(1)}%
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Format currency
|
|
const formatCurrency = (value: number) => {
|
|
return new Intl.NumberFormat("en-GB", {
|
|
style: "currency",
|
|
currency: "GBP",
|
|
maximumFractionDigits: 0,
|
|
}).format(value);
|
|
};
|
|
|
|
// Calculate best month from daily data (for YTD, full year, or previous years)
|
|
const calculateBestMonth = () => {
|
|
// 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-6">
|
|
{errorMessage && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Error</AlertTitle>
|
|
<AlertDescription>{errorMessage}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h2 className="text-2xl font-bold">
|
|
Dashboard Analytics
|
|
{!isViewingCurrentYear && (
|
|
<span className="ml-2 text-lg font-normal text-muted-foreground">
|
|
({selectedYear})
|
|
</span>
|
|
)}
|
|
</h2>
|
|
<p className="text-muted-foreground">
|
|
{isViewingCurrentYear
|
|
? "Overview of your marketplace performance"
|
|
: `Historical data for ${selectedYear}`}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Year selector */}
|
|
<Select
|
|
value={selectedYear.toString()}
|
|
onValueChange={(value) => setSelectedYear(parseInt(value, 10))}
|
|
>
|
|
<SelectTrigger className="w-[100px]">
|
|
<SelectValue placeholder="Year" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableYears.map((year) => (
|
|
<SelectItem key={year} value={year.toString()}>
|
|
{year}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Date range selector - only show options for current year */}
|
|
<Select
|
|
value={isViewingCurrentYear ? dateRange : "year"}
|
|
onValueChange={setDateRange}
|
|
disabled={!isViewingCurrentYear}
|
|
>
|
|
<SelectTrigger className="w-[140px]">
|
|
<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="ytd">Year to Date</SelectItem>
|
|
<SelectItem value="year">Full Year</SelectItem>
|
|
</>
|
|
) : (
|
|
<SelectItem value="year">Full Year</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
|
/>
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowDebug(!showDebug)}
|
|
>
|
|
{showDebug ? "Hide" : "Show"} Debug
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{showDebug && analyticsData && (
|
|
<Card className="mt-4">
|
|
<CardHeader>
|
|
<CardTitle>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>
|
|
<div>First 3 Daily Orders:</div>
|
|
<pre className="pl-4 bg-muted p-2 rounded overflow-auto max-h-32">
|
|
{JSON.stringify(
|
|
analyticsData?.orders?.dailyOrders?.slice(0, 3),
|
|
null,
|
|
2,
|
|
)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="font-semibold mb-2">Revenue:</div>
|
|
<div className="pl-4 space-y-1">
|
|
<div>Total: {analyticsData?.revenue?.total || "N/A"}</div>
|
|
<div>Today: {analyticsData?.revenue?.today || "N/A"}</div>
|
|
<div>
|
|
Daily Revenue Array Length:{" "}
|
|
{analyticsData?.revenue?.dailyRevenue?.length || 0}
|
|
</div>
|
|
<div>First 3 Daily Revenue:</div>
|
|
<pre className="pl-4 bg-muted p-2 rounded overflow-auto max-h-32">
|
|
{JSON.stringify(
|
|
analyticsData?.revenue?.dailyRevenue?.slice(0, 3),
|
|
null,
|
|
2,
|
|
)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="font-semibold mb-2">Vendors:</div>
|
|
<div className="pl-4 space-y-1">
|
|
<div>Total: {analyticsData?.vendors?.total || "N/A"}</div>
|
|
<div>
|
|
Daily Growth Array Length:{" "}
|
|
{analyticsData?.vendors?.dailyGrowth?.length || 0}
|
|
</div>
|
|
<div>First 3 Daily Growth:</div>
|
|
<pre className="pl-4 bg-muted p-2 rounded overflow-auto max-h-32">
|
|
{JSON.stringify(
|
|
analyticsData?.vendors?.dailyGrowth?.slice(0, 3),
|
|
null,
|
|
2,
|
|
)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<details className="mt-4">
|
|
<summary className="font-semibold cursor-pointer">
|
|
Full JSON Response
|
|
</summary>
|
|
<pre className="mt-2 bg-muted p-4 rounded overflow-auto max-h-96 text-[10px]">
|
|
{JSON.stringify(analyticsData, null, 2)}
|
|
</pre>
|
|
</details>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Best Month Card (show for YTD, full year, or previous years) */}
|
|
{bestMonth && (
|
|
<Card className="border-green-500/50 bg-green-500/5">
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-full bg-green-500/20">
|
|
<Trophy className="h-5 w-5 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-xl font-bold text-green-600">
|
|
{bestMonth.month}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Revenue
|
|
</div>
|
|
<div className="text-xl font-bold">
|
|
{formatCurrency(bestMonth.revenue)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
{bestMonth.orders.toLocaleString()} orders
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{/* Orders Card */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex justify-between items-start">
|
|
<CardTitle className="text-sm font-medium">
|
|
Total Orders
|
|
</CardTitle>
|
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{analyticsData?.orders?.total?.toLocaleString() || "0"}
|
|
</div>
|
|
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
|
<span>Today: {analyticsData?.orders?.totalToday || 0}</span>
|
|
<TrendIndicator
|
|
current={analyticsData?.orders?.totalToday || 0}
|
|
previous={(analyticsData?.orders?.total || 0) / 30}
|
|
/>
|
|
</div>
|
|
|
|
{loading || refreshing ? (
|
|
<div className="mt-3 h-12 flex items-center justify-center">
|
|
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
|
</div>
|
|
) : analyticsData?.orders?.dailyOrders &&
|
|
analyticsData.orders.dailyOrders.length > 0 ? (
|
|
<div className="mt-3 h-12">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<RechartsBarChart
|
|
data={transformChartData(
|
|
analyticsData.orders.dailyOrders,
|
|
"count",
|
|
)}
|
|
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
|
>
|
|
<Bar dataKey="value" fill="#3b82f6" radius={[2, 2, 0, 0]} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
</RechartsBarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<div className="mt-3 text-xs text-muted-foreground">
|
|
No chart data available
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Revenue Card */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex justify-between items-start">
|
|
<CardTitle className="text-sm font-medium">
|
|
Total Revenue
|
|
</CardTitle>
|
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{formatCurrency(analyticsData?.revenue?.total || 0)}
|
|
</div>
|
|
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
|
<span>
|
|
Today: {formatCurrency(analyticsData?.revenue?.today || 0)}
|
|
</span>
|
|
<TrendIndicator
|
|
current={analyticsData?.revenue?.today || 0}
|
|
previous={(analyticsData?.revenue?.total || 0) / 30} // Rough estimate
|
|
/>
|
|
</div>
|
|
|
|
{loading || refreshing ? (
|
|
<div className="mt-3 h-12 flex items-center justify-center">
|
|
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
|
</div>
|
|
) : analyticsData?.revenue?.dailyRevenue &&
|
|
analyticsData.revenue.dailyRevenue.length > 0 ? (
|
|
<div className="mt-3 h-12">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<RechartsBarChart
|
|
data={transformChartData(
|
|
analyticsData.revenue.dailyRevenue,
|
|
"amount",
|
|
)}
|
|
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
|
>
|
|
<Bar dataKey="value" fill="#10b981" radius={[2, 2, 0, 0]} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
</RechartsBarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<div className="mt-3 text-xs text-muted-foreground">
|
|
No chart data available
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Vendors Card */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex justify-between items-start">
|
|
<CardTitle className="text-sm font-medium">Vendors</CardTitle>
|
|
<Users className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{analyticsData?.vendors?.total?.toLocaleString() || "0"}
|
|
</div>
|
|
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
|
<span>Active: {analyticsData?.vendors?.active || 0}</span>
|
|
<span className="ml-2">
|
|
Stores: {analyticsData?.vendors?.activeStores || 0}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
|
<span>New Today: {analyticsData?.vendors?.newToday || 0}</span>
|
|
<TrendIndicator
|
|
current={analyticsData?.vendors?.newToday || 0}
|
|
previous={(analyticsData?.vendors?.newThisWeek || 0) / 7} // Average per day
|
|
/>
|
|
</div>
|
|
|
|
{loading || refreshing ? (
|
|
<div className="mt-3 h-12 flex items-center justify-center">
|
|
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full"></div>
|
|
</div>
|
|
) : analyticsData?.vendors?.dailyGrowth &&
|
|
analyticsData.vendors.dailyGrowth.length > 0 ? (
|
|
<div className="mt-3 h-12">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<RechartsBarChart
|
|
data={transformChartData(
|
|
analyticsData.vendors.dailyGrowth,
|
|
"count",
|
|
)}
|
|
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
|
>
|
|
<Bar dataKey="value" fill="#8b5cf6" radius={[2, 2, 0, 0]} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
</RechartsBarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<div className="mt-3 text-xs text-muted-foreground">
|
|
No chart data available
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Products Card */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex justify-between items-start">
|
|
<CardTitle className="text-sm font-medium">Products</CardTitle>
|
|
<Package className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{analyticsData?.products?.total?.toLocaleString() || "0"}
|
|
</div>
|
|
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
|
<span>New This Week: {analyticsData?.products?.recent || 0}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Tabs defaultValue="orders" className="mt-6">
|
|
<TabsList>
|
|
<TabsTrigger value="orders">Orders</TabsTrigger>
|
|
<TabsTrigger value="vendors">Vendors</TabsTrigger>
|
|
<TabsTrigger value="growth">Growth Since Launch</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="orders" className="mt-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Order Trends</CardTitle>
|
|
<CardDescription>
|
|
Daily order volume and revenue processed over the selected time
|
|
period
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading || refreshing ? (
|
|
<div className="flex items-center justify-center h-80">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Loading chart data...
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : 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 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis
|
|
dataKey="formattedDate"
|
|
tick={{ fontSize: 12 }}
|
|
angle={-45}
|
|
textAnchor="end"
|
|
height={60}
|
|
/>
|
|
<YAxis
|
|
yAxisId="left"
|
|
tick={{ fontSize: 12 }}
|
|
label={{
|
|
value: "Orders",
|
|
angle: -90,
|
|
position: "insideLeft",
|
|
}}
|
|
/>
|
|
<YAxis
|
|
yAxisId="right"
|
|
orientation="right"
|
|
tick={{ fontSize: 12 }}
|
|
tickFormatter={(value) =>
|
|
`£${(value / 1000).toFixed(0)}k`
|
|
}
|
|
label={{
|
|
value: "Revenue / AOV",
|
|
angle: 90,
|
|
position: "insideRight",
|
|
}}
|
|
/>
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Bar
|
|
yAxisId="left"
|
|
dataKey="orders"
|
|
fill="#3b82f6"
|
|
radius={[2, 2, 0, 0]}
|
|
name="Orders"
|
|
/>
|
|
<Line
|
|
yAxisId="right"
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke="#10b981"
|
|
strokeWidth={2}
|
|
dot={{ fill: "#10b981", r: 4 }}
|
|
name="Revenue"
|
|
/>
|
|
<Line
|
|
yAxisId="right"
|
|
type="monotone"
|
|
dataKey="avgOrderValue"
|
|
stroke="#a855f7"
|
|
strokeWidth={2}
|
|
strokeDasharray="5 5"
|
|
dot={{ fill: "#a855f7", r: 3 }}
|
|
name="Avg Order Value"
|
|
/>
|
|
</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">
|
|
{formatCurrency(
|
|
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">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Vendor Growth</CardTitle>
|
|
<CardDescription>
|
|
New vendor registrations over time
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading || refreshing ? (
|
|
<div className="flex items-center justify-center h-80">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Loading chart data...
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : analyticsData?.vendors?.dailyGrowth &&
|
|
analyticsData.vendors.dailyGrowth.length > 0 ? (
|
|
<div className="h-80">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<RechartsBarChart
|
|
data={transformChartData(
|
|
analyticsData.vendors.dailyGrowth,
|
|
"count",
|
|
)}
|
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis
|
|
dataKey="formattedDate"
|
|
tick={{ fontSize: 12 }}
|
|
angle={-45}
|
|
textAnchor="end"
|
|
height={60}
|
|
/>
|
|
<YAxis tick={{ fontSize: 12 }} />
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Bar
|
|
dataKey="value"
|
|
fill="#8b5cf6"
|
|
radius={[2, 2, 0, 0]}
|
|
/>
|
|
</RechartsBarChart>
|
|
</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">
|
|
{analyticsData?.vendors?.total?.toLocaleString() || "0"}
|
|
</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">
|
|
{analyticsData?.vendors?.active?.toLocaleString() || "0"}
|
|
</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">
|
|
{analyticsData?.vendors?.activeStores?.toLocaleString() ||
|
|
"0"}
|
|
</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">
|
|
{analyticsData?.vendors?.newThisWeek?.toLocaleString() ||
|
|
"0"}
|
|
</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">
|
|
{formatCurrency(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 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Total Orders
|
|
</div>
|
|
<div className="text-2xl font-bold">
|
|
{growthData.cumulative.orders.toLocaleString()}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Total Revenue
|
|
</div>
|
|
<div className="text-2xl font-bold text-green-600">
|
|
{formatCurrency(growthData.cumulative.revenue)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Customers
|
|
</div>
|
|
<div className="text-2xl font-bold">
|
|
{growthData.cumulative.customers.toLocaleString()}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Vendors
|
|
</div>
|
|
<div className="text-2xl font-bold">
|
|
{growthData.cumulative.vendors.toLocaleString()}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Products
|
|
</div>
|
|
<div className="text-2xl font-bold">
|
|
{growthData.cumulative.products.toLocaleString()}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Avg Order Value
|
|
</div>
|
|
<div className="text-2xl font-bold">
|
|
{formatCurrency(growthData.cumulative.avgOrderValue)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Monthly Revenue & Orders Chart */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Monthly Revenue & Orders</CardTitle>
|
|
<CardDescription>
|
|
Platform performance by month since launch
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{growthLoading ? (
|
|
<div className="flex items-center justify-center h-80">
|
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
|
</div>
|
|
) : 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 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="formattedMonth" tick={{ fontSize: 12 }} />
|
|
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
|
|
<YAxis
|
|
yAxisId="right"
|
|
orientation="right"
|
|
tick={{ fontSize: 12 }}
|
|
tickFormatter={(value) =>
|
|
`£${(value / 1000).toFixed(0)}k`
|
|
}
|
|
/>
|
|
<Tooltip
|
|
content={({ active, payload }) => {
|
|
if (active && payload?.length) {
|
|
const data = payload[0].payload;
|
|
return (
|
|
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
|
<p className="font-medium mb-2">{data.month}</p>
|
|
<p className="text-sm text-blue-600">
|
|
Orders: {data.orders.toLocaleString()}
|
|
</p>
|
|
<p className="text-sm text-green-600">
|
|
Revenue: {formatCurrency(data.revenue)}
|
|
</p>
|
|
<p className="text-sm text-purple-600">
|
|
Customers: {data.customers.toLocaleString()}
|
|
</p>
|
|
<p className="text-sm text-amber-600">
|
|
New Vendors: {data.newVendors}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
/>
|
|
<Bar
|
|
yAxisId="left"
|
|
dataKey="orders"
|
|
fill="#3b82f6"
|
|
radius={[4, 4, 0, 0]}
|
|
name="Orders"
|
|
/>
|
|
<Line
|
|
yAxisId="right"
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke="#10b981"
|
|
strokeWidth={3}
|
|
dot={{ fill: "#10b981", r: 4 }}
|
|
name="Revenue"
|
|
/>
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center h-80 text-muted-foreground">
|
|
No growth data available
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Customer Segments Pie Chart */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Customer Segments</CardTitle>
|
|
<CardDescription>Breakdown by purchase behavior</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{growthLoading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
|
</div>
|
|
) : growthData?.customers ? (
|
|
<div className="h-64 min-w-0">
|
|
<ResponsiveContainer key={growthData?.customers ? 'ready' : 'loading'} width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
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={50}
|
|
outerRadius={80}
|
|
paddingAngle={2}
|
|
dataKey="value"
|
|
label={({ name, percent }) =>
|
|
`${name}: ${(percent * 100).toFixed(0)}%`
|
|
}
|
|
labelLine={false}
|
|
>
|
|
{[
|
|
{ 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>
|
|
<Tooltip
|
|
content={({ active, payload }) => {
|
|
if (active && payload?.length) {
|
|
const data = payload[0].payload;
|
|
const details =
|
|
growthData.customers.segmentDetails[
|
|
data.name.split(" ")[0].toLowerCase()
|
|
];
|
|
return (
|
|
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
|
<p className="font-medium">{data.name}</p>
|
|
<p className="text-sm">
|
|
Count: {data.value.toLocaleString()}
|
|
</p>
|
|
{details && (
|
|
<>
|
|
<p className="text-sm text-green-600">
|
|
Revenue:{" "}
|
|
{formatCurrency(details.totalRevenue)}
|
|
</p>
|
|
<p className="text-sm">
|
|
Avg Orders: {details.avgOrderCount}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
|
No customer data available
|
|
</div>
|
|
)}
|
|
|
|
{/* Segment Stats */}
|
|
{growthData?.customers && (
|
|
<div className="grid grid-cols-2 gap-2 mt-4">
|
|
<div className="p-2 rounded bg-blue-500/10 text-center">
|
|
<div className="text-lg font-bold text-blue-600">
|
|
{growthData.customers.segments.new}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">New</div>
|
|
</div>
|
|
<div className="p-2 rounded bg-green-500/10 text-center">
|
|
<div className="text-lg font-bold text-green-600">
|
|
{growthData.customers.segments.returning}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Returning
|
|
</div>
|
|
</div>
|
|
<div className="p-2 rounded bg-amber-500/10 text-center">
|
|
<div className="text-lg font-bold text-amber-600">
|
|
{growthData.customers.segments.loyal}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">Loyal</div>
|
|
</div>
|
|
<div className="p-2 rounded bg-purple-500/10 text-center">
|
|
<div className="text-lg font-bold text-purple-600">
|
|
{growthData.customers.segments.vip}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">VIP</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Monthly Growth Table */}
|
|
{growthData?.monthly && growthData.monthly.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Monthly Breakdown</CardTitle>
|
|
<CardDescription>Detailed metrics by month</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="text-left p-2 font-medium">Month</th>
|
|
<th className="text-right p-2 font-medium">Orders</th>
|
|
<th className="text-right p-2 font-medium">Revenue</th>
|
|
<th className="text-right p-2 font-medium">
|
|
Customers
|
|
</th>
|
|
<th className="text-right p-2 font-medium">
|
|
Avg Order
|
|
</th>
|
|
<th className="text-right p-2 font-medium">
|
|
New Vendors
|
|
</th>
|
|
<th className="text-right p-2 font-medium">
|
|
New Customers
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{growthData.monthly.map((month) => (
|
|
<tr
|
|
key={month.month}
|
|
className="border-b hover:bg-muted/50"
|
|
>
|
|
<td className="p-2 font-medium">
|
|
{new Date(month.month + "-01").toLocaleDateString(
|
|
"en-GB",
|
|
{ month: "long", year: "numeric" },
|
|
)}
|
|
</td>
|
|
<td className="text-right p-2">
|
|
{month.orders.toLocaleString()}
|
|
</td>
|
|
<td className="text-right p-2 text-green-600">
|
|
{formatCurrency(month.revenue)}
|
|
</td>
|
|
<td className="text-right p-2">
|
|
{month.customers.toLocaleString()}
|
|
</td>
|
|
<td className="text-right p-2">
|
|
{formatCurrency(month.avgOrderValue)}
|
|
</td>
|
|
<td className="text-right p-2">{month.newVendors}</td>
|
|
<td className="text-right p-2">
|
|
{month.newCustomers}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|