Update GrowthAnalyticsChart.tsx
This commit is contained in:
@@ -8,23 +8,13 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
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 { RefreshCw } from "lucide-react";
|
||||||
Users,
|
|
||||||
ShoppingCart,
|
|
||||||
DollarSign,
|
|
||||||
Package,
|
|
||||||
RefreshCw,
|
|
||||||
Calendar,
|
|
||||||
TrendingUp,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
getGrowthAnalyticsWithStore,
|
getGrowthAnalyticsWithStore,
|
||||||
type GrowthAnalytics,
|
type GrowthAnalytics,
|
||||||
} from "@/lib/services/analytics-service";
|
} from "@/lib/services/analytics-service";
|
||||||
import { formatGBP } from "@/utils/format";
|
|
||||||
import {
|
import {
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
Bar,
|
Bar,
|
||||||
@@ -37,8 +27,6 @@ import {
|
|||||||
PieChart,
|
PieChart,
|
||||||
Pie,
|
Pie,
|
||||||
Cell,
|
Cell,
|
||||||
BarChart,
|
|
||||||
Legend,
|
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
interface GrowthAnalyticsChartProps {
|
interface GrowthAnalyticsChartProps {
|
||||||
@@ -52,26 +40,18 @@ 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 [growthData, setGrowthData] = useState<GrowthAnalytics | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [growthLoading, setGrowthLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchGrowthData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setGrowthLoading(true);
|
||||||
const response = await getGrowthAnalyticsWithStore();
|
const response = await getGrowthAnalyticsWithStore();
|
||||||
setData(response);
|
setGrowthData(response);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching growth data:", err);
|
console.error("Error fetching growth data:", err);
|
||||||
toast({
|
toast({
|
||||||
@@ -80,18 +60,16 @@ export default function GrowthAnalyticsChart({
|
|||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setGrowthLoading(false);
|
||||||
setRefreshing(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchGrowthData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleGrowthRefresh = () => {
|
||||||
setRefreshing(true);
|
fetchGrowthData();
|
||||||
fetchData();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
@@ -103,522 +81,398 @@ export default function GrowthAnalyticsChart({
|
|||||||
}).format(value);
|
}).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatNumber = (value: number) => {
|
|
||||||
if (hideNumbers) return "***";
|
|
||||||
return value.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString("en-GB", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDaysSinceLaunch = () => {
|
|
||||||
if (!data?.launchDate) return 0;
|
|
||||||
const launch = new Date(data.launchDate);
|
|
||||||
const now = new Date();
|
|
||||||
return Math.floor(
|
|
||||||
(now.getTime() - launch.getTime()) / (1000 * 60 * 60 * 24),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading && !data) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center my-8">
|
|
||||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-center text-muted-foreground">
|
|
||||||
No growth data available. Complete your first sale to see analytics.
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare chart data
|
|
||||||
const recentDaily = data.daily.slice(-30); // Last 30 days for daily chart
|
|
||||||
|
|
||||||
// Prepare segment pie chart data
|
|
||||||
const segmentData = Object.entries(data.customers.segments)
|
|
||||||
.filter(([_, value]) => value > 0)
|
|
||||||
.map(([key, value]) => ({
|
|
||||||
name: key.charAt(0).toUpperCase() + key.slice(1),
|
|
||||||
value,
|
|
||||||
color: SEGMENT_COLORS[key as keyof typeof SEGMENT_COLORS],
|
|
||||||
label: SEGMENT_LABELS[key as keyof typeof SEGMENT_LABELS],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }: any) => {
|
|
||||||
if (active && payload?.length) {
|
|
||||||
const item = payload[0].payload;
|
|
||||||
return (
|
|
||||||
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
|
||||||
<p className="font-medium mb-2">{item.date || item.month}</p>
|
|
||||||
<p className="text-sm text-blue-600">
|
|
||||||
Orders: {hideNumbers ? "***" : item.orders?.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-green-600">
|
|
||||||
Revenue: {hideNumbers ? "£***" : formatGBP(item.revenue)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-purple-600">
|
|
||||||
Customers: {hideNumbers ? "***" : item.customers}
|
|
||||||
</p>
|
|
||||||
{item.avgOrderValue && (
|
|
||||||
<p className="text-sm text-orange-600">
|
|
||||||
Avg Order: {hideNumbers ? "£***" : formatGBP(item.avgOrderValue)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MonthlyTooltip = ({ active, payload }: any) => {
|
|
||||||
if (active && payload?.length) {
|
|
||||||
const item = payload[0].payload;
|
|
||||||
return (
|
|
||||||
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
|
||||||
<p className="font-medium mb-2">{item.month}</p>
|
|
||||||
<p className="text-sm text-blue-600">
|
|
||||||
Orders: {hideNumbers ? "***" : item.orders?.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-green-600">
|
|
||||||
Revenue: {hideNumbers ? "£***" : formatGBP(item.revenue)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-purple-600">
|
|
||||||
Customers: {hideNumbers ? "***" : item.customers}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-cyan-600">
|
|
||||||
New Customers: {hideNumbers ? "***" : item.newCustomers}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Growth Header */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
<h3 className="text-lg font-semibold">Growth Since First Sale</h3>
|
||||||
<TrendingUp className="h-5 w-5" />
|
<p className="text-sm text-muted-foreground">
|
||||||
Growth Since First Sale
|
{growthData?.launchDate
|
||||||
</h3>
|
? `Tracking since ${new Date(growthData.launchDate).toLocaleDateString("en-GB", { month: "long", year: "numeric" })}`
|
||||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
: "Loading..."}
|
||||||
<Calendar className="h-3 w-3" />
|
{growthData?.generatedAt && (
|
||||||
Started {formatDate(data.launchDate)} ({getDaysSinceLaunch()} days
|
<span className="ml-2">
|
||||||
ago)
|
- Last updated:{" "}
|
||||||
|
{new Date(growthData.generatedAt).toLocaleTimeString("en-GB", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="sm"
|
||||||
onClick={handleRefresh}
|
onClick={handleGrowthRefresh}
|
||||||
disabled={refreshing}
|
disabled={growthLoading}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
className={`h-4 w-4 mr-2 ${growthLoading ? "animate-spin" : ""}`}
|
||||||
/>
|
/>
|
||||||
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cumulative Summary Cards */}
|
{/* Cumulative Stats Cards */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
{growthData?.cumulative && (
|
||||||
<Card>
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
<CardHeader className="pb-2">
|
<Card>
|
||||||
<div className="flex justify-between items-start">
|
<CardContent className="pt-4">
|
||||||
<CardTitle className="text-sm font-medium">
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
Total Orders
|
Total Orders
|
||||||
</CardTitle>
|
</div>
|
||||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
<div className="text-2xl font-bold">
|
||||||
</div>
|
{hideNumbers
|
||||||
</CardHeader>
|
? "***"
|
||||||
<CardContent>
|
: growthData.cumulative.orders.toLocaleString()}
|
||||||
<div className="text-2xl font-bold">
|
</div>
|
||||||
{formatNumber(data.cumulative.orders)}
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</CardContent>
|
<Card>
|
||||||
</Card>
|
<CardContent className="pt-4">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Total Revenue
|
Total Revenue
|
||||||
</CardTitle>
|
</div>
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
<div className="text-2xl font-bold text-green-600">
|
||||||
</div>
|
{formatCurrency(growthData.cumulative.revenue)}
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
{formatCurrency(data.cumulative.revenue)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Total Customers
|
|
||||||
</CardTitle>
|
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(data.cumulative.customers)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<CardTitle className="text-sm font-medium">Products</CardTitle>
|
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatNumber(data.cumulative.products)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<CardTitle className="text-sm font-medium">Avg Order</CardTitle>
|
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatCurrency(data.cumulative.avgOrderValue)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabbed Charts */}
|
|
||||||
<Tabs defaultValue="daily" className="space-y-4">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="daily">Daily (Last 30 Days)</TabsTrigger>
|
|
||||||
<TabsTrigger value="monthly">Monthly Growth</TabsTrigger>
|
|
||||||
<TabsTrigger value="customers">Customer Segments</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Daily Chart */}
|
|
||||||
<TabsContent value="daily">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Daily Orders & Revenue</CardTitle>
|
|
||||||
<CardDescription>Last 30 days of activity</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading || refreshing ? (
|
|
||||||
<div className="flex items-center justify-center h-80">
|
|
||||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
) : recentDaily.length > 0 ? (
|
|
||||||
<div className="h-80">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<ComposedChart
|
|
||||||
data={recentDaily}
|
|
||||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tick={{ fontSize: 11 }}
|
|
||||||
angle={-45}
|
|
||||||
textAnchor="end"
|
|
||||||
height={60}
|
|
||||||
tickFormatter={(v) => {
|
|
||||||
const d = new Date(v);
|
|
||||||
return `${d.getDate()}/${d.getMonth() + 1}`;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="left"
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
tickFormatter={(v) => (hideNumbers ? "***" : v)}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="right"
|
|
||||||
orientation="right"
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
tickFormatter={(v) =>
|
|
||||||
hideNumbers ? "***" : `£${(v / 1000).toFixed(0)}k`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Legend />
|
|
||||||
<Bar
|
|
||||||
yAxisId="left"
|
|
||||||
dataKey="orders"
|
|
||||||
fill="#3b82f6"
|
|
||||||
radius={[4, 4, 0, 0]}
|
|
||||||
name="Orders"
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
yAxisId="right"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="revenue"
|
|
||||||
stroke="#10b981"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
name="Revenue"
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-80 text-muted-foreground">
|
|
||||||
No daily data available yet
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Monthly Chart */}
|
|
||||||
<TabsContent value="monthly">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="pt-4">
|
||||||
<CardTitle>Monthly Growth</CardTitle>
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
<CardDescription>
|
Customers
|
||||||
Orders, revenue, and new customers by month
|
</div>
|
||||||
</CardDescription>
|
<div className="text-2xl font-bold">
|
||||||
</CardHeader>
|
{hideNumbers
|
||||||
<CardContent>
|
? "***"
|
||||||
{loading || refreshing ? (
|
: growthData.cumulative.customers.toLocaleString()}
|
||||||
<div className="flex items-center justify-center h-80">
|
</div>
|
||||||
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
Products
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{hideNumbers
|
||||||
|
? "***"
|
||||||
|
: growthData.cumulative.products.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
Avg Order Value
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(growthData.cumulative.avgOrderValue)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Customer Segments */}
|
{/* Monthly Revenue & Orders Chart */}
|
||||||
<TabsContent value="customers">
|
<Card>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<CardHeader>
|
||||||
{/* Pie Chart */}
|
<CardTitle>Monthly Revenue & Orders</CardTitle>
|
||||||
<Card>
|
<CardDescription>
|
||||||
<CardHeader>
|
Store performance by month since first sale
|
||||||
<CardTitle>Customer Segments</CardTitle>
|
</CardDescription>
|
||||||
<CardDescription>
|
</CardHeader>
|
||||||
Breakdown by purchase behavior
|
<CardContent>
|
||||||
</CardDescription>
|
{growthLoading ? (
|
||||||
</CardHeader>
|
<div className="flex items-center justify-center h-80">
|
||||||
<CardContent>
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
||||||
{segmentData.length > 0 ? (
|
</div>
|
||||||
<div className="h-64">
|
) : growthData?.monthly && growthData.monthly.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<div className="h-80">
|
||||||
<PieChart>
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<Pie
|
<ComposedChart
|
||||||
data={segmentData}
|
data={growthData.monthly.map((m) => ({
|
||||||
cx="50%"
|
...m,
|
||||||
cy="50%"
|
formattedMonth: new Date(
|
||||||
innerRadius={60}
|
m.month + "-01",
|
||||||
outerRadius={80}
|
).toLocaleDateString("en-GB", {
|
||||||
paddingAngle={5}
|
month: "short",
|
||||||
dataKey="value"
|
year: "2-digit",
|
||||||
label={({ name, percent }) =>
|
}),
|
||||||
hideNumbers
|
}))}
|
||||||
? name
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
: `${name} ${(percent * 100).toFixed(0)}%`
|
>
|
||||||
}
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
>
|
<XAxis dataKey="formattedMonth" tick={{ fontSize: 12 }} />
|
||||||
{segmentData.map((entry, index) => (
|
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
<YAxis
|
||||||
))}
|
yAxisId="right"
|
||||||
</Pie>
|
orientation="right"
|
||||||
<Tooltip
|
tick={{ fontSize: 12 }}
|
||||||
formatter={(value: number) =>
|
tickFormatter={(value) =>
|
||||||
hideNumbers ? "***" : value
|
hideNumbers ? "***" : `£${(value / 1000).toFixed(0)}k`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</PieChart>
|
<Tooltip
|
||||||
</ResponsiveContainer>
|
content={({ active, payload }) => {
|
||||||
</div>
|
if (active && payload?.length) {
|
||||||
) : (
|
const data = payload[0].payload;
|
||||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
return (
|
||||||
No customer data yet
|
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
||||||
</div>
|
<p className="font-medium mb-2">{data.month}</p>
|
||||||
)}
|
<p className="text-sm text-blue-600">
|
||||||
</CardContent>
|
Orders:{" "}
|
||||||
</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
|
{hideNumbers
|
||||||
? "£***"
|
? "***"
|
||||||
: formatGBP(details.totalRevenue || 0)}{" "}
|
: data.orders.toLocaleString()}
|
||||||
revenue
|
</p>
|
||||||
</div>
|
<p className="text-sm text-green-600">
|
||||||
|
Revenue: {formatCurrency(data.revenue)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-purple-600">
|
||||||
|
Customers:{" "}
|
||||||
|
{hideNumbers
|
||||||
|
? "***"
|
||||||
|
: data.customers.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{data.newCustomers !== undefined && (
|
||||||
|
<p className="text-sm text-cyan-600">
|
||||||
|
New Customers:{" "}
|
||||||
|
{hideNumbers ? "***" : data.newCustomers}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
},
|
return null;
|
||||||
)}
|
}}
|
||||||
</div>
|
/>
|
||||||
|
<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 growth data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Summary Stats */}
|
{/* Customer Segments Pie Chart */}
|
||||||
<div className="grid grid-cols-2 gap-4 mt-6 pt-4 border-t">
|
<Card>
|
||||||
<div className="text-center">
|
<CardHeader>
|
||||||
<div className="text-2xl font-bold">
|
<CardTitle>Customer Segments</CardTitle>
|
||||||
{formatNumber(data.customers.total)}
|
<CardDescription>Breakdown by purchase behavior</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
<div className="text-xs text-muted-foreground">
|
<CardContent>
|
||||||
Total Customers
|
{growthLoading ? (
|
||||||
</div>
|
<div className="flex items-center justify-center h-64">
|
||||||
</div>
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
||||||
<div className="text-center">
|
</div>
|
||||||
<div className="text-2xl font-bold text-green-600">
|
) : growthData?.customers ? (
|
||||||
{formatCurrency(
|
<div className="h-64">
|
||||||
data.cumulative.revenue / (data.customers.total || 1),
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
)}
|
<PieChart>
|
||||||
</div>
|
<Pie
|
||||||
<div className="text-xs text-muted-foreground">
|
data={[
|
||||||
Avg Revenue/Customer
|
{
|
||||||
</div>
|
name: "New (1 order)",
|
||||||
</div>
|
value: growthData.customers.segments.new,
|
||||||
|
color: SEGMENT_COLORS.new,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Returning (2+)",
|
||||||
|
value: growthData.customers.segments.returning,
|
||||||
|
color: SEGMENT_COLORS.returning,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Loyal (£300+/4+)",
|
||||||
|
value: growthData.customers.segments.loyal,
|
||||||
|
color: SEGMENT_COLORS.loyal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VIP (£1k+/10+)",
|
||||||
|
value: growthData.customers.segments.vip,
|
||||||
|
color: SEGMENT_COLORS.vip,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, percent }) =>
|
||||||
|
hideNumbers
|
||||||
|
? name
|
||||||
|
: `${name}: ${(percent * 100).toFixed(0)}%`
|
||||||
|
}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ color: SEGMENT_COLORS.new },
|
||||||
|
{ color: SEGMENT_COLORS.returning },
|
||||||
|
{ color: SEGMENT_COLORS.loyal },
|
||||||
|
{ color: SEGMENT_COLORS.vip },
|
||||||
|
].map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload?.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
const segmentKey = data.name
|
||||||
|
.split(" ")[0]
|
||||||
|
.toLowerCase();
|
||||||
|
const details =
|
||||||
|
growthData.customers.segmentDetails[segmentKey];
|
||||||
|
return (
|
||||||
|
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
||||||
|
<p className="font-medium">{data.name}</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Count:{" "}
|
||||||
|
{hideNumbers
|
||||||
|
? "***"
|
||||||
|
: data.value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{details && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-green-600">
|
||||||
|
Revenue:{" "}
|
||||||
|
{formatCurrency(details.totalRevenue)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Avg Orders:{" "}
|
||||||
|
{hideNumbers ? "***" : details.avgOrderCount}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||||
|
No customer data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Segment Stats */}
|
||||||
|
{growthData?.customers && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-4">
|
||||||
|
<div className="p-2 rounded bg-blue-500/10 text-center">
|
||||||
|
<div className="text-lg font-bold text-blue-600">
|
||||||
|
{hideNumbers ? "***" : growthData.customers.segments.new}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="text-xs text-muted-foreground">New</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
<div className="p-2 rounded bg-green-500/10 text-center">
|
||||||
</TabsContent>
|
<div className="text-lg font-bold text-green-600">
|
||||||
</Tabs>
|
{hideNumbers
|
||||||
|
? "***"
|
||||||
|
: growthData.customers.segments.returning}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Returning</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded bg-amber-500/10 text-center">
|
||||||
|
<div className="text-lg font-bold text-amber-600">
|
||||||
|
{hideNumbers ? "***" : growthData.customers.segments.loyal}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Loyal</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded bg-purple-500/10 text-center">
|
||||||
|
<div className="text-lg font-bold text-purple-600">
|
||||||
|
{hideNumbers ? "***" : growthData.customers.segments.vip}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">VIP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Monthly Growth Table */}
|
||||||
|
{growthData?.monthly && growthData.monthly.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Monthly Breakdown</CardTitle>
|
||||||
|
<CardDescription>Detailed metrics by month</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left p-2 font-medium">Month</th>
|
||||||
|
<th className="text-right p-2 font-medium">Orders</th>
|
||||||
|
<th className="text-right p-2 font-medium">Revenue</th>
|
||||||
|
<th className="text-right p-2 font-medium">Customers</th>
|
||||||
|
<th className="text-right p-2 font-medium">Avg Order</th>
|
||||||
|
<th className="text-right p-2 font-medium">
|
||||||
|
New Customers
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{growthData.monthly.map((month) => (
|
||||||
|
<tr
|
||||||
|
key={month.month}
|
||||||
|
className="border-b hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<td className="p-2 font-medium">
|
||||||
|
{new Date(month.month + "-01").toLocaleDateString(
|
||||||
|
"en-GB",
|
||||||
|
{ month: "long", year: "numeric" },
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right p-2">
|
||||||
|
{hideNumbers ? "***" : month.orders.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-right p-2 text-green-600">
|
||||||
|
{formatCurrency(month.revenue)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right p-2">
|
||||||
|
{hideNumbers ? "***" : month.customers.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-right p-2">
|
||||||
|
{formatCurrency(month.avgOrderValue)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right p-2">
|
||||||
|
{hideNumbers ? "***" : (month.newCustomers ?? 0)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user