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,324 +139,486 @@ 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">
|
<Button
|
||||||
<Select value={period} onValueChange={setPeriod}>
|
variant="outline"
|
||||||
<SelectTrigger className="w-[140px]">
|
size="icon"
|
||||||
<SelectValue />
|
onClick={handleRefresh}
|
||||||
</SelectTrigger>
|
disabled={refreshing}
|
||||||
<SelectContent>
|
>
|
||||||
<SelectItem value="7">Last 7 days</SelectItem>
|
<RefreshCw
|
||||||
<SelectItem value="30">Last 30 days</SelectItem>
|
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||||
<SelectItem value="90">Last 90 days</SelectItem>
|
/>
|
||||||
<SelectItem value="365">Last year</SelectItem>
|
</Button>
|
||||||
<SelectItem value="all">All time</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={refreshing}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
</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 */}
|
||||||
<Card>
|
<Tabs defaultValue="daily" className="space-y-4">
|
||||||
<CardHeader>
|
<TabsList>
|
||||||
<CardTitle>Orders & Revenue Trend</CardTitle>
|
<TabsTrigger value="daily">Daily (Last 30 Days)</TabsTrigger>
|
||||||
<CardDescription>
|
<TabsTrigger value="monthly">Monthly Growth</TabsTrigger>
|
||||||
Performance over the selected time period
|
<TabsTrigger value="customers">Customer Segments</TabsTrigger>
|
||||||
</CardDescription>
|
</TabsList>
|
||||||
</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 ? (
|
|
||||||
<div className="h-80">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<ComposedChart
|
|
||||||
data={timeSeries}
|
|
||||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
angle={-45}
|
|
||||||
textAnchor="end"
|
|
||||||
height={60}
|
|
||||||
/>
|
|
||||||
<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 />} />
|
|
||||||
<Bar
|
|
||||||
yAxisId="left"
|
|
||||||
dataKey="orders"
|
|
||||||
fill="#3b82f6"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
name="Orders"
|
|
||||||
/>
|
|
||||||
<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 data available for the selected period
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Customer Breakdown & Top Products */}
|
{/* Daily Chart */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<TabsContent value="daily">
|
||||||
{/* Customer Segments */}
|
<Card>
|
||||||
<Card>
|
<CardHeader>
|
||||||
<CardHeader>
|
<CardTitle>Daily Orders & Revenue</CardTitle>
|
||||||
<CardTitle>Customer Breakdown</CardTitle>
|
<CardDescription>Last 30 days of activity</CardDescription>
|
||||||
<CardDescription>New vs returning customers</CardDescription>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
{loading || refreshing ? (
|
||||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
<div className="flex items-center justify-center h-80">
|
||||||
<div className="bg-muted/50 p-3 rounded-lg text-center">
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{formatNumber(customerInsights.newCustomers)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">New</div>
|
) : recentDaily.length > 0 ? (
|
||||||
</div>
|
<div className="h-80">
|
||||||
<div className="bg-muted/50 p-3 rounded-lg text-center">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<div className="text-2xl font-bold text-green-600">
|
<ComposedChart
|
||||||
{formatNumber(customerInsights.returningCustomers)}
|
data={recentDaily}
|
||||||
</div>
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
<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>
|
|
||||||
Highest revenue growth vs previous period
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<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
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
? "***"
|
<XAxis
|
||||||
: `${product.revenueGrowth >= 0 ? "+" : ""}${product.revenueGrowth.toFixed(0)}%`}
|
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>
|
</div>
|
||||||
))}
|
<div className="text-center">
|
||||||
</div>
|
<div className="text-2xl font-bold text-green-600">
|
||||||
) : (
|
{formatCurrency(
|
||||||
<div className="text-center text-muted-foreground py-8">
|
data.cumulative.revenue / (data.customers.total || 1),
|
||||||
No product growth data available
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="text-xs text-muted-foreground">
|
||||||
</CardContent>
|
Avg Revenue/Customer
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</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,13 +284,11 @@ 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,
|
const storeId = getStoreIdForUser();
|
||||||
): Promise<GrowthAnalytics> => {
|
return getGrowthAnalytics(storeId);
|
||||||
const storeId = getStoreIdForUser();
|
};
|
||||||
return getGrowthAnalytics(period, granularity, storeId);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatGBP(value: number) {
|
export function formatGBP(value: number) {
|
||||||
return value.toLocaleString("en-GB", {
|
return value.toLocaleString("en-GB", {
|
||||||
|
|||||||
Reference in New Issue
Block a user