diff --git a/components/admin/AdminAnalytics.tsx b/components/admin/AdminAnalytics.tsx index 89647b5..7965d14 100644 --- a/components/admin/AdminAnalytics.tsx +++ b/components/admin/AdminAnalytics.tsx @@ -47,6 +47,14 @@ import { } from "recharts"; import { formatGBP, formatNumber } from "@/lib/utils/format"; import { PieChart, Pie, Cell, Legend } from "recharts"; +import { AdminStatCard } from "./AdminStatCard"; +import { TrendIndicator } from "./TrendIndicator"; +import { + ChartSkeleton, + CustomerInsightsSkeleton, + MetricsCardSkeleton, + TableSkeleton +} from "../analytics/SkeletonLoaders"; interface GrowthData { launchDate: string; @@ -417,33 +425,7 @@ export default function AdminAnalytics() { 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 ( -
= 0 ? "text-green-500" : "text-red-500"}`} - > - {percentChange >= 0 ? ( - - ) : ( - - )} - {Math.abs(percentChange).toFixed(1)}% -
- ); - }; // Format currency const formatCurrency = (value: number) => { @@ -707,234 +689,98 @@ export default function AdminAnalytics() {
{/* Orders Card */} - - -
- - Total Orders - -
- -
-
-
- -
- {formatNumber(analyticsData?.orders?.total)} -
-
- Today: {analyticsData?.orders?.totalToday || 0} -
- -
-
- - {loading || refreshing ? ( -
-
-
- ) : analyticsData?.orders?.dailyOrders && - analyticsData.orders.dailyOrders.length > 0 ? ( -
- - - - - - - - - - } cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} /> - - -
- ) : ( -
- No chart data -
- )} -
-
+ + Today: {analyticsData?.orders?.totalToday || 0} + + } + 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 */} - - -
- - Total Revenue - -
- -
-
-
- -
- {formatCurrency(analyticsData?.revenue?.total || 0)} -
-
- Today: {formatCurrency(analyticsData?.revenue?.today || 0)} -
- -
-
- - {loading || refreshing ? ( -
-
-
- ) : analyticsData?.revenue?.dailyRevenue && - analyticsData.revenue.dailyRevenue.length > 0 ? ( -
- - - - - - - - - - } cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} /> - - -
- ) : ( -
- No chart data -
- )} -
-
+ + Today: {formatCurrency(analyticsData?.revenue?.today || 0)} + + } + 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 */} - - -
- Vendors -
- -
-
-
- -
- {analyticsData?.vendors?.total?.toLocaleString() || "0"} -
-
- Active: {analyticsData?.vendors?.active || 0} - Stores: {analyticsData?.vendors?.activeStores || 0} -
-
- New: {analyticsData?.vendors?.newToday || 0} -
- -
-
- - {loading || refreshing ? ( -
-
-
- ) : analyticsData?.vendors?.dailyGrowth && - analyticsData.vendors.dailyGrowth.length > 0 ? ( -
- - - - - - - - - - } cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} /> - - -
- ) : ( -
- No chart data -
- )} -
-
+ New: {analyticsData?.vendors?.newToday || 0}} + 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" + > +
+ + Active: {analyticsData?.vendors?.active || 0} + + + Stores: {analyticsData?.vendors?.activeStores || 0} + +
+
{/* Products Card */} - - -
- Products -
- -
-
-
- -
- {formatNumber(analyticsData?.products?.total)} -
-
- New This Week: {analyticsData?.products?.recent || 0} -
- {/* Visual spacer since no chart here */} -
- Inventory Overview -
-
-
+
@@ -960,251 +806,290 @@ export default function AdminAnalytics() { - - - Order Trends - - Daily order volume and revenue processed over the selected time period - - - - {loading || refreshing ? ( -
-
-
-

- Loading chart data... -

-
-
- ) : analyticsData?.orders?.dailyOrders && - analyticsData.orders.dailyOrders.length > 0 ? ( -
- - - - - - - `£${(value / 1000).toFixed(0)}k` - } - label={{ - value: "Revenue / AOV", - angle: 90, - position: "insideRight", - }} - /> - } /> - - - - - -
- ) : ( -
- No order data available for the selected time period -
- )} - - {/* Calculate totals for the selected period */} - {analyticsData?.orders?.dailyOrders && - analyticsData?.revenue?.dailyRevenue && ( -
-
-
- Total Revenue -
-
- {formatCurrency( - analyticsData.revenue.dailyRevenue.reduce( - (sum, day) => sum + (day.amount || 0), - 0, - ), + {loading || refreshing ? ( + + ) : ( + + + + Order Trends + + + Daily order volume and revenue processed over the selected time + period + + + + {analyticsData?.orders?.dailyOrders && + analyticsData.orders.dailyOrders.length > 0 ? ( +
+ + -
-
-
- Total Orders -
-
- {analyticsData.orders.dailyOrders - .reduce((sum, day) => sum + (day.count || 0), 0) - .toLocaleString()} -
-
+ margin={{ top: 5, right: 30, left: 20, bottom: 5 }} + > + + + + + + + + + + + + + + + `£${(value / 1000).toFixed(0)}k` + } + label={{ + value: "Revenue", + angle: 90, + position: "insideRight", + style: { fill: 'hsl(var(--muted-foreground))' } + }} + /> + } cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} /> + + + + +
+ ) : ( +
+ No order data available for the selected time period
)} - - + + {/* Calculate totals for the selected period */} + {analyticsData?.orders?.dailyOrders && + analyticsData?.revenue?.dailyRevenue && ( +
+
+
+ Total Revenue +
+
+ {formatCurrency( + analyticsData.revenue.dailyRevenue.reduce( + (sum, day) => sum + (day.amount || 0), + 0, + ), + )} +
+
+
+
+ Total Orders +
+
+ {analyticsData.orders.dailyOrders + .reduce((sum, day) => sum + (day.count || 0), 0) + .toLocaleString()} +
+
+
+ )} + + + )} - - - Vendor Growth - - New vendor registrations over time - - - - {loading || refreshing ? ( -
-
-
-

- Loading chart data... -

+ {loading || refreshing ? ( + + ) : ( + + + + Vendor Growth + + New vendor registrations over time + + + {analyticsData?.vendors?.dailyGrowth && + analyticsData.vendors.dailyGrowth.length > 0 ? ( +
+ + + + + + + + + + + + + } /> + +
-
- ) : analyticsData?.vendors?.dailyGrowth && - analyticsData.vendors.dailyGrowth.length > 0 ? ( -
- - - - - - } /> - - - -
- ) : ( -
- No vendor data available for the selected time period -
- )} - -
-
-
Total Vendors
-
- {formatNumber(analyticsData?.vendors?.total)} -
-
-
-
Active Vendors
-
- {formatNumber(analyticsData?.vendors?.active)} -
-
-
-
Active Stores
-
- {formatNumber(analyticsData?.vendors?.activeStores)} -
-
-
-
New This Week
-
- {formatNumber(analyticsData?.vendors?.newThisWeek)} -
-
-
- - {/* Top Vendors by Revenue */} - {analyticsData?.vendors?.topVendors && - analyticsData.vendors.topVendors.length > 0 && ( -
-

- Top Vendors by Revenue -

-
- {analyticsData.vendors.topVendors.map((vendor, index) => ( -
-
-
- {index + 1} -
-
-
- {vendor.vendorName} -
-
- {vendor.orderCount} orders -
-
-
-
-
- {formatCurrency(vendor.totalRevenue)} -
-
-
- ))} -
+ ) : ( +
+ No vendor data available for the selected time period
)} - - + +
+
+
Total Vendors
+
+ {formatNumber(analyticsData?.vendors?.total)} +
+
+
+
Active Vendors
+
+ {formatNumber(analyticsData?.vendors?.active)} +
+
+
+
Active Stores
+
+ {formatNumber(analyticsData?.vendors?.activeStores)} +
+
+
+
New This Week
+
+ {formatNumber(analyticsData?.vendors?.newThisWeek)} +
+
+
+ + {/* Top Vendors by Revenue */} + {analyticsData?.vendors?.topVendors && + analyticsData.vendors.topVendors.length > 0 && ( +
+

+ Top Vendors by Revenue +

+
+ {analyticsData.vendors.topVendors.map((vendor, index) => ( +
+
+
+ {index + 1} +
+
+
+ {vendor.vendorName} +
+
+ {vendor.orderCount} orders +
+
+
+
+
+ {formatCurrency(vendor.totalRevenue)} +
+
+
+ ))} +
+
+ )} + + + )} diff --git a/components/admin/AdminStatCard.tsx b/components/admin/AdminStatCard.tsx new file mode 100644 index 0000000..0e0b555 --- /dev/null +++ b/components/admin/AdminStatCard.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/common/card"; +import { Skeleton } from "@/components/common/skeleton"; +import { Area, AreaChart, ResponsiveContainer, Tooltip, TooltipProps } from "recharts"; +import { TrendIndicator } from "./TrendIndicator"; +import { cn } from "@/lib/utils"; +import { LucideIcon } from "lucide-react"; +import { formatGBP } from "@/lib/utils/format"; + +interface ChartDataPoint { + formattedDate: string; + value: number; + orders?: number; + revenue?: number; + [key: string]: any; +} + +interface AdminStatCardProps { + title: string; + icon: LucideIcon; + iconColorClass: string; + iconBgClass: string; + value: string | number; + subtext?: React.ReactNode; + trend?: { + current: number; + previous: number; + }; + loading?: boolean; + chartData?: ChartDataPoint[]; + chartColor: string; + chartGradientId: string; + tooltipPrefix?: string; // "£" or "" + hideChart?: boolean; + children?: React.ReactNode; +} + +const CustomTooltip = ({ active, payload, label, prefix = "" }: TooltipProps & { prefix?: string }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

+ {data.formattedDate || label} +

+
+ + {prefix === "£" ? "Revenue" : "Count"} + + + {prefix}{prefix === "£" ? (data.value || 0).toFixed(2) : data.value} + +
+
+ ); + } + return null; +}; + +export function AdminStatCard({ + title, + icon: Icon, + iconColorClass, + iconBgClass, + value, + subtext, + trend, + loading, + chartData, + chartColor, + chartGradientId, + tooltipPrefix = "", + hideChart = false, + children, +}: AdminStatCardProps) { + if (loading) { + return ( + + +
+ + +
+
+ + +
+ + +
+ {!hideChart && } +
+
+ ); + } + + return ( + + +
+ + {title} + +
+ +
+
+
+ +
{value}
+ +
+ {subtext} + {trend && ( +
+ +
+ )} +
+ + {children &&
{children}
} + + {!hideChart && ( + chartData && chartData.length > 0 ? ( +
+ + + + + + + + + + } + cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }} + /> + + +
+ ) : ( +
+ No chart data +
+ ) + )} + + {/* Fill space if chart is hidden but we want structure consistency */} + {hideChart &&
} + + + ); +} diff --git a/components/admin/TrendIndicator.tsx b/components/admin/TrendIndicator.tsx new file mode 100644 index 0000000..bf6faa6 --- /dev/null +++ b/components/admin/TrendIndicator.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { TrendingDown, TrendingUp } from "lucide-react"; + +// Trend indicator component for metric cards +export 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 ( +
= 0 ? "text-green-500" : "text-red-500"}`} + > + {percentChange >= 0 ? ( + + ) : ( + + )} + {Math.abs(percentChange).toFixed(1)}% +
+ ); +}; diff --git a/public/git-info.json b/public/git-info.json index 408c62e..38e1d6a 100644 --- a/public/git-info.json +++ b/public/git-info.json @@ -1,4 +1,4 @@ { - "commitHash": "4c15f43", - "buildTime": "2026-01-13T05:27:43.269Z" + "commitHash": "a07ca55", + "buildTime": "2026-01-13T05:50:49.436Z" } \ No newline at end of file