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.
This commit is contained in:
@@ -8,23 +8,17 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Users,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getGrowthAnalyticsWithStore,
|
||||
@@ -43,6 +37,8 @@ import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
BarChart,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
interface GrowthAnalyticsChartProps {
|
||||
@@ -56,19 +52,25 @@ const SEGMENT_COLORS = {
|
||||
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 [period, setPeriod] = useState("30");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getGrowthAnalyticsWithStore(period);
|
||||
const response = await getGrowthAnalyticsWithStore();
|
||||
setData(response);
|
||||
} catch (err) {
|
||||
console.error("Error fetching growth data:", err);
|
||||
@@ -85,7 +87,7 @@ export default function GrowthAnalyticsChart({
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [period]);
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
@@ -106,59 +108,22 @@ export default function GrowthAnalyticsChart({
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
const TrendIndicator = ({
|
||||
value,
|
||||
suffix = "%",
|
||||
}: {
|
||||
value: number;
|
||||
suffix?: string;
|
||||
}) => {
|
||||
if (hideNumbers) return <span className="text-muted-foreground">***</span>;
|
||||
|
||||
const isPositive = value > 0;
|
||||
const isNeutral = value === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center text-sm font-medium ${
|
||||
isNeutral
|
||||
? "text-muted-foreground"
|
||||
: isPositive
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 mr-1" />
|
||||
) : isNeutral ? null : (
|
||||
<TrendingDown className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isPositive ? "+" : ""}
|
||||
{value.toFixed(1)}
|
||||
{suffix}
|
||||
</div>
|
||||
);
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
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}</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.uniqueCustomers}
|
||||
</p>
|
||||
</div>
|
||||
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),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (loading && !data) {
|
||||
@@ -174,52 +139,91 @@ export default function GrowthAnalyticsChart({
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
No growth data available
|
||||
No growth data available. Complete your first sale to see analytics.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, customerInsights, timeSeries, topGrowingProducts } = data;
|
||||
// Prepare chart data
|
||||
const recentDaily = data.daily.slice(-30); // Last 30 days for daily chart
|
||||
|
||||
// Prepare pie chart data
|
||||
const segmentData = [
|
||||
{
|
||||
name: "New",
|
||||
value: customerInsights.newCustomers,
|
||||
color: SEGMENT_COLORS.new,
|
||||
},
|
||||
{
|
||||
name: "Returning",
|
||||
value: customerInsights.returningCustomers,
|
||||
color: SEGMENT_COLORS.returning,
|
||||
},
|
||||
];
|
||||
// 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">Store Growth</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.period.start} to {data.period.end} ({data.period.granularity})
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={period} onValueChange={setPeriod}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
<SelectItem value="365">Last year</SelectItem>
|
||||
<SelectItem value="all">All time</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -231,122 +235,124 @@ export default function GrowthAnalyticsChart({
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Orders */}
|
||||
{/* 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">Orders</CardTitle>
|
||||
<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(summary.currentPeriod.orders)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
vs {formatNumber(summary.previousPeriod.orders)} prev
|
||||
</span>
|
||||
<TrendIndicator value={summary.growthRates.orders} />
|
||||
{formatNumber(data.cumulative.orders)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Revenue */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-sm font-medium">Revenue</CardTitle>
|
||||
<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(summary.currentPeriod.revenue)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
vs {formatCurrency(summary.previousPeriod.revenue)} prev
|
||||
</span>
|
||||
<TrendIndicator value={summary.growthRates.revenue} />
|
||||
{formatCurrency(data.cumulative.revenue)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Avg Order Value */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-sm font-medium">Avg Order</CardTitle>
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(summary.currentPeriod.avgOrderValue)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
vs {formatCurrency(summary.previousPeriod.avgOrderValue)} prev
|
||||
</span>
|
||||
<TrendIndicator value={summary.growthRates.avgOrderValue} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Customers */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-sm font-medium">Customers</CardTitle>
|
||||
<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(summary.currentPeriod.customers)}
|
||||
{formatNumber(data.cumulative.customers)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
vs {formatNumber(summary.previousPeriod.customers)} prev
|
||||
</span>
|
||||
<TrendIndicator value={summary.growthRates.customers} />
|
||||
</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>
|
||||
|
||||
{/* Orders & Revenue Chart */}
|
||||
{/* 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>Orders & Revenue Trend</CardTitle>
|
||||
<CardDescription>
|
||||
Performance over the selected time period
|
||||
</CardDescription>
|
||||
<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>
|
||||
) : timeSeries.length > 0 ? (
|
||||
) : recentDaily.length > 0 ? (
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={timeSeries}
|
||||
data={recentDaily}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 12 }}
|
||||
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"
|
||||
@@ -362,6 +368,7 @@ export default function GrowthAnalyticsChart({
|
||||
}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="orders"
|
||||
@@ -369,6 +376,91 @@ export default function GrowthAnalyticsChart({
|
||||
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"
|
||||
@@ -383,115 +475,150 @@ export default function GrowthAnalyticsChart({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-80 text-muted-foreground">
|
||||
No data available for the selected period
|
||||
No monthly data available yet
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Customer Breakdown & Top Products */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Customer Segments */}
|
||||
<TabsContent value="customers">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Pie Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Customer Breakdown</CardTitle>
|
||||
<CardDescription>New vs returning customers</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="bg-muted/50 p-3 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{formatNumber(customerInsights.newCustomers)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">New</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 p-3 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{formatNumber(customerInsights.returningCustomers)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Returning</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 p-3 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(customerInsights.totalCustomers)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-muted/50 p-3 rounded-lg text-center">
|
||||
<div className="text-lg font-bold">
|
||||
{hideNumbers
|
||||
? "***"
|
||||
: customerInsights.avgOrdersPerCustomer.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Avg Orders/Customer
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 p-3 rounded-lg text-center">
|
||||
<div className="text-lg font-bold">
|
||||
{formatCurrency(customerInsights.avgSpentPerCustomer)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Avg Spent/Customer
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Growing Products */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Growing Products</CardTitle>
|
||||
<CardTitle>Customer Segments</CardTitle>
|
||||
<CardDescription>
|
||||
Highest revenue growth vs previous period
|
||||
Breakdown by purchase behavior
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{topGrowingProducts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{topGrowingProducts.slice(0, 5).map((product, index) => (
|
||||
<div
|
||||
key={product.productId}
|
||||
className="flex items-center justify-between p-2 bg-muted/30 rounded-lg"
|
||||
{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)}%`
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium truncate max-w-[150px]">
|
||||
{product.productName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatCurrency(product.currentPeriodRevenue)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-semibold ${
|
||||
product.revenueGrowth >= 0
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{hideNumbers
|
||||
? "***"
|
||||
: `${product.revenueGrowth >= 0 ? "+" : ""}${product.revenueGrowth.toFixed(0)}%`}
|
||||
</div>
|
||||
</div>
|
||||
{segmentData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number) =>
|
||||
hideNumbers ? "***" : value
|
||||
}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
No product growth data available
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,58 +87,53 @@ export interface OrderAnalytics {
|
||||
}
|
||||
|
||||
export interface GrowthAnalytics {
|
||||
period: {
|
||||
start: string;
|
||||
end: string;
|
||||
days: number;
|
||||
granularity: "daily" | "weekly" | "monthly";
|
||||
};
|
||||
summary: {
|
||||
currentPeriod: {
|
||||
revenue: number;
|
||||
orders: number;
|
||||
avgOrderValue: number;
|
||||
customers: number;
|
||||
};
|
||||
previousPeriod: {
|
||||
revenue: number;
|
||||
orders: number;
|
||||
avgOrderValue: number;
|
||||
customers: number;
|
||||
};
|
||||
growthRates: {
|
||||
revenue: number;
|
||||
orders: number;
|
||||
avgOrderValue: number;
|
||||
customers: number;
|
||||
};
|
||||
};
|
||||
customerInsights: {
|
||||
newCustomers: number;
|
||||
returningCustomers: number;
|
||||
totalCustomers: number;
|
||||
newCustomerRate: number;
|
||||
avgOrdersPerCustomer: number;
|
||||
avgSpentPerCustomer: number;
|
||||
};
|
||||
timeSeries: Array<{
|
||||
launchDate: string;
|
||||
generatedAt: string;
|
||||
daily: Array<{
|
||||
date: string;
|
||||
revenue: number;
|
||||
orders: number;
|
||||
revenue: number;
|
||||
customers: number;
|
||||
avgOrderValue: number;
|
||||
uniqueCustomers: number;
|
||||
cumulativeRevenue: number;
|
||||
cumulativeOrders: number;
|
||||
}>;
|
||||
topGrowingProducts: Array<{
|
||||
productId: string;
|
||||
productName: string;
|
||||
currentPeriodRevenue: number;
|
||||
previousPeriodRevenue: number;
|
||||
revenueGrowth: number;
|
||||
currentPeriodQuantity: number;
|
||||
previousPeriodQuantity: number;
|
||||
monthly: Array<{
|
||||
month: string;
|
||||
orders: number;
|
||||
revenue: number;
|
||||
customers: number;
|
||||
avgOrderValue: number;
|
||||
newCustomers: number;
|
||||
}>;
|
||||
customers: {
|
||||
total: number;
|
||||
segments: {
|
||||
new: number;
|
||||
returning: number;
|
||||
loyal: number;
|
||||
vip: number;
|
||||
};
|
||||
segmentDetails: {
|
||||
[key: string]: {
|
||||
count: number;
|
||||
totalRevenue: number;
|
||||
avgOrderCount: number;
|
||||
avgSpent: number;
|
||||
};
|
||||
};
|
||||
segmentPercentages: {
|
||||
new: number;
|
||||
returning: number;
|
||||
loyal: number;
|
||||
vip: number;
|
||||
};
|
||||
};
|
||||
cumulative: {
|
||||
orders: number;
|
||||
revenue: number;
|
||||
customers: number;
|
||||
products: number;
|
||||
avgOrderValue: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Analytics Service Functions
|
||||
@@ -223,21 +218,18 @@ export const getOrderAnalytics = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Get growth analytics data
|
||||
* @param period Time period: "7", "30", "90", "365", or "all" (default: "30")
|
||||
* @param granularity Data granularity: "daily", "weekly", "monthly" (auto-selected if not specified)
|
||||
* Get growth analytics data (since first order)
|
||||
* @param storeId Optional storeId for staff users
|
||||
*/
|
||||
export const getGrowthAnalytics = async (
|
||||
period: string = "30",
|
||||
granularity?: string,
|
||||
storeId?: string,
|
||||
): Promise<GrowthAnalytics> => {
|
||||
const params = new URLSearchParams({ period });
|
||||
if (granularity) params.append("granularity", granularity);
|
||||
const params = new URLSearchParams();
|
||||
if (storeId) params.append("storeId", storeId);
|
||||
|
||||
const url = `/analytics/growth?${params.toString()}`;
|
||||
const url = params.toString()
|
||||
? `/analytics/growth?${params.toString()}`
|
||||
: "/analytics/growth";
|
||||
return clientFetch<GrowthAnalytics>(url);
|
||||
};
|
||||
|
||||
@@ -292,12 +284,10 @@ export const getOrderAnalyticsWithStore = async (
|
||||
return getOrderAnalytics(period, storeId);
|
||||
};
|
||||
|
||||
export const getGrowthAnalyticsWithStore = async (
|
||||
period: string = "30",
|
||||
granularity?: string,
|
||||
): Promise<GrowthAnalytics> => {
|
||||
export const getGrowthAnalyticsWithStore =
|
||||
async (): Promise<GrowthAnalytics> => {
|
||||
const storeId = getStoreIdForUser();
|
||||
return getGrowthAnalytics(period, granularity, storeId);
|
||||
return getGrowthAnalytics(storeId);
|
||||
};
|
||||
|
||||
export function formatGBP(value: number) {
|
||||
|
||||
Reference in New Issue
Block a user