Enhance analytics charts with interactivity and skeletons
All checks were successful
Build Frontend / build (push) Successful in 1m16s
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:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
}: {
|
||||||
description: string;
|
title: 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" />
|
||||||
@@ -26,7 +30,7 @@ export function ChartSkeleton({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Chart area */}
|
{/* Chart area */}
|
||||||
<div className="h-64 bg-muted/20 rounded-md animate-pulse" />
|
<div className="h-64 bg-muted/20 rounded-md animate-pulse" />
|
||||||
|
|
||||||
{/* Summary stats if applicable */}
|
{/* Summary stats if applicable */}
|
||||||
{showStats && (
|
{showStats && (
|
||||||
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
|
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
|
||||||
@@ -45,15 +49,15 @@ export function ChartSkeleton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Table skeleton for product performance
|
// Table skeleton for product performance
|
||||||
export function TableSkeleton({
|
export function TableSkeleton({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
rows = 5,
|
rows = 5,
|
||||||
columns = 5
|
columns = 5
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: any;
|
icon: any;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
columns?: number;
|
columns?: number;
|
||||||
@@ -75,7 +79,7 @@ export function TableSkeleton({
|
|||||||
<Skeleton key={i} className="h-4 w-full" />
|
<Skeleton key={i} className="h-4 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table rows */}
|
{/* Table rows */}
|
||||||
{[...Array(rows)].map((_, rowIndex) => (
|
{[...Array(rows)].map((_, rowIndex) => (
|
||||||
<div key={rowIndex} className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
|
<div key={rowIndex} className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
|
||||||
@@ -96,13 +100,13 @@ export function TableSkeleton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Customer insights skeleton with segments
|
// Customer insights skeleton with segments
|
||||||
export function CustomerInsightsSkeleton({
|
export function CustomerInsightsSkeleton({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon: Icon
|
icon: Icon
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: any;
|
icon: any;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -125,7 +129,7 @@ export function CustomerInsightsSkeleton({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top customers table */}
|
{/* Top customers table */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
|
|||||||
Reference in New Issue
Block a user