Files
ember-market-frontend/components/analytics/GrowthAnalyticsChart.tsx
g f17d623570 Revamp growth analytics to show all-time cumulative data
Refactors GrowthAnalyticsChart to display all-time growth since first sale, removes period selection, and introduces tabbed charts for daily, monthly, and customer segment analytics. Updates the GrowthAnalytics interface and service to return cumulative and segmented data, and simplifies API usage to always fetch all-time analytics. Improves customer segment breakdown and chart visualizations.
2026-01-07 12:58:52 +00:00

625 lines
21 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import {
Users,
ShoppingCart,
DollarSign,
Package,
RefreshCw,
Calendar,
TrendingUp,
} from "lucide-react";
import {
getGrowthAnalyticsWithStore,
type GrowthAnalytics,
} from "@/lib/services/analytics-service";
import { formatGBP } from "@/utils/format";
import {
ComposedChart,
Bar,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
BarChart,
Legend,
} from "recharts";
interface GrowthAnalyticsChartProps {
hideNumbers?: boolean;
}
const SEGMENT_COLORS = {
new: "#3b82f6",
returning: "#10b981",
loyal: "#f59e0b",
vip: "#8b5cf6",
};
const SEGMENT_LABELS = {
new: "New (1 order)",
returning: "Returning (2-3 orders)",
loyal: "Loyal (4+ orders or £300+)",
vip: "VIP (10+ orders or £1000+)",
};
export default function GrowthAnalyticsChart({
hideNumbers = false,
}: GrowthAnalyticsChartProps) {
const [data, setData] = useState<GrowthAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const { toast } = useToast();
const fetchData = async () => {
try {
setLoading(true);
const response = await getGrowthAnalyticsWithStore();
setData(response);
} catch (err) {
console.error("Error fetching growth data:", err);
toast({
title: "Error",
description: "Failed to load growth analytics data.",
variant: "destructive",
});
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleRefresh = () => {
setRefreshing(true);
fetchData();
};
const formatCurrency = (value: number) => {
if (hideNumbers) return "£***";
return new Intl.NumberFormat("en-GB", {
style: "currency",
currency: "GBP",
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number) => {
if (hideNumbers) return "***";
return value.toLocaleString();
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
};
const getDaysSinceLaunch = () => {
if (!data?.launchDate) return 0;
const launch = new Date(data.launchDate);
const now = new Date();
return Math.floor(
(now.getTime() - launch.getTime()) / (1000 * 60 * 60 * 24),
);
};
if (loading && !data) {
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>
);
}
if (!data) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center text-muted-foreground">
No growth data available. Complete your first sale to see analytics.
</div>
</CardContent>
</Card>
);
}
// Prepare chart data
const recentDaily = data.daily.slice(-30); // Last 30 days for daily chart
// Prepare segment pie chart data
const segmentData = Object.entries(data.customers.segments)
.filter(([_, value]) => value > 0)
.map(([key, value]) => ({
name: key.charAt(0).toUpperCase() + key.slice(1),
value,
color: SEGMENT_COLORS[key as keyof typeof SEGMENT_COLORS],
label: SEGMENT_LABELS[key as keyof typeof SEGMENT_LABELS],
}));
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload?.length) {
const item = payload[0].payload;
return (
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
<p className="font-medium mb-2">{item.date || item.month}</p>
<p className="text-sm text-blue-600">
Orders: {hideNumbers ? "***" : item.orders?.toLocaleString()}
</p>
<p className="text-sm text-green-600">
Revenue: {hideNumbers ? "£***" : formatGBP(item.revenue)}
</p>
<p className="text-sm text-purple-600">
Customers: {hideNumbers ? "***" : item.customers}
</p>
{item.avgOrderValue && (
<p className="text-sm text-orange-600">
Avg Order: {hideNumbers ? "£***" : formatGBP(item.avgOrderValue)}
</p>
)}
</div>
);
}
return null;
};
const MonthlyTooltip = ({ active, payload }: any) => {
if (active && payload?.length) {
const item = payload[0].payload;
return (
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
<p className="font-medium mb-2">{item.month}</p>
<p className="text-sm text-blue-600">
Orders: {hideNumbers ? "***" : item.orders?.toLocaleString()}
</p>
<p className="text-sm text-green-600">
Revenue: {hideNumbers ? "£***" : formatGBP(item.revenue)}
</p>
<p className="text-sm text-purple-600">
Customers: {hideNumbers ? "***" : item.customers}
</p>
<p className="text-sm text-cyan-600">
New Customers: {hideNumbers ? "***" : item.newCustomers}
</p>
</div>
);
}
return null;
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Growth Since First Sale
</h3>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Calendar className="h-3 w-3" />
Started {formatDate(data.launchDate)} ({getDaysSinceLaunch()} days
ago)
</p>
</div>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
</div>
{/* Cumulative Summary Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
<Card>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-sm font-medium">
Total Orders
</CardTitle>
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(data.cumulative.orders)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-sm font-medium">
Total Revenue
</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{formatCurrency(data.cumulative.revenue)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-sm font-medium">
Total Customers
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(data.cumulative.customers)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-sm font-medium">Products</CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(data.cumulative.products)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-sm font-medium">Avg Order</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(data.cumulative.avgOrderValue)}
</div>
</CardContent>
</Card>
</div>
{/* Tabbed Charts */}
<Tabs defaultValue="daily" className="space-y-4">
<TabsList>
<TabsTrigger value="daily">Daily (Last 30 Days)</TabsTrigger>
<TabsTrigger value="monthly">Monthly Growth</TabsTrigger>
<TabsTrigger value="customers">Customer Segments</TabsTrigger>
</TabsList>
{/* Daily Chart */}
<TabsContent value="daily">
<Card>
<CardHeader>
<CardTitle>Daily Orders & Revenue</CardTitle>
<CardDescription>Last 30 days of activity</CardDescription>
</CardHeader>
<CardContent>
{loading || refreshing ? (
<div className="flex items-center justify-center h-80">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
</div>
) : recentDaily.length > 0 ? (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={recentDaily}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tick={{ fontSize: 11 }}
angle={-45}
textAnchor="end"
height={60}
tickFormatter={(v) => {
const d = new Date(v);
return `${d.getDate()}/${d.getMonth() + 1}`;
}}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 12 }}
tickFormatter={(v) => (hideNumbers ? "***" : v)}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12 }}
tickFormatter={(v) =>
hideNumbers ? "***" : `£${(v / 1000).toFixed(0)}k`
}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
<Bar
yAxisId="left"
dataKey="orders"
fill="#3b82f6"
radius={[4, 4, 0, 0]}
name="Orders"
/>
<Line
yAxisId="right"
type="monotone"
dataKey="revenue"
stroke="#10b981"
strokeWidth={2}
dot={false}
name="Revenue"
/>
</ComposedChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex items-center justify-center h-80 text-muted-foreground">
No daily data available yet
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Monthly Chart */}
<TabsContent value="monthly">
<Card>
<CardHeader>
<CardTitle>Monthly Growth</CardTitle>
<CardDescription>
Orders, revenue, and new customers by month
</CardDescription>
</CardHeader>
<CardContent>
{loading || refreshing ? (
<div className="flex items-center justify-center h-80">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
</div>
) : data.monthly.length > 0 ? (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={data.monthly}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="month"
tick={{ fontSize: 12 }}
tickFormatter={(v) => {
const [year, month] = v.split("-");
const date = new Date(
parseInt(year),
parseInt(month) - 1,
);
return date.toLocaleDateString("en-GB", {
month: "short",
year: "2-digit",
});
}}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 12 }}
tickFormatter={(v) => (hideNumbers ? "***" : v)}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12 }}
tickFormatter={(v) =>
hideNumbers ? "***" : `£${(v / 1000).toFixed(0)}k`
}
/>
<Tooltip content={<MonthlyTooltip />} />
<Legend />
<Bar
yAxisId="left"
dataKey="orders"
fill="#3b82f6"
name="Orders"
/>
<Bar
yAxisId="left"
dataKey="newCustomers"
fill="#06b6d4"
name="New Customers"
/>
<Line
yAxisId="right"
type="monotone"
dataKey="revenue"
stroke="#10b981"
strokeWidth={3}
dot={{ fill: "#10b981", r: 4 }}
name="Revenue"
/>
</ComposedChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex items-center justify-center h-80 text-muted-foreground">
No monthly data available yet
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Customer Segments */}
<TabsContent value="customers">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Pie Chart */}
<Card>
<CardHeader>
<CardTitle>Customer Segments</CardTitle>
<CardDescription>
Breakdown by purchase behavior
</CardDescription>
</CardHeader>
<CardContent>
{segmentData.length > 0 ? (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={segmentData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
label={({ name, percent }) =>
hideNumbers
? name
: `${name} ${(percent * 100).toFixed(0)}%`
}
>
{segmentData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value: number) =>
hideNumbers ? "***" : value
}
/>
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex items-center justify-center h-64 text-muted-foreground">
No customer data yet
</div>
)}
</CardContent>
</Card>
{/* Segment Details */}
<Card>
<CardHeader>
<CardTitle>Segment Details</CardTitle>
<CardDescription>Customer value by segment</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{Object.entries(data.customers.segments).map(
([segment, count]) => {
const details =
data.customers.segmentDetails[segment] || {};
const percentage =
data.customers.segmentPercentages[
segment as keyof typeof data.customers.segmentPercentages
] || 0;
return (
<div
key={segment}
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
>
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{
backgroundColor:
SEGMENT_COLORS[
segment as keyof typeof SEGMENT_COLORS
],
}}
/>
<div>
<div className="font-medium capitalize">
{segment}
</div>
<div className="text-xs text-muted-foreground">
{SEGMENT_LABELS[
segment as keyof typeof SEGMENT_LABELS
] || segment}
</div>
</div>
</div>
<div className="text-right">
<div className="font-semibold">
{hideNumbers ? "***" : count} customers
</div>
<div className="text-xs text-muted-foreground">
{hideNumbers ? "***" : `${percentage}%`} |{" "}
{hideNumbers
? "£***"
: formatGBP(details.totalRevenue || 0)}{" "}
revenue
</div>
</div>
</div>
);
},
)}
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-4 mt-6 pt-4 border-t">
<div className="text-center">
<div className="text-2xl font-bold">
{formatNumber(data.customers.total)}
</div>
<div className="text-xs text-muted-foreground">
Total Customers
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{formatCurrency(
data.cumulative.revenue / (data.customers.total || 1),
)}
</div>
<div className="text-xs text-muted-foreground">
Avg Revenue/Customer
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
);
}