Enhance analytics charts with interactivity and skeletons
All checks were successful
Build Frontend / build (push) Successful in 1m16s

Added interactive active segment highlighting to the customer segments pie chart and improved the monthly revenue/orders chart with gradient areas and labeled axes. Replaced loading spinners with ChartSkeleton components for a more consistent loading state. Refactored SkeletonLoaders to accept className and improved code style.
This commit is contained in:
g
2026-01-13 06:05:52 +00:00
parent 66964a3218
commit 600ba1e10e
2 changed files with 153 additions and 78 deletions

View File

@@ -28,6 +28,7 @@ import {
DollarSign, DollarSign,
Package, Package,
Trophy, Trophy,
PieChart as PieChartIcon,
} from "lucide-react"; } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/common/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/common/alert";
import { fetchClient } from "@/lib/api/api-client"; import { fetchClient } from "@/lib/api/api-client";
@@ -46,7 +47,7 @@ import {
Area, Area,
} from "recharts"; } from "recharts";
import { formatGBP, formatNumber } from "@/lib/utils/format"; import { formatGBP, formatNumber } from "@/lib/utils/format";
import { PieChart, Pie, Cell, Legend } from "recharts"; import { PieChart, Pie, Cell, Legend, Sector } from "recharts";
import { AdminStatCard } from "./AdminStatCard"; import { AdminStatCard } from "./AdminStatCard";
import { TrendIndicator } from "./TrendIndicator"; import { TrendIndicator } from "./TrendIndicator";
import { import {
@@ -56,6 +57,81 @@ import {
TableSkeleton TableSkeleton
} from "../analytics/SkeletonLoaders"; } from "../analytics/SkeletonLoaders";
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.split(" ")[0]}
</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>
);
};
interface GrowthData { interface GrowthData {
launchDate: string; launchDate: string;
generatedAt: string; generatedAt: string;
@@ -190,6 +266,12 @@ export default function AdminAnalytics() {
const [growthData, setGrowthData] = useState<GrowthData | null>(null); const [growthData, setGrowthData] = useState<GrowthData | null>(null);
const [growthLoading, setGrowthLoading] = useState(false); const [growthLoading, setGrowthLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const onPieEnter = (_: any, index: number) => {
setActiveIndex(index);
};
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
// Segment colors for pie chart // Segment colors for pie chart
@@ -1180,9 +1262,13 @@ export default function AdminAnalytics() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{growthLoading ? ( {growthLoading ? (
<div className="flex items-center justify-center h-80"> <ChartSkeleton
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div> title="Monthly Revenue & Orders"
</div> 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 ? ( ) : growthData?.monthly && growthData.monthly.length > 0 ? (
<div className="h-80"> <div className="h-80">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@@ -1198,9 +1284,19 @@ export default function AdminAnalytics() {
}))} }))}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }} 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} /> <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} /> <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} /> <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 <YAxis
yAxisId="right" yAxisId="right"
orientation="right" orientation="right"
@@ -1209,9 +1305,10 @@ export default function AdminAnalytics() {
tickFormatter={(value) => tickFormatter={(value) =>
`£${(value / 1000).toFixed(0)}k` `£${(value / 1000).toFixed(0)}k`
} }
label={{ value: "Revenue", angle: 90, position: "insideRight", style: { fill: 'hsl(var(--muted-foreground))' } }}
/> />
<Tooltip <Tooltip
cursor={{ fill: 'hsl(var(--muted)/0.4)' }} cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }}
content={({ active, payload }) => { content={({ active, payload }) => {
if (active && payload?.length) { if (active && payload?.length) {
const data = payload[0].payload; const data = payload[0].payload;
@@ -1250,24 +1347,25 @@ export default function AdminAnalytics() {
return null; return null;
}} }}
/> />
<Bar <Area
yAxisId="left" yAxisId="left"
type="monotone"
dataKey="orders" dataKey="orders"
fill="#3b82f6" stroke="#3b82f6"
radius={[4, 4, 0, 0]} fill="url(#colorGrowthOrders)"
maxBarSize={50} strokeWidth={2}
name="Orders" name="Orders"
fillOpacity={0.8} activeDot={{ r: 4, strokeWidth: 0, fill: "#3b82f6" }}
/> />
<Line <Area
yAxisId="right" yAxisId="right"
type="monotone" type="monotone"
dataKey="revenue" dataKey="revenue"
stroke="#10b981" stroke="#10b981"
strokeWidth={3} fill="url(#colorGrowthRevenue)"
dot={{ fill: "#10b981", r: 4, strokeWidth: 2, stroke: "hsl(var(--background))" }} strokeWidth={2}
activeDot={{ r: 6, strokeWidth: 0 }}
name="Revenue" name="Revenue"
activeDot={{ r: 4, strokeWidth: 0, fill: "#10b981" }}
/> />
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -1288,15 +1386,21 @@ export default function AdminAnalytics() {
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex flex-col justify-center"> <CardContent className="flex-1 flex flex-col justify-center">
{growthLoading ? ( {growthLoading ? (
<div className="flex items-center justify-center h-64"> <ChartSkeleton
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div> title="Customer Segments"
</div> description="By purchase behavior"
icon={PieChartIcon}
showStats={false}
className="h-full border-0 shadow-none bg-transparent"
/>
) : growthData?.customers ? ( ) : growthData?.customers ? (
<> <>
<div className="h-64 min-w-0"> <div className="h-64 min-w-0">
<ResponsiveContainer key={growthData?.customers ? 'ready' : 'loading'} width="100%" height="100%"> <ResponsiveContainer key={growthData?.customers ? 'ready' : 'loading'} width="100%" height="100%">
<PieChart> <PieChart>
<Pie <Pie
activeIndex={activeIndex}
activeShape={renderActiveShape}
data={[ data={[
{ {
name: "New (1 order)", name: "New (1 order)",
@@ -1322,12 +1426,10 @@ export default function AdminAnalytics() {
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={60} innerRadius={60}
outerRadius={90} outerRadius={80}
paddingAngle={3} fill="#8884d8"
dataKey="value" dataKey="value"
label={({ percent }) => `${(percent * 100).toFixed(0)}%`} onMouseEnter={onPieEnter}
labelLine={false}
stroke="none"
> >
{[ {[
{ color: SEGMENT_COLORS.new }, { color: SEGMENT_COLORS.new },
@@ -1338,37 +1440,6 @@ export default function AdminAnalytics() {
<Cell key={`cell-${index}`} fill={entry.color} /> <Cell key={`cell-${index}`} fill={entry.color} />
))} ))}
</Pie> </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/95 border border-border/50 p-3 rounded-lg shadow-xl backdrop-blur-md">
<p className="font-semibold mb-1">{data.name}</p>
<p className="text-sm">
Count: <span className="font-mono">{data.value.toLocaleString()}</span>
</p>
{details && (
<>
<p className="text-sm text-green-600 font-medium">
Revenue:{" "}
{formatCurrency(details.totalRevenue)}
</p>
<p className="text-xs text-muted-foreground mt-1">
Avg Orders: {details.avgOrderCount}
</p>
</>
)}
</div>
);
}
return null;
}}
/>
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View File

@@ -1,20 +1,24 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
import { Skeleton } from "@/components/common/skeleton"; import { Skeleton } from "@/components/common/skeleton";
import { cn } from "@/lib/utils";
// Chart skeleton for revenue trends and order analytics // Chart skeleton for revenue trends and order analytics
export function ChartSkeleton({ export function ChartSkeleton({
title, title,
description, description,
icon: Icon, icon: Icon,
showStats = false showStats = false,
className,
}: { }: {
title: string; title: string;
description: string; description: string;
icon: any; icon: any;
showStats?: boolean; showStats?: boolean;
className?: string;
}) { }) {
return ( return (
<Card> <Card className={cn(className)}>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />