Update GrowthAnalyticsChart.tsx
This commit is contained in:
@@ -8,23 +8,13 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Users,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import {
|
||||
getGrowthAnalyticsWithStore,
|
||||
type GrowthAnalytics,
|
||||
} from "@/lib/services/analytics-service";
|
||||
import { formatGBP } from "@/utils/format";
|
||||
import {
|
||||
ComposedChart,
|
||||
Bar,
|
||||
@@ -37,8 +27,6 @@ import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
BarChart,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
interface GrowthAnalyticsChartProps {
|
||||
@@ -52,26 +40,18 @@ const SEGMENT_COLORS = {
|
||||
vip: "#8b5cf6",
|
||||
};
|
||||
|
||||
const SEGMENT_LABELS = {
|
||||
new: "New (1 order)",
|
||||
returning: "Returning (2-3 orders)",
|
||||
loyal: "Loyal (4+ orders or £300+)",
|
||||
vip: "VIP (10+ orders or £1000+)",
|
||||
};
|
||||
|
||||
export default function GrowthAnalyticsChart({
|
||||
hideNumbers = false,
|
||||
}: GrowthAnalyticsChartProps) {
|
||||
const [data, setData] = useState<GrowthAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [growthData, setGrowthData] = useState<GrowthAnalytics | null>(null);
|
||||
const [growthLoading, setGrowthLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
const fetchData = async () => {
|
||||
const fetchGrowthData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setGrowthLoading(true);
|
||||
const response = await getGrowthAnalyticsWithStore();
|
||||
setData(response);
|
||||
setGrowthData(response);
|
||||
} catch (err) {
|
||||
console.error("Error fetching growth data:", err);
|
||||
toast({
|
||||
@@ -80,18 +60,16 @@ export default function GrowthAnalyticsChart({
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setGrowthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
fetchGrowthData();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchData();
|
||||
const handleGrowthRefresh = () => {
|
||||
fetchGrowthData();
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
@@ -103,522 +81,398 @@ export default function GrowthAnalyticsChart({
|
||||
}).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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
{/* Growth Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Growth Since First Sale
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Started {formatDate(data.launchDate)} ({getDaysSinceLaunch()} days
|
||||
ago)
|
||||
<h3 className="text-lg font-semibold">Growth Since First Sale</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{growthData?.launchDate
|
||||
? `Tracking since ${new Date(growthData.launchDate).toLocaleDateString("en-GB", { month: "long", year: "numeric" })}`
|
||||
: "Loading..."}
|
||||
{growthData?.generatedAt && (
|
||||
<span className="ml-2">
|
||||
- Last updated:{" "}
|
||||
{new Date(growthData.generatedAt).toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
size="sm"
|
||||
onClick={handleGrowthRefresh}
|
||||
disabled={growthLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||
className={`h-4 w-4 mr-2 ${growthLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cumulative Summary Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{/* Cumulative Stats Cards */}
|
||||
{growthData?.cumulative && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Total Orders
|
||||
</CardTitle>
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(data.cumulative.orders)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{hideNumbers
|
||||
? "***"
|
||||
: growthData.cumulative.orders.toLocaleString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Total Revenue
|
||||
</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{formatCurrency(growthData.cumulative.revenue)}
|
||||
</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 className="pt-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Customers
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{hideNumbers
|
||||
? "***"
|
||||
: growthData.cumulative.customers.toLocaleString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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 */}
|
||||
<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}%`} |{" "}
|
||||
{/* Monthly Revenue & Orders Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monthly Revenue & Orders</CardTitle>
|
||||
<CardDescription>
|
||||
Store performance by month since first sale
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{growthLoading ? (
|
||||
<div className="flex items-center justify-center h-80">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
) : growthData?.monthly && growthData.monthly.length > 0 ? (
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={growthData.monthly.map((m) => ({
|
||||
...m,
|
||||
formattedMonth: new Date(
|
||||
m.month + "-01",
|
||||
).toLocaleDateString("en-GB", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}),
|
||||
}))}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="formattedMonth" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(value) =>
|
||||
hideNumbers ? "***" : `£${(value / 1000).toFixed(0)}k`
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload?.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
||||
<p className="font-medium mb-2">{data.month}</p>
|
||||
<p className="text-sm text-blue-600">
|
||||
Orders:{" "}
|
||||
{hideNumbers
|
||||
? "£***"
|
||||
: formatGBP(details.totalRevenue || 0)}{" "}
|
||||
revenue
|
||||
</div>
|
||||
? "***"
|
||||
: data.orders.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Revenue: {formatCurrency(data.revenue)}
|
||||
</p>
|
||||
<p className="text-sm text-purple-600">
|
||||
Customers:{" "}
|
||||
{hideNumbers
|
||||
? "***"
|
||||
: data.customers.toLocaleString()}
|
||||
</p>
|
||||
{data.newCustomers !== undefined && (
|
||||
<p className="text-sm text-cyan-600">
|
||||
New Customers:{" "}
|
||||
{hideNumbers ? "***" : data.newCustomers}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<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 */}
|
||||
<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>
|
||||
{/* Customer Segments Pie Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Customer Segments</CardTitle>
|
||||
<CardDescription>Breakdown by purchase behavior</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{growthLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
) : growthData?.customers ? (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{
|
||||
name: "New (1 order)",
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="text-xs text-muted-foreground">New</div>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-green-500/10 text-center">
|
||||
<div className="text-lg font-bold text-green-600">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user