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