All checks were successful
Build Frontend / build (push) Successful in 1m19s
Refactored GrowthAnalyticsChart to use Area for 'orders' with gradient fill and improved dot handling. Enhanced PredictionsChart with consistent null checks for predictions data, improved tooltip rendering, and adjusted chart margins and axis styles. Updated RevenueChart to add activeDot styling for better interactivity.
355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { RefreshCw } from "lucide-react";
|
|
import {
|
|
getGrowthAnalyticsWithStore,
|
|
type GrowthAnalytics,
|
|
} from "@/lib/services/analytics-service";
|
|
import {
|
|
ComposedChart,
|
|
Bar,
|
|
Line,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
Area,
|
|
} from "recharts";
|
|
|
|
interface GrowthAnalyticsChartProps {
|
|
hideNumbers?: boolean;
|
|
}
|
|
|
|
export default function GrowthAnalyticsChart({
|
|
hideNumbers = false,
|
|
}: GrowthAnalyticsChartProps) {
|
|
const [growthData, setGrowthData] = useState<GrowthAnalytics | null>(null);
|
|
const [growthLoading, setGrowthLoading] = useState(true);
|
|
const { toast } = useToast();
|
|
|
|
const fetchGrowthData = async () => {
|
|
try {
|
|
setGrowthLoading(true);
|
|
const response = await getGrowthAnalyticsWithStore();
|
|
setGrowthData(response);
|
|
} catch (err) {
|
|
console.error("Error fetching growth data:", err);
|
|
toast({
|
|
title: "Error",
|
|
description: "Failed to load growth analytics data.",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setGrowthLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchGrowthData();
|
|
}, []);
|
|
|
|
const handleGrowthRefresh = () => {
|
|
fetchGrowthData();
|
|
};
|
|
|
|
const formatCurrency = (value: number) => {
|
|
if (hideNumbers) return "£***";
|
|
return new Intl.NumberFormat("en-GB", {
|
|
style: "currency",
|
|
currency: "GBP",
|
|
maximumFractionDigits: 0,
|
|
}).format(value);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Growth Header */}
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h3 className="text-lg font-semibold">Growth Since First Sale</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{growthData?.launchDate
|
|
? `Tracking since ${new Date(growthData.launchDate).toLocaleDateString("en-GB", { month: "long", year: "numeric" })}`
|
|
: "Loading..."}
|
|
{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 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Total Orders
|
|
</div>
|
|
<div className="text-2xl font-bold">
|
|
{hideNumbers
|
|
? "***"
|
|
: growthData.cumulative.orders.toLocaleString()}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Total Revenue
|
|
</div>
|
|
<div className="text-2xl font-bold text-green-600">
|
|
{formatCurrency(growthData.cumulative.revenue)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Customers
|
|
</div>
|
|
<div className="text-2xl font-bold">
|
|
{hideNumbers
|
|
? "***"
|
|
: growthData.cumulative.customers.toLocaleString()}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Products
|
|
</div>
|
|
<div className="text-2xl font-bold">
|
|
{hideNumbers
|
|
? "***"
|
|
: growthData.cumulative.products.toLocaleString()}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Avg Order Value
|
|
</div>
|
|
<div className="text-2xl font-bold">
|
|
{formatCurrency(growthData.cumulative.avgOrderValue)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Monthly Revenue & Orders Chart */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Monthly Revenue & Orders</CardTitle>
|
|
<CardDescription>
|
|
Store performance by month since first sale
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{growthLoading ? (
|
|
<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>
|
|
) : 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="colorRevenueGrowth" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.8} />
|
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="colorOrdersGrowth" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.6} />
|
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
|
<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="right"
|
|
orientation="right"
|
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tickFormatter={(value) =>
|
|
hideNumbers ? "***" : `£${(value / 1000).toFixed(0)}k`
|
|
}
|
|
/>
|
|
<Tooltip
|
|
cursor={{ fill: "transparent", stroke: "hsl(var(--muted-foreground))", strokeDasharray: "3 3" }}
|
|
content={({ active, payload }) => {
|
|
if (active && payload?.length) {
|
|
const data = payload[0].payload;
|
|
return (
|
|
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
|
<p className="font-medium mb-2">{data.month}</p>
|
|
<p className="text-sm text-blue-600">
|
|
Orders:{" "}
|
|
{hideNumbers
|
|
? "***"
|
|
: data.orders.toLocaleString()}
|
|
</p>
|
|
<p className="text-sm text-green-600">
|
|
Revenue: {formatCurrency(data.revenue)}
|
|
</p>
|
|
<p className="text-sm text-purple-600">
|
|
Customers:{" "}
|
|
{hideNumbers
|
|
? "***"
|
|
: data.customers.toLocaleString()}
|
|
</p>
|
|
{data.newCustomers !== undefined && (
|
|
<p className="text-sm text-cyan-600">
|
|
New Customers:{" "}
|
|
{hideNumbers ? "***" : data.newCustomers}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
/>
|
|
<Area
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="orders"
|
|
stroke="#3b82f6"
|
|
strokeWidth={2}
|
|
dot={false}
|
|
activeDot={{ r: 4, strokeWidth: 0 }}
|
|
name="Orders"
|
|
fill="url(#colorOrdersGrowth)"
|
|
/>
|
|
<Area
|
|
yAxisId="right"
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke="#10b981"
|
|
strokeWidth={3}
|
|
dot={false}
|
|
activeDot={{ r: 5, strokeWidth: 0 }}
|
|
name="Revenue"
|
|
fill="url(#colorRevenueGrowth)"
|
|
/>
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center h-80 text-muted-foreground">
|
|
No growth data available
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Monthly Growth Table */}
|
|
{growthData?.monthly && growthData.monthly.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Monthly Breakdown</CardTitle>
|
|
<CardDescription>Detailed metrics by month</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="text-left p-2 font-medium">Month</th>
|
|
<th className="text-right p-2 font-medium">Orders</th>
|
|
<th className="text-right p-2 font-medium">Revenue</th>
|
|
<th className="text-right p-2 font-medium">Customers</th>
|
|
<th className="text-right p-2 font-medium">Avg Order</th>
|
|
<th className="text-right p-2 font-medium">
|
|
New Customers
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{growthData.monthly.map((month) => (
|
|
<tr
|
|
key={month.month}
|
|
className="border-b hover:bg-muted/50"
|
|
>
|
|
<td className="p-2 font-medium">
|
|
{new Date(month.month + "-01").toLocaleDateString(
|
|
"en-GB",
|
|
{ month: "long", year: "numeric" },
|
|
)}
|
|
</td>
|
|
<td className="text-right p-2">
|
|
{hideNumbers ? "***" : month.orders.toLocaleString()}
|
|
</td>
|
|
<td className="text-right p-2 text-green-600">
|
|
{formatCurrency(month.revenue)}
|
|
</td>
|
|
<td className="text-right p-2">
|
|
{hideNumbers ? "***" : month.customers.toLocaleString()}
|
|
</td>
|
|
<td className="text-right p-2">
|
|
{formatCurrency(month.avgOrderValue)}
|
|
</td>
|
|
<td className="text-right p-2">
|
|
{hideNumbers ? "***" : (month.newCustomers ?? 0)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|