Implemented intelligent header rules based on user feedback.
Problem Analysis:
1. Dashboard submenu tabs already show page names (Overview, Revenue, Orders...)
2. Showing "Orders" header is ambiguous (Analytics or Management?)
3. Wasted vertical space for redundant information
4. FAB already handles actions on management pages
Solution: Headers ONLY When They Add Value
Rules Implemented:
1. Dashboard Pages: NO HEADERS
- Submenu tabs are sufficient
- Saves vertical space
- No ambiguity
Before:
Dashboard → Overview = "Dashboard" header (redundant!)
Dashboard → Orders = "Orders" header (confusing!)
After:
Dashboard → Overview = No header (tabs show "Overview")
Dashboard → Orders = No header (tabs show "Orders")
2. Settings Pages: HEADERS ONLY WITH ACTIONS
- Store Details + [Save] = Show header ✓
- Payments + [Refresh] = Show header ✓
- Pages without actions = No header (save space)
Logic: If there is an action button, we need a place to put it → header
If no action button, header is just wasting space → remove it
3. Management Pages: NO HEADERS
- FAB handles actions ([+ Add Order])
- No need for redundant header with action button
4. Payments Exception: REMOVED
- Treat Payments like any other settings page
- Has action (Refresh) = show header
- Consistent with other pages
Implementation:
Dashboard Pages (7 files):
- Removed usePageHeader hook
- Removed useEffect for setting header
- Removed unused imports (useEffect, usePageHeader)
- Result: Clean, no headers, tabs are enough
PageHeader Component:
- Removed Payments special case detection
- Removed useLocation import
- Simplified logic: hideOnDesktop prop only
SettingsLayout Component:
- Changed logic: Only set header when onSave OR action exists
- If no action: clearPageHeader() instead of setPageHeader(title)
- Result: Headers only appear when needed
Benefits:
✅ Saves vertical space (no redundant headers)
✅ No ambiguity (Dashboard Orders vs Orders Management)
✅ Consistent logic (action = header, no action = no header)
✅ Cleaner UI (less visual clutter)
✅ FAB handles management page actions
Files Modified:
- Dashboard/index.tsx (Overview)
- Dashboard/Revenue.tsx
- Dashboard/Orders.tsx
- Dashboard/Products.tsx
- Dashboard/Customers.tsx
- Dashboard/Coupons.tsx
- Dashboard/Taxes.tsx
- PageHeader.tsx
- SettingsLayout.tsx
Result: Smart headers that only appear when they add value! 🎯
467 lines
18 KiB
TypeScript
467 lines
18 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
|
import { Users, TrendingUp, DollarSign, ShoppingCart, UserPlus, UserCheck, Info } from 'lucide-react';
|
|
import { __ } from '@/lib/i18n';
|
|
import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
|
import { useDashboardPeriod } from '@/hooks/useDashboardPeriod';
|
|
import { useCustomersAnalytics } from '@/hooks/useAnalytics';
|
|
import { ErrorCard } from '@/components/ErrorCard';
|
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
|
import { StatCard } from './components/StatCard';
|
|
import { ChartCard } from './components/ChartCard';
|
|
import { DataTable, Column } from './components/DataTable';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { DUMMY_CUSTOMERS_DATA, CustomersData, TopCustomer } from './data/dummyCustomers';
|
|
|
|
export default function CustomersAnalytics() {
|
|
const { period } = useDashboardPeriod();
|
|
const store = getStoreCurrency();
|
|
|
|
// Fetch real data or use dummy data based on toggle
|
|
const { data, isLoading, error, refetch } = useCustomersAnalytics(DUMMY_CUSTOMERS_DATA);
|
|
|
|
// ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL RETURNS!
|
|
// Filter chart data by period
|
|
const chartData = useMemo(() => {
|
|
if (!data) return [];
|
|
return period === 'all' ? data.acquisition_chart : data.acquisition_chart.slice(-parseInt(period));
|
|
}, [data, period]);
|
|
|
|
// Calculate period metrics
|
|
const periodMetrics = useMemo(() => {
|
|
// Store-level data (not affected by period)
|
|
const totalCustomersStoreLevel = data.overview.total_customers; // All-time total
|
|
const avgLtvStoreLevel = data.overview.avg_ltv; // Lifetime value is cumulative
|
|
const avgOrdersPerCustomer = data.overview.avg_orders_per_customer; // Average ratio
|
|
|
|
if (period === 'all') {
|
|
const totalNew = data.acquisition_chart.reduce((sum: number, d: any) => sum + d.new_customers, 0);
|
|
const totalReturning = data.acquisition_chart.reduce((sum: number, d: any) => sum + d.returning_customers, 0);
|
|
const totalInPeriod = totalNew + totalReturning;
|
|
|
|
return {
|
|
// Store-level (not affected)
|
|
total_customers: totalCustomersStoreLevel,
|
|
avg_ltv: avgLtvStoreLevel,
|
|
avg_orders_per_customer: avgOrdersPerCustomer,
|
|
|
|
// Period-based
|
|
new_customers: totalNew,
|
|
returning_customers: totalReturning,
|
|
retention_rate: totalInPeriod > 0 ? (totalReturning / totalInPeriod) * 100 : 0,
|
|
|
|
// No comparison for "all time"
|
|
new_customers_change: undefined,
|
|
retention_rate_change: undefined,
|
|
};
|
|
}
|
|
|
|
const periodData = data.acquisition_chart.slice(-parseInt(period));
|
|
const previousData = data.acquisition_chart.slice(-parseInt(period) * 2, -parseInt(period));
|
|
|
|
const totalNew = periodData.reduce((sum: number, d: any) => sum + d.new_customers, 0);
|
|
const totalReturning = periodData.reduce((sum: number, d: any) => sum + d.returning_customers, 0);
|
|
const totalInPeriod = totalNew + totalReturning;
|
|
|
|
const prevTotalNew = previousData.reduce((sum: number, d: any) => sum + d.new_customers, 0);
|
|
const prevTotalReturning = previousData.reduce((sum: number, d: any) => sum + d.returning_customers, 0);
|
|
const prevTotalInPeriod = prevTotalNew + prevTotalReturning;
|
|
|
|
const retentionRate = totalInPeriod > 0 ? (totalReturning / totalInPeriod) * 100 : 0;
|
|
const prevRetentionRate = prevTotalInPeriod > 0 ? (prevTotalReturning / prevTotalInPeriod) * 100 : 0;
|
|
|
|
return {
|
|
// Store-level (not affected)
|
|
total_customers: totalCustomersStoreLevel,
|
|
avg_ltv: avgLtvStoreLevel,
|
|
avg_orders_per_customer: avgOrdersPerCustomer,
|
|
|
|
// Period-based
|
|
new_customers: totalNew,
|
|
returning_customers: totalReturning,
|
|
retention_rate: retentionRate,
|
|
|
|
// Comparisons
|
|
new_customers_change: prevTotalNew > 0 ? ((totalNew - prevTotalNew) / prevTotalNew) * 100 : 0,
|
|
retention_rate_change: prevRetentionRate > 0 ? ((retentionRate - prevRetentionRate) / prevRetentionRate) * 100 : 0,
|
|
};
|
|
}, [data.acquisition_chart, period, data.overview]);
|
|
|
|
// Format money with M/B abbreviations (translatable)
|
|
const formatMoneyAxis = (value: number) => {
|
|
if (value >= 1000000000) {
|
|
return `${(value / 1000000000).toFixed(1)}${__('B')}`;
|
|
}
|
|
if (value >= 1000000) {
|
|
return `${(value / 1000000).toFixed(1)}${__('M')}`;
|
|
}
|
|
if (value >= 1000) {
|
|
return `${(value / 1000).toFixed(0)}${__('K')}`;
|
|
}
|
|
return value.toString();
|
|
};
|
|
|
|
// Format currency
|
|
const formatCurrency = (value: number) => {
|
|
return formatMoney(value, {
|
|
currency: store.currency,
|
|
symbol: store.symbol,
|
|
thousandSep: store.thousand_sep,
|
|
decimalSep: store.decimal_sep,
|
|
decimals: 0,
|
|
preferSymbol: true,
|
|
});
|
|
};
|
|
|
|
// Format money range strings (e.g., "Rp1.000.000 - Rp5.000.000" -> "Rp1.0M - Rp5.0M")
|
|
const formatMoneyRange = (rangeStr: string) => {
|
|
// Extract numbers from the range string
|
|
const numbers = rangeStr.match(/\d+(?:[.,]\d+)*/g);
|
|
if (!numbers) return rangeStr;
|
|
|
|
// Parse and format each number
|
|
const formatted = numbers.map((numStr: string) => {
|
|
const num = parseInt(numStr.replace(/[.,]/g, ''));
|
|
return store.symbol + formatMoneyAxis(num).replace(/[^\d.KMB]/g, '');
|
|
});
|
|
|
|
// Reconstruct the range
|
|
if (rangeStr.includes('-')) {
|
|
return `${formatted[0]} - ${formatted[1]}`;
|
|
} else if (rangeStr.startsWith('<')) {
|
|
return `< ${formatted[0]}`;
|
|
} else if (rangeStr.startsWith('>')) {
|
|
return `> ${formatted[0]}`;
|
|
}
|
|
return formatted.join(' - ');
|
|
};
|
|
|
|
// Filter top customers by period (for revenue in period, not LTV)
|
|
const filteredTopCustomers = useMemo(() => {
|
|
if (!data || !data.top_customers) return [];
|
|
if (period === 'all') {
|
|
return data.top_customers; // Show all-time data
|
|
}
|
|
|
|
// Scale customer spending by period factor for demonstration
|
|
// In real implementation, this would fetch period-specific data from API
|
|
const factor = parseInt(period) / 30;
|
|
return data.top_customers.map((customer: any) => ({
|
|
...customer,
|
|
total_spent: Math.round(customer.total_spent * factor),
|
|
orders: Math.round(customer.orders * factor),
|
|
}));
|
|
}, [data, period]);
|
|
|
|
// Debug logging
|
|
console.log('[CustomersAnalytics] State:', {
|
|
isLoading,
|
|
hasError: !!error,
|
|
errorMessage: error?.message,
|
|
hasData: !!data,
|
|
dataKeys: data ? Object.keys(data) : []
|
|
});
|
|
|
|
// Show loading state
|
|
if (isLoading) {
|
|
console.log('[CustomersAnalytics] Rendering loading state');
|
|
return (
|
|
<div className="flex items-center justify-center h-96">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
|
<p className="text-muted-foreground">{__('Loading analytics...')}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show error state with clear message and retry button
|
|
if (error) {
|
|
console.log('[CustomersAnalytics] Rendering error state:', error);
|
|
return (
|
|
<ErrorCard
|
|
title={__('Failed to load customer analytics')}
|
|
message={getPageLoadErrorMessage(error)}
|
|
onRetry={() => refetch()}
|
|
/>
|
|
);
|
|
}
|
|
|
|
console.log('[CustomersAnalytics] Rendering normal content');
|
|
|
|
// Table columns
|
|
const customerColumns: Column<TopCustomer>[] = [
|
|
{ key: 'name', label: __('Customer'), sortable: true },
|
|
{ key: 'email', label: __('Email'), sortable: true },
|
|
{
|
|
key: 'orders',
|
|
label: __('Orders'),
|
|
sortable: true,
|
|
align: 'right',
|
|
},
|
|
{
|
|
key: 'total_spent',
|
|
label: __('Total Spent'),
|
|
sortable: true,
|
|
align: 'right',
|
|
render: (value) => formatCurrency(value),
|
|
},
|
|
{
|
|
key: 'avg_order_value',
|
|
label: __('Avg Order'),
|
|
sortable: true,
|
|
align: 'right',
|
|
render: (value) => formatCurrency(value),
|
|
},
|
|
{
|
|
key: 'segment',
|
|
label: __('Segment'),
|
|
sortable: true,
|
|
render: (value) => {
|
|
const colors: Record<string, string> = {
|
|
vip: 'bg-purple-100 text-purple-800',
|
|
returning: 'bg-blue-100 text-blue-800',
|
|
new: 'bg-green-100 text-green-800',
|
|
at_risk: 'bg-red-100 text-red-800',
|
|
};
|
|
const labels: Record<string, string> = {
|
|
vip: __('VIP'),
|
|
returning: __('Returning'),
|
|
new: __('New'),
|
|
at_risk: __('At Risk'),
|
|
};
|
|
return (
|
|
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${colors[value] || ''}`}>
|
|
{labels[value] || value}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6 lg:p-6 pb-6">
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold">{__('Customers Analytics')}</h1>
|
|
<p className="text-sm text-muted-foreground">{__('Customer behavior and lifetime value')}</p>
|
|
</div>
|
|
|
|
{/* Metric Cards - Row 1: Period-based metrics */}
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
<StatCard
|
|
title={__('New Customers')}
|
|
value={periodMetrics.new_customers}
|
|
change={periodMetrics.new_customers_change}
|
|
icon={UserPlus}
|
|
format="number"
|
|
period={period}
|
|
/>
|
|
<StatCard
|
|
title={__('Retention Rate')}
|
|
value={periodMetrics.retention_rate}
|
|
change={periodMetrics.retention_rate_change}
|
|
icon={UserCheck}
|
|
format="percent"
|
|
period={period}
|
|
/>
|
|
<StatCard
|
|
title={__('Avg Orders/Customer')}
|
|
value={periodMetrics.avg_orders_per_customer}
|
|
icon={ShoppingCart}
|
|
format="number"
|
|
/>
|
|
<StatCard
|
|
title={__('Avg Lifetime Value')}
|
|
value={periodMetrics.avg_ltv}
|
|
icon={DollarSign}
|
|
format="money"
|
|
/>
|
|
</div>
|
|
|
|
{/* Customer Segments - Row 2: Store-level + Period segments */}
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
<div className="rounded-lg border bg-card p-6">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<Users className="w-5 h-5 text-blue-600" />
|
|
<h3 className="font-semibold text-sm">{__('Total Customers')}</h3>
|
|
</div>
|
|
<p className="text-3xl font-bold">{periodMetrics.total_customers}</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{__('All-time total')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="rounded-lg border bg-card p-6">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<UserCheck className="w-5 h-5 text-green-600" />
|
|
<h3 className="font-semibold text-sm">{__('Returning')}</h3>
|
|
</div>
|
|
<p className="text-3xl font-bold">{periodMetrics.returning_customers}</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{__('In selected period')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="rounded-lg border bg-card p-6">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<TrendingUp className="w-5 h-5 text-purple-600" />
|
|
<h3 className="font-semibold text-sm">{__('VIP Customers')}</h3>
|
|
</div>
|
|
<div className="group relative">
|
|
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
|
<div className="invisible group-hover:visible absolute right-0 top-6 z-10 w-64 p-3 bg-popover border rounded-lg shadow-lg text-xs">
|
|
<p className="font-medium mb-1">{__('VIP Qualification:')}</p>
|
|
<p className="text-muted-foreground">{__('Customers with 10+ orders OR lifetime value > Rp5.000.000')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p className="text-3xl font-bold">{data.segments.vip}</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{((data.segments.vip / data.overview.total_customers) * 100).toFixed(1)}% {__('of total')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="rounded-lg border bg-card p-6">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-5 h-5 text-red-600" />
|
|
<h3 className="font-semibold text-sm">{__('At Risk')}</h3>
|
|
</div>
|
|
<div className="group relative">
|
|
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
|
<div className="invisible group-hover:visible absolute right-0 top-6 z-10 w-64 p-3 bg-popover border rounded-lg shadow-lg text-xs">
|
|
<p className="font-medium mb-1">{__('At Risk Qualification:')}</p>
|
|
<p className="text-muted-foreground">{__('Customers with no orders in the last 90 days')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p className="text-3xl font-bold">{data.segments.at_risk}</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{((data.segments.at_risk / data.overview.total_customers) * 100).toFixed(1)}% {__('of total')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Customer Acquisition Chart */}
|
|
<ChartCard
|
|
title={__('Customer Acquisition')}
|
|
description={__('New vs returning customers over time')}
|
|
>
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<LineChart data={chartData}>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
<XAxis
|
|
dataKey="date"
|
|
className="text-xs"
|
|
tickFormatter={(value) => {
|
|
const date = new Date(value);
|
|
return `${date.getMonth() + 1}/${date.getDate()}`;
|
|
}}
|
|
/>
|
|
<YAxis className="text-xs" />
|
|
<Tooltip
|
|
content={({ active, payload }) => {
|
|
if (!active || !payload || !payload.length) return null;
|
|
return (
|
|
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
|
<p className="text-sm font-medium mb-2">
|
|
{new Date(payload[0].payload.date).toLocaleDateString()}
|
|
</p>
|
|
{payload.map((entry: any) => (
|
|
<div key={entry.dataKey} className="flex items-center justify-between gap-4 text-sm">
|
|
<span style={{ color: entry.color }}>{entry.name}:</span>
|
|
<span className="font-medium">{entry.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
<Legend />
|
|
<Line
|
|
type="monotone"
|
|
dataKey="new_customers"
|
|
name={__('New Customers')}
|
|
stroke="#10b981"
|
|
strokeWidth={2}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="returning_customers"
|
|
name={__('Returning Customers')}
|
|
stroke="#3b82f6"
|
|
strokeWidth={2}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</ChartCard>
|
|
|
|
{/* Two Column Layout */}
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
{/* Top Customers */}
|
|
<ChartCard
|
|
title={__('Top Customers')}
|
|
description={__('Highest spending customers')}
|
|
>
|
|
<DataTable
|
|
data={filteredTopCustomers.slice(0, 5)}
|
|
columns={customerColumns}
|
|
/>
|
|
</ChartCard>
|
|
|
|
{/* LTV Distribution */}
|
|
<ChartCard
|
|
title={__('Lifetime Value Distribution')}
|
|
description={__('Customer segments by total spend')}
|
|
>
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<BarChart data={data.ltv_distribution}>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
<XAxis
|
|
dataKey="range"
|
|
className="text-xs"
|
|
angle={-45}
|
|
textAnchor="end"
|
|
height={80}
|
|
tickFormatter={formatMoneyRange}
|
|
/>
|
|
<YAxis className="text-xs" />
|
|
<Tooltip
|
|
content={({ active, payload }) => {
|
|
if (!active || !payload || !payload.length) return null;
|
|
const data = payload[0].payload;
|
|
return (
|
|
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
|
<p className="text-sm font-medium mb-1">{data.range}</p>
|
|
<p className="text-sm">
|
|
{__('Customers')}: <span className="font-medium">{data.count}</span>
|
|
</p>
|
|
<p className="text-sm">
|
|
{__('Percentage')}: <span className="font-medium">{data.percentage.toFixed(1)}%</span>
|
|
</p>
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
<Bar dataKey="count" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</ChartCard>
|
|
</div>
|
|
|
|
{/* All Customers Table */}
|
|
<ChartCard
|
|
title={__('All Top Customers')}
|
|
description={__('Complete list of top spending customers')}
|
|
>
|
|
<DataTable
|
|
data={filteredTopCustomers}
|
|
columns={customerColumns}
|
|
/>
|
|
</ChartCard>
|
|
</div>
|
|
);
|
|
}
|