All checks were successful
Build Frontend / build (push) Successful in 1m12s
Refactored helper functions (transformChartData, combineOrdersAndRevenue, CustomTooltip, formatCurrency, calculateBestMonth) to be defined outside the main component for improved readability and maintainability. Updated code formatting and indentation for consistency, with no changes to business logic.
1802 lines
71 KiB
TypeScript
1802 lines
71 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 md:grid-cols-3 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="bg-gradient-to-r from-blue-600 to-cyan-500 bg-clip-text text-transparent 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="bg-gradient-to-r from-purple-600 to-pink-500 bg-clip-text text-transparent 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="bg-gradient-to-r from-green-600 to-emerald-500 bg-clip-text text-transparent 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="bg-gradient-to-r from-amber-600 to-orange-500 bg-clip-text text-transparent 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="bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent 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>
|
|
);
|
|
}
|
|
|
|
|