Files
dashboard/examples DO NOT USE OR EDIT/EXAMPLE ONLY KlaviyoStats.jsx
2024-12-21 09:49:53 -05:00

2087 lines
66 KiB
JavaScript

import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DollarSign,
ShoppingCart,
Package,
Clock,
Map,
Tags,
Star,
XCircle,
TrendingUp,
AlertCircle,
Box,
RefreshCcw,
CircleDollarSign,
ArrowDown,
ArrowUp,
MapPin
} from "lucide-react";
import { DateTime } from "luxon";
import { TimeRangeSelect } from "@/components/dashboard/TimeRangeSelect";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
Area,
AreaChart,
ComposedChart,
} from "recharts";
const CHART_COLORS = [
"hsl(var(--primary))",
"hsl(var(--secondary))",
"#8b5cf6",
"#10b981",
"#f59e0b",
"#ef4444",
];
// Formatting utilities
const formatCurrency = (value, minimumFractionDigits = 0) => {
if (!value || isNaN(value)) return "$0";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits,
maximumFractionDigits: minimumFractionDigits,
}).format(value);
};
const formatHour = (hour) => {
const hourNum = parseInt(hour);
if (hourNum === 0) return "12am";
if (hourNum === 12) return "12pm";
if (hourNum > 12) return `${hourNum - 12}pm`;
return `${hourNum}am`;
};
const normalizePaymentMethod = (method) => {
if (method.toLowerCase().includes("credit card")) return "Credit Card";
if (method.toLowerCase().includes("gift")) return "Gift Card";
return method;
};
const formatPercent = (value, total) => {
if (!total || !value) return "0%";
return `${((value / total) * 100).toFixed(1)}%`;
};
const getPreviousPeriod = (timeRange) => {
switch (timeRange) {
case 'today': return 'yesterday';
case 'yesterday': return 'last2days';
case 'last7days': return 'previous7days';
case 'last30days': return 'previous30days';
case 'last90days': return 'previous90days';
default: return timeRange;
}
};
const formatShipMethod = (method) => {
if (!method) return "Standard Shipping";
return method
.replace("usps_", "USPS ")
.replace("ups_", "UPS ")
.replace("fedex_", "FedEx ")
.replace(/_/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
// Component building blocks
const DetailCard = ({ title, icon: Icon, children }) => (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-3">
<div className="flex items-center space-x-2">
{Icon && <Icon className="h-5 w-5 text-gray-500" />}
<h3 className="font-medium text-gray-900 dark:text-gray-100">{title}</h3>
</div>
{children}
</div>
);
const SkeletonMetricCard = () => (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4 rounded-full" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-32 mb-2" />
<Skeleton className="h-3 w-20" />
</CardContent>
</Card>
);
const SkeletonChart = ({ type = "line" }) => (
<div className="h-[400px] w-full bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div className="h-full flex flex-col">
<div className="flex-1 relative">
{type === "bar" ? (
<div className="h-full flex items-end justify-between gap-1">
{[...Array(24)].map((_, i) => (
<div
key={i}
className="w-full bg-gray-200 dark:bg-gray-700 rounded-t"
style={{ height: `${15 + Math.random() * 70}%` }}
/>
))}
</div>
) : type === "range" ? (
<div className="h-full flex items-center justify-between">
<div className="h-full w-full relative">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-1 bg-gray-200 dark:bg-gray-700"
style={{ top: `${20 + i * 20}%` }}
/>
))}
<div className="absolute inset-x-0 top-1/2 h-8 bg-gray-300 dark:bg-gray-600 opacity-25" />
</div>
</div>
) : (
<div className="h-full w-full relative">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-gray-200 dark:bg-gray-700"
style={{ top: `${20 + i * 20}%` }}
/>
))}
<div
className="absolute inset-0 bg-gray-300 dark:bg-gray-600"
style={{
opacity: 0.2,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}}
/>
</div>
)}
</div>
</div>
</div>
);
const SkeletonTable = ({ rows = 5 }) => (
<div className="space-y-2">
{/* Header */}
<div className="grid grid-cols-3 gap-4 pb-2">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</div>
{/* Rows */}
{[...Array(rows)].map((_, i) => (
<div key={i} className="grid grid-cols-3 gap-4 py-2">
{[...Array(3)].map((_, j) => (
<Skeleton key={j} className="h-4 w-full" />
))}
</div>
))}
</div>
);
const SkeletonMetricGrid = () => (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array(12)
.fill(0)
.map((_, i) => (
<SkeletonMetricCard key={i} />
))}
</div>
);
const StatRow = ({ label, value, change, emphasize }) => (
<div className="flex justify-between items-center py-1">
<span className="text-gray-600 dark:text-gray-400">{label}</span>
<div className="flex items-center space-x-2">
<span className={emphasize ? "font-medium text-primary" : "font-medium"}>
{value}
</span>
{change && (
<span
className={`text-sm ${
change > 0 ? "text-green-500" : "text-red-500"
}`}
>
{change > 0 ? "↑" : "↓"} {Math.abs(change)}%
</span>
)}
</div>
</div>
);
const DetailSection = ({ title, children }) => (
<div className="space-y-4">
<h3 className="text-lg font-medium">{title}</h3>
{children}
</div>
);
const formatChartDate = (value) => {
if (!value) return '';
try {
return DateTime.fromISO(value).setZone('America/New_York').toFormat('LLL d');
} catch (error) {
console.error("[KLAVIYO STATS] Date formatting error:", error);
return value;
}
};
export const RevenueDetails = ({ metrics, isLoading }) => {
if (isLoading) {
return <SkeletonChart type="line" />;
}
// Ensure we have valid daily data
if (!metrics?.revenue?.daily || !Array.isArray(metrics.revenue.daily)) {
console.error("[KLAVIYO STATS] Invalid daily revenue data:", metrics?.revenue);
return null;
}
// Sort daily data by date to ensure correct order
const revenueData = metrics.revenue.daily
.sort((a, b) => DateTime.fromISO(a.date).toMillis() - DateTime.fromISO(b.date).toMillis())
.map(day => ({
date: day.date,
revenue: parseFloat(day.value) || 0,
orders: parseInt(day.orders) || 0,
items: parseInt(day.items) || 0
}));
console.log("[KLAVIYO STATS] Processed revenue data:", {
totalDays: revenueData.length,
dates: revenueData.map(d => d.date),
totals: {
revenue: revenueData.reduce((sum, day) => sum + day.revenue, 0),
orders: revenueData.reduce((sum, day) => sum + day.orders, 0),
items: revenueData.reduce((sum, day) => sum + day.items, 0)
}
});
return (
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
<ResponsiveContainer>
<LineChart
data={revenueData}
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-gray-200 dark:stroke-gray-700"
/>
<XAxis
dataKey="date"
tickFormatter={formatChartDate}
height={50}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={(value) => formatCurrency(value, 0)}
width={80}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<Tooltip
labelFormatter={formatChartDate}
formatter={(value) => [formatCurrency(value, 0), "Revenue"]}
/>
<Line
type="monotone"
dataKey="revenue"
stroke={CHART_COLORS[0]}
name="Revenue"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
};
export const OrdersDetails = ({ metrics, isLoading }) => {
if (isLoading) {
return (
<div className="space-y-6">
<SkeletonChart />
<div>
<Skeleton className="h-6 w-40 mb-4" />
<SkeletonChart />
</div>
</div>
);
}
const orderData =
metrics.revenue?.daily?.map((day) => ({
date: day.date,
orders: day.orders || 0,
})) || [];
const hourlyData =
metrics.hourly_distribution
?.map((count, hour) => ({
hour,
orders: count,
}))
.filter((data) => data.orders > 0) || [];
return (
<div className="space-y-6">
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={orderData}
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={formatChartDate}
height={50}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
width={60}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<Tooltip
labelFormatter={formatChartDate}
formatter={(value) => [value.toLocaleString(), "Orders"]}
/>
<Line
type="monotone"
dataKey="orders"
stroke="hsl(var(--primary))"
name="Orders"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
{hourlyData.length > 0 && (
<div>
<h3 className="text-lg font-medium mb-4">Hourly Distribution</h3>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={hourlyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" tickFormatter={formatHour} />
<YAxis />
<Tooltip
formatter={(value) => [value.toLocaleString(), "Orders"]}
labelFormatter={formatHour}
/>
<Bar dataKey="orders" fill={CHART_COLORS[0]} name="Orders" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
</div>
);
};
export const AverageOrderDetails = ({ metrics, isLoading }) => {
if (isLoading) {
return <SkeletonChart />;
}
const avgOrderData =
metrics.revenue?.daily?.map((day) => ({
date: day.date,
average: day.orders > 0 ? day.value / day.orders : 0,
})) || [];
return (
<TimeSeriesChart
data={avgOrderData}
valueKey="average"
label="Average Order Value"
type="line"
valueFormatter={(value) => formatCurrency(value, 0)}
minDays={30}
/>
);
};
const DataTable = ({ title, data, isLoading }) => {
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-2">
{Array(10).fill(0).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="text-right">Count</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map(([name, count]) => (
<TableRow key={name}>
<TableCell>{name}</TableCell>
<TableCell className="text-right font-medium">{count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
};
export const BrandsAndCategoriesDetails = ({ metrics, isLoading }) => {
const brands = Object.entries(metrics?.orders?.brands || {})
.sort(([, a], [, b]) => b - a)
.slice(0, 20);
const categories = Object.entries(metrics?.orders?.categories || {})
.sort(([, a], [, b]) => b - a)
.slice(0, 20);
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<DataTable
title="Top Brands"
data={brands}
isLoading={isLoading}
/>
<DataTable
title="Top Categories"
data={categories}
isLoading={isLoading}
/>
</div>
);
};
export const ShippedOrdersDetails = ({ metrics, isLoading }) => {
if (isLoading) {
return (
<div className="space-y-6">
<SkeletonChart />
<div>
<Skeleton className="h-6 w-48 mb-4" />
<div className="space-y-2">
{Array(8)
.fill(0)
.map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
</div>
</div>
);
}
const shippedData = metrics.revenue?.daily?.map((day) => ({
date: day.date,
shipped_orders: day.shipped_orders || 0,
})) || [];
const locations = Object.entries(metrics.orders?.shipping_states || {})
.sort(([, a], [, b]) => b - a)
.filter(([location]) => location);
return (
<div className="space-y-6">
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={shippedData}
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={formatChartDate}
height={50}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
width={60}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<Tooltip
labelFormatter={formatChartDate}
formatter={(value) => [value.toLocaleString(), "Orders"]}
/>
<Line
type="monotone"
dataKey="shipped_orders"
stroke="hsl(var(--primary))"
name="Shipped Orders"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div>
<h3 className="text-lg font-medium mb-4">Top Shipping Locations</h3>
<div className="space-y-2">
{locations.map(([location, count]) => (
<div
key={location}
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<span>{location}</span>
<span className="font-medium">{count.toLocaleString()}</span>
</div>
))}
</div>
</div>
</div>
);
};
export const PreOrdersDetails = ({ metrics, isLoading }) => {
if (isLoading) {
return <SkeletonChart type="line" />;
}
const preOrderData =
metrics.revenue?.daily?.map((day) => ({
date: day.date,
percentage:
day.orders > 0 ? ((day.pre_orders || 0) / day.orders) * 100 : 0,
})) || [];
return (
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
<ResponsiveContainer>
<LineChart
data={preOrderData}
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-gray-200 dark:stroke-gray-700"
/>
<XAxis
dataKey="date"
tickFormatter={formatChartDate}
height={50}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={(value) => `${Math.round(value)}%`}
width={60}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<Tooltip content={<ChartTooltip />} />
<Line
type="monotone"
dataKey="percentage"
stroke={CHART_COLORS[0]}
name="Pre-Orders"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
};
export const LocalPickupDetails = ({ metrics, isLoading }) => {
if (isLoading) {
return <SkeletonChart type="line" />;
}
const pickupData =
metrics.revenue?.daily?.map((day) => ({
date: day.date,
percentage:
day.orders > 0 ? ((day.local_pickup || 0) / day.orders) * 100 : 0,
})) || [];
return (
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
<ResponsiveContainer>
<LineChart
data={pickupData}
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-gray-200 dark:stroke-gray-700"
/>
<XAxis
dataKey="date"
tickFormatter={formatChartDate}
height={50}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={(value) => `${Math.round(value)}%`}
width={60}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<Tooltip content={<ChartTooltip />} />
<Line
type="monotone"
dataKey="percentage"
stroke={CHART_COLORS[0]}
name="Local Pickup"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
};
export const OnHoldDetails = ({ metrics, isLoading }) => {
if (isLoading) {
return <SkeletonChart type="line" />;
}
const onHoldData =
metrics.revenue?.daily?.map((day) => ({
date: day.date,
percentage:
day.orders > 0 ? ((day.status?.on_hold || 0) / day.orders) * 100 : 0,
})) || [];
return (
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
<ResponsiveContainer>
<LineChart
data={onHoldData}
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-gray-200 dark:stroke-gray-700"
/>
<XAxis
dataKey="date"
tickFormatter={formatChartDate}
height={50}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={(value) => `${Math.round(value)}%`}
width={60}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<Tooltip content={<ChartTooltip />} />
<Line
type="monotone"
dataKey="percentage"
stroke={CHART_COLORS[0]}
name="On Hold"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
};
export const ShippingDetails = ({ metrics }) => {
const locations = Object.entries(
metrics.orders?.shipping_locations || {}
).sort(([, a], [, b]) => b - a);
const shippedOrders = metrics.orders?.shipped_orders || 0;
const totalOrders = metrics.orders?.total || 0;
return (
<div className="space-y-6">
<DetailCard title="Shipping Overview" icon={Package}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<StatRow
label="Orders Shipped"
value={shippedOrders.toLocaleString()}
subtitle={`${((shippedOrders / totalOrders) * 100).toFixed(
1
)}% of total`}
/>
<StatRow
label="Items Shipped"
value={(metrics.shipped?.items || 0).toLocaleString()}
/>
<StatRow
label="Locations"
value={locations.length.toLocaleString()}
/>
</div>
<div className="space-y-2">
<StatRow
label="Average Processing Time"
value={
metrics.shipping?.average_processing_days
? `${metrics.shipping.average_processing_days.toFixed(
1
)} days`
: "N/A"
}
/>
<StatRow
label="Rush Orders"
value={(metrics.shipping?.rush_orders || 0).toLocaleString()}
/>
<StatRow
label="Local Pickup"
value={(metrics.orders?.local_pickup || 0).toLocaleString()}
/>
</div>
</div>
</DetailCard>
<DetailCard title="Shipping Methods" icon={Package}>
<div className="space-y-3">
{Object.entries(metrics.orders?.shipping_methods || {})
.sort(([, a], [, b]) => b - a)
.map(([method, count]) => (
<div
key={method}
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<span className="font-medium">{formatShipMethod(method)}</span>
<div className="text-sm">
<span className="font-medium">{count.toLocaleString()}</span>
<span className="text-gray-500 ml-2">
({((count / shippedOrders) * 100).toFixed(1)}%)
</span>
</div>
</div>
))}
</div>
</DetailCard>
<DetailCard title="Delivery Locations" icon={MapPin}>
<div className="grid grid-cols-2 gap-3">
{locations.map(([location, count]) => (
<div
key={location}
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<span className="font-medium">{location}</span>
<div className="text-sm">
<span className="font-medium">{count.toLocaleString()}</span>
<span className="text-gray-500 ml-2">
({((count / totalOrders) * 100).toFixed(1)}%)
</span>
</div>
</div>
))}
</div>
</DetailCard>
</div>
);
};
export const ProductsDetails = ({ metrics }) => {
const brands = Object.entries(metrics.orders?.brands || {}).sort(
([, a], [, b]) => b - a
);
const categories = Object.entries(metrics.orders?.categories || {}).sort(
([, a], [, b]) => b - a
);
return (
<div className="space-y-6">
<DetailCard title="Top Brands" icon={Star}>
<div className="grid gap-3">
{brands.map(([brand, count], index) => (
<div
key={brand}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div className="flex items-center space-x-3">
<span className="text-sm font-medium w-6">{index + 1}.</span>
<span className="font-medium">{brand}</span>
</div>
<div className="text-sm">
<span className="font-medium">{count.toLocaleString()}</span>
<span className="text-gray-500 ml-2">
({((count / metrics.orders.items_total) * 100).toFixed(1)}%)
</span>
</div>
</div>
))}
</div>
</DetailCard>
<DetailCard title="Top Categories" icon={Tags}>
<div className="grid gap-3">
{categories.map(([category, count], index) => (
<div
key={category}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div className="flex items-center space-x-3">
<span className="text-sm font-medium w-6">{index + 1}.</span>
<span className="font-medium">{category}</span>
</div>
<div className="text-sm">
<span className="font-medium">{count.toLocaleString()}</span>
<span className="text-gray-500 ml-2">
({((count / metrics.orders.items_total) * 100).toFixed(1)}%)
</span>
</div>
</div>
))}
</div>
</DetailCard>
</div>
);
};
export const RefundsDetails = ({ metrics, isLoading }) => {
if (isLoading) {
return <SkeletonChart type="bar" />;
}
const refundData =
metrics.revenue?.daily?.map((day) => ({
date: day.date,
amount: day.refunds?.total || 0,
})) || [];
return (
<TimeSeriesChart
data={refundData}
valueKey="amount"
label="Refunds"
type="bar"
valueFormatter={(value) => formatCurrency(value, 0)}
minDays={30}
/>
);
};
export const PeakHourDetails = ({ metrics, isLoading }) => {
if (isLoading) {
return <SkeletonChart type="bar" />;
}
const hourlyData =
metrics.hourly_distribution
?.map((count, hour) => ({
hour,
orders: count,
}))
.filter((data) => data.orders > 0) || [];
return (
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
<ResponsiveContainer>
<BarChart
data={hourlyData}
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-gray-200 dark:stroke-gray-700"
/>
<XAxis
dataKey="hour"
tickFormatter={formatHour}
height={50}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
width={60}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<Tooltip
formatter={(value) => [value, "Orders"]}
labelFormatter={formatHour}
content={<ChartTooltip />}
/>
<Bar
dataKey="orders"
fill={CHART_COLORS[0]}
name="Orders"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
);
};
export const CancellationsDetails = ({ metrics, isLoading }) => {
if (isLoading) {
return <SkeletonChart type="bar" />;
}
const cancelData =
metrics.revenue?.daily?.map((day) => ({
date: day.date,
amount: day.cancellations?.total || 0,
})) || [];
return (
<TimeSeriesChart
data={cancelData}
valueKey="amount"
label="Cancellations"
type="bar"
valueFormatter={(value) => formatCurrency(value, 0)}
minDays={30}
/>
);
};
export const OrderRangeDetails = ({ metrics, isLoading }) => {
if (isLoading) {
return <SkeletonChart type="range" />;
}
const rangeData = metrics.revenue?.daily
?.map((day) => {
if (!day.orders_list?.length) return null;
const validOrders = day.orders_list
.map((order) => parseFloat(order.TotalAmount))
.filter((amount) => amount > 0);
if (!validOrders.length) return null;
return {
date: day.date,
min: Math.min(...validOrders),
max: Math.max(...validOrders),
avg: validOrders.reduce((a, b) => a + b, 0) / validOrders.length,
};
})
.filter(Boolean);
return (
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
<ResponsiveContainer>
<ComposedChart
data={rangeData}
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-gray-200 dark:stroke-gray-700"
/>
<XAxis
dataKey="date"
tickFormatter={formatChartDate}
height={50}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={(value) => formatCurrency(value, 0)}
width={80}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<Tooltip content={<ChartTooltip />} />
<Bar
dataKey="min"
fill={CHART_COLORS[1]}
name="Minimum Order"
stackId="range"
/>
<Bar
dataKey="max"
fill={CHART_COLORS[0]}
name="Maximum Order"
stackId="range"
/>
<Line
type="monotone"
dataKey="avg"
stroke={CHART_COLORS[2]}
name="Average Order"
dot={false}
strokeWidth={2}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
);
};
const MetricCard = React.memo(
({ title, value, subtitle, icon: Icon, iconColor, onClick, secondaryValue }) => (
<Card
className={`bg-white dark:bg-gray-800 ${
onClick
? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
: ""
}`}
onClick={onClick}
>
<CardHeader className="flex flex-row items-center justify-between p-4 pb-0">
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">
{title}
</CardTitle>
<Icon className={`h-4 w-4 ${iconColor}`} />
</CardHeader>
<CardContent className="p-4">
<div className="text-2xl font-bold">
{value}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{subtitle}
</div>
{secondaryValue && (
<div className="text-sm text-primary mt-1 font-medium">
{secondaryValue}
</div>
)}
</CardContent>
</Card>
)
);
MetricCard.displayName = "MetricCard";
const ChartTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-white dark:bg-gray-900 p-2 shadow-lg border rounded-lg">
<p className="text-sm font-semibold mb-1">{formatChartDate(label)}</p>
{payload.map((entry, index) => (
<p key={index} className="text-sm">
<span style={{ color: entry.color }}>{entry.name}: </span>
{typeof entry.value === "number"
? entry.name.toLowerCase().includes("revenue")
? formatCurrency(entry.value)
: entry.value.toLocaleString()
: entry.value}
</p>
))}
</div>
);
};
const TimeSeriesChart = ({
data,
valueKey,
label,
type = "line",
valueFormatter = (v) => v,
}) => {
const ChartComponent =
type === "line" ? LineChart : type === "bar" ? BarChart : AreaChart;
return (
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
<ResponsiveContainer>
<ChartComponent
data={data}
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-gray-200 dark:stroke-gray-700"
/>
<XAxis
dataKey="date"
tickFormatter={formatChartDate}
height={50}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={valueFormatter}
width={60}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
/>
<Tooltip content={<ChartTooltip />} />
{type === "line" && (
<Line
type="monotone"
dataKey={valueKey}
stroke={CHART_COLORS[0]}
name={label}
dot={false}
strokeWidth={2}
/>
)}
{type === "bar" && (
<Bar
dataKey={valueKey}
fill={CHART_COLORS[0]}
name={label}
radius={[4, 4, 0, 0]}
/>
)}
{type === "area" && (
<Area
type="monotone"
dataKey={valueKey}
stroke={CHART_COLORS[0]}
fill={`url(#${label.replace(/\s+/g, "")}-gradient)`}
name={label}
>
<defs>
<linearGradient
id={`${label.replace(/\s+/g, "")}-gradient`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={CHART_COLORS[0]}
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor={CHART_COLORS[0]}
stopOpacity={0}
/>
</linearGradient>
</defs>
</Area>
)}
</ChartComponent>
</ResponsiveContainer>
</div>
);
};
// Status components
const OrderStatusTags = React.memo(({ details }) => {
if (!details) return null;
const tags = [
{
condition: details.HasPreorder,
label: "Pre-order",
color: "bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300",
},
{
condition: details.LocalPickup,
label: "Local Pickup",
color:
"bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300",
},
{
condition: details.IsOnHold,
label: "On Hold",
color:
"bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300",
},
{
condition: details.HasDigitalGC,
label: "Gift Card",
color:
"bg-indigo-100 dark:bg-indigo-900/20 text-indigo-800 dark:text-indigo-300",
},
];
return (
<div className="flex flex-wrap gap-2">
{tags.map(
({ condition, label, color }, index) =>
condition && (
<span
key={index}
className={`px-2 py-1 rounded-full text-xs cursor-help ${color}`}
>
{label}
</span>
)
)}
</div>
);
});
OrderStatusTags.displayName = "OrderStatusTags";
const PromotionalInfo = React.memo(({ details }) => {
if (!details?.PromosUsedReg?.length && !details?.PointsDiscount) return null;
return (
<div className="mt-4 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<h4 className="text-sm font-medium text-green-800 dark:text-green-400 mb-2">
Savings Applied
</h4>
<div className="space-y-2">
{details.PromosUsedReg?.map(([code, amount], index) => (
<div key={index} className="flex justify-between text-sm">
<span className="font-mono text-green-700 dark:text-green-300">
{code}
</span>
<span className="font-medium text-green-700 dark:text-green-300">
-{formatCurrency(amount)}
</span>
</div>
))}
{details.PointsDiscount > 0 && (
<div className="flex justify-between text-sm">
<span className="text-green-700 dark:text-green-300">
Points Discount
</span>
<span className="font-medium text-green-700 dark:text-green-300">
-{formatCurrency(details.PointsDiscount)}
</span>
</div>
)}
</div>
</div>
);
});
PromotionalInfo.displayName = "PromotionalInfo";
const ShippingInfo = React.memo(({ details }) => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-500">Shipping Address</h4>
<div className="text-sm space-y-1">
<div className="font-medium">{details.ShippingName}</div>
<div>{details.ShippingStreet1}</div>
{details.ShippingStreet2 && <div>{details.ShippingStreet2}</div>}
<div>
{details.ShippingCity}, {details.ShippingState} {details.ShippingZip}
</div>
{details.ShippingCountry !== "US" && (
<div>{details.ShippingCountry}</div>
)}
</div>
</div>
{details.TrackingNumber && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-500">
Tracking Information
</h4>
<div className="text-sm space-y-1">
<div className="font-medium">
{formatShipMethod(details.ShipMethod)}
</div>
<div className="font-mono text-blue-600 dark:text-blue-400">
{details.TrackingNumber}
</div>
</div>
</div>
)}
</div>
));
ShippingInfo.displayName = "ShippingInfo";
const OrderSummary = React.memo(({ details }) => (
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Subtotal</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>Items ({details.Items?.length || 0})</span>
<span>{formatCurrency(details.Subtotal)}</span>
</div>
{details.PointsDiscount > 0 && (
<div className="flex justify-between text-green-600">
<span>Points Discount</span>
<span>-{formatCurrency(details.PointsDiscount)}</span>
</div>
)}
{details.TotalDiscounts > 0 && (
<div className="flex justify-between text-green-600">
<span>Discounts</span>
<span>-{formatCurrency(details.TotalDiscounts)}</span>
</div>
)}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Shipping</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>Shipping Cost</span>
<span>{formatCurrency(details.ShippingTotal)}</span>
</div>
{details.RushFee > 0 && (
<div className="flex justify-between">
<span>Rush Fee</span>
<span>{formatCurrency(details.RushFee)}</span>
</div>
)}
{details.SalesTax > 0 && (
<div className="flex justify-between">
<span>Sales Tax</span>
<span>{formatCurrency(details.SalesTax)}</span>
</div>
)}
</div>
</div>
</div>
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center">
<div>
<span className="text-sm font-medium">Total</span>
{details.TotalSavings > 0 && (
<div className="text-xs text-green-600">
You saved {formatCurrency(details.TotalSavings)}
</div>
)}
</div>
<span className="text-lg font-bold">
{formatCurrency(details.TotalAmount)}
</span>
</div>
{details.Payments?.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-medium text-gray-500 mb-2">
Payment Details
</h4>
<div className="space-y-1">
{details.Payments.map(([method, amount], index) => (
<div key={index} className="flex justify-between text-sm">
<span>{method}</span>
<span>{formatCurrency(amount)}</span>
</div>
))}
</div>
</div>
)}
</div>
<PromotionalInfo details={details} />
</div>
));
OrderSummary.displayName = "OrderSummary";
// Loading and empty states
const LoadingState = () => (
<div className="flex flex-col items-center justify-center p-8 space-y-4">
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div className="h-full bg-primary animate-[loader_1s_ease-in-out_infinite]" />
</div>
<p className="text-sm text-gray-500">Loading data...</p>
</div>
);
const ErrorState = ({ message }) => (
<div className="flex flex-col items-center justify-center p-6">
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Metrics</h3>
<p className="text-sm text-gray-500 text-center">{message}</p>
</div>
);
// Add data validation and normalization utilities
const validateMetricsData = (data) => {
if (!data || typeof data !== 'object') {
throw new Error('Invalid metrics data: expected object');
}
if (!data.revenue?.daily || !Array.isArray(data.revenue.daily)) {
throw new Error('Invalid metrics data: missing or invalid daily revenue data');
}
return true;
};
const normalizeMetricsData = (data) => {
// Ensure all required properties exist with default values
const normalized = {
revenue: {
total: parseFloat(data.revenue?.total) || 0,
daily: data.revenue?.daily?.map(day => ({
date: day.date,
value: parseFloat(day.value) || 0,
orders: parseInt(day.orders) || 0,
items: parseInt(day.items) || 0,
pre_orders: parseInt(day.pre_orders) || 0,
local_pickup: parseInt(day.local_pickup) || 0,
refunds: {
total: parseFloat(day.refunds?.total) || 0,
count: parseInt(day.refunds?.count) || 0
},
cancellations: {
total: parseFloat(day.cancellations?.total) || 0,
count: parseInt(day.cancellations?.count) || 0
},
status: {
on_hold: parseInt(day.status?.on_hold) || 0,
processing: parseInt(day.status?.processing) || 0,
completed: parseInt(day.status?.completed) || 0
},
hourly_orders: Array.isArray(day.hourly_orders) ?
day.hourly_orders.map(count => parseInt(count) || 0) :
Array(24).fill(0),
payment_methods: day.payment_methods || {},
categories: day.categories || {},
brands: day.brands || {},
shipping_states: day.shipping_states || {},
unique_customers: Array.isArray(day.unique_customers) ?
[...new Set(day.unique_customers)] : []
})) || []
},
orders: {
total: parseInt(data.orders?.total) || 0,
items_total: parseInt(data.orders?.items_total) || 0,
pre_orders: parseInt(data.orders?.pre_orders) || 0,
local_pickup: parseInt(data.orders?.local_pickup) || 0,
unique_customers: Array.isArray(data.orders?.unique_customers) ?
[...new Set(data.orders.unique_customers)] : [],
payment_methods: data.orders?.payment_methods || {},
categories: data.orders?.categories || {},
brands: data.orders?.brands || {},
shipping_states: data.orders?.shipping_states || {},
status: {
pre_order: parseInt(data.orders?.status?.pre_order) || 0,
ready: parseInt(data.orders?.status?.ready) || 0,
on_hold: parseInt(data.orders?.status?.on_hold) || 0
}
},
hourly_distribution: Array.isArray(data.hourly_distribution) ?
data.hourly_distribution.map(count => parseInt(count) || 0) :
Array(24).fill(0),
refunds: {
count: parseInt(data.refunds?.count) || 0,
total: parseFloat(data.refunds?.total) || 0
},
cancellations: {
count: parseInt(data.cancellations?.count) || 0,
total: parseFloat(data.cancellations?.total) || 0
}
};
// Sort daily data by date
normalized.revenue.daily.sort((a, b) => new Date(a.date) - new Date(b.date));
// Calculate any derived values
if (normalized.orders.total > 0) {
normalized.orders.average_order_value = normalized.revenue.total / normalized.orders.total;
normalized.orders.average_items = normalized.orders.items_total / normalized.orders.total;
}
return normalized;
};
// Main component
const KlaviyoStats = ({ className }) => {
const [timeRange, setTimeRange] = useState("today");
const [timeRangeChanging, setTimeRangeChanging] = useState(false);
const [metrics, setMetrics] = useState(null);
const [previousMetrics, setPreviousMetrics] = useState(null);
const [error, setError] = useState(null);
const [selectedMetric, setSelectedMetric] = useState(null);
const [lastUpdate, setLastUpdate] = useState(null);
// Extended data pre-loaded in background
const [extendedData, setExtendedData] = useState(null);
const [isLoadingExtraData, setIsLoadingExtraData] = useState(false);
const [isLoadingPrevious, setIsLoadingPrevious] = useState(false);
const handleTimeRangeChange = useCallback(async (newRange) => {
const validRanges = [
'today',
'yesterday',
'last2days',
'last7days',
'last30days',
'last90days',
'previous7days',
'previous30days',
'previous90days'
];
if (!validRanges.includes(newRange)) {
console.error(`Invalid time range: ${newRange}`);
return;
}
setTimeRangeChanging(true);
setTimeRange(newRange);
}, []);
const isSingleDay = timeRange === "today" || timeRange === "yesterday";
const processedMetrics = useMemo(() => {
if (!metrics) return null;
console.log("[KLAVIYO STATS] Processing metrics:", {
hasMetrics: !!metrics,
hasPreviousMetrics: !!previousMetrics,
revenue: metrics.revenue,
orders: metrics.orders,
refunds: metrics.refunds,
cancellations: metrics.cancellations,
timeRange
});
const getComparison = (current, previous) => {
if (!previous) return null;
const diff = current - previous;
return {
diff,
percent: previous ? (diff / previous) * 100 : 0,
increased: diff > 0,
};
};
// Ensure we have valid numbers for all metrics
const currentRevenue = parseFloat(metrics.revenue?.total) || 0;
const previousRevenue = parseFloat(previousMetrics?.revenue?.total) || 0;
const revenueComparison = getComparison(currentRevenue, previousRevenue);
const totalOrders = parseInt(metrics.orders?.total) || 0;
const totalItems = parseInt(metrics.orders?.items_total) || 0;
const shippedOrders = parseInt(metrics.orders?.shipped_orders) || 0;
const onHoldCount = parseInt(metrics.orders?.status?.on_hold) || 0;
const preOrderCount = parseInt(metrics.orders?.pre_orders) || 0;
const localPickupCount = parseInt(metrics.orders?.local_pickup) || 0;
const refundCount = parseInt(metrics.refunds?.count) || 0;
const refundTotal = parseFloat(metrics.refunds?.total) || 0;
const cancelCount = parseInt(metrics.cancellations?.count) || 0;
const cancelTotal = parseFloat(metrics.cancellations?.total) || 0;
const avgOrderValue = totalOrders > 0 ? currentRevenue / totalOrders : 0;
const avgItems = totalOrders > 0 ? totalItems / totalOrders : 0;
const processed = {
revenue: {
current: currentRevenue,
previous: previousRevenue,
comparison: revenueComparison,
},
orders: {
current: totalOrders,
items: totalItems,
avgItems: avgItems,
avgValue: avgOrderValue,
shipped_orders: shippedOrders,
locations: Object.keys(metrics.orders?.shipping_locations || {}).length,
preOrders: {
count: preOrderCount,
percent: totalOrders > 0 ? (preOrderCount / totalOrders) * 100 : 0,
},
localPickup: {
count: localPickupCount,
percent: totalOrders > 0 ? (localPickupCount / totalOrders) * 100 : 0,
},
onHold: {
count: onHoldCount,
percent: totalOrders > 0 ? (onHoldCount / totalOrders) * 100 : 0,
},
largest: metrics.orders?.largest || { value: 0, items: 0 },
smallest: metrics.orders?.smallest || { value: 0, items: 0 },
},
products: {
brands: Object.keys(metrics.orders?.brands || {}).length,
categories: Object.keys(metrics.orders?.categories || {}).length,
},
peak: metrics.peak_hour || null,
bestDay: metrics.revenue?.best_day || null,
refunds: {
count: refundCount,
total: refundTotal,
},
cancellations: {
count: cancelCount,
total: cancelTotal,
},
};
console.log("[KLAVIYO STATS] Processed metrics:", processed);
return processed;
}, [metrics, previousMetrics, timeRange]);
const RevenueCard = useMemo(() => {
const comparison = processedMetrics?.revenue.comparison;
return (
<MetricCard
title="Revenue"
value={
timeRangeChanging ? (
<Skeleton className="h-8 w-32" />
) : formatCurrency(processedMetrics?.revenue.current, 0)
}
subtitle={
<div className="flex items-center justify-between">
{timeRangeChanging || isLoadingPrevious ? (
<Skeleton className="h-4 w-24" />
) : previousMetrics ? (
<>
<span>{`prev: ${formatCurrency(previousMetrics.revenue?.total || 0, 0)}`}</span>
{comparison && (
<div className={`flex items-center ${comparison.increased ? "text-green-500" : "text-red-500"} text-xs ml-2`}>
{comparison.increased ? <ArrowUp className="h-3 w-3 mr-1" /> : <ArrowDown className="h-3 w-3 mr-1" />}
{Math.abs(comparison.percent).toFixed(1)}%
</div>
)}
</>
) : null}
</div>
}
icon={DollarSign}
iconColor="text-green-500"
onClick={() => setSelectedMetric("revenue")}
/>
);
}, [timeRangeChanging, isLoadingPrevious, processedMetrics, previousMetrics]);
const hasFetchedExtendedData = useRef(false);
const fetchExtendedData = useCallback(async () => {
if (hasFetchedExtendedData.current) return; // Skip fetch if already fetched
setIsLoadingExtraData(true);
try {
const extendedResponse = await fetch(`/api/klaviyo/metrics/last30days`);
const extendedData = await extendedResponse.json();
setExtendedData(extendedData);
hasFetchedExtendedData.current = true; // Mark as fetched
} catch (error) {
console.error("Error fetching extended data:", error);
} finally {
setIsLoadingExtraData(false);
}
}, []);
const fetchData = useCallback(async () => {
console.log("[KLAVIYO STATS] Starting fetchData:", { timeRange });
setTimeRangeChanging(true);
setError(null);
try {
// Fetch current period data
const currentResponse = await fetch(`/api/klaviyo/metrics/${timeRange}`);
if (!currentResponse.ok) {
throw new Error(`Failed to fetch current metrics: ${currentResponse.status}`);
}
const rawData = await currentResponse.json();
// Validate and normalize the data
try {
validateMetricsData(rawData);
const currentData = normalizeMetricsData(rawData);
setMetrics(currentData);
setLastUpdate(DateTime.now().setZone('America/New_York'));
} catch (validationError) {
console.error("[KLAVIYO STATS] Data validation error:", validationError);
throw new Error(`Invalid data structure: ${validationError.message}`);
}
// Fetch previous period data
const prevPeriod = getPreviousPeriod(timeRange);
setIsLoadingPrevious(true);
try {
const previousResponse = await fetch(`/api/klaviyo/metrics/${prevPeriod}`);
if (!previousResponse.ok) {
console.warn(`Failed to fetch previous period metrics: ${previousResponse.status}`);
} else {
const rawPreviousData = await previousResponse.json();
try {
validateMetricsData(rawPreviousData);
const previousData = normalizeMetricsData(rawPreviousData);
setPreviousMetrics(previousData);
} catch (validationError) {
console.warn("[KLAVIYO STATS] Previous period data validation error:", validationError);
}
}
} catch (error) {
console.error("[KLAVIYO STATS] Error fetching previous period:", error);
} finally {
setIsLoadingPrevious(false);
}
console.log("[KLAVIYO STATS] Processed data:", {
timeRange,
totalRevenue: metrics?.revenue?.total,
dailyCount: metrics?.revenue?.daily?.length,
hasPreviousData: !!previousMetrics
});
} catch (error) {
console.error("[KLAVIYO STATS] Error fetching data:", error);
setError(error.message);
} finally {
setTimeRangeChanging(false);
}
}, [timeRange, getPreviousPeriod]);
// Add helper function for getting previous period
const getPreviousPeriod = useCallback((currentRange) => {
switch (currentRange) {
case 'today':
return 'yesterday';
case 'yesterday':
return 'last2days';
case 'last7days':
return 'previous7days';
case 'last30days':
return 'previous30days';
case 'last90days':
return 'previous90days';
default:
return currentRange;
}
}, []);
useEffect(() => {
let isSubscribed = true;
const loadMainData = async () => {
if (!isSubscribed) return;
console.log("[KLAVIYO STATS] Loading main data for timeRange:", timeRange);
await fetchData();
};
const loadExtendedData = async () => {
if (!isSubscribed) return;
console.log("[KLAVIYO STATS] Loading extended data");
await fetchExtendedData();
};
loadMainData().then(() => {
loadExtendedData();
});
let interval;
if (timeRange === "today") {
interval = setInterval(() => {
if (isSubscribed) {
console.log("[KLAVIYO STATS] Auto-refreshing today's data");
loadMainData();
}
}, 5 * 60 * 1000);
}
return () => {
isSubscribed = false;
if (interval) clearInterval(interval);
};
}, [timeRange, fetchData, fetchExtendedData]);
const dataForDetails = extendedData || metrics;
const renderMetricDetails = useCallback(() => {
if (!selectedMetric || !metrics) return null;
const isDetailLoading = !extendedData || timeRangeChanging || isLoadingExtraData;
switch (selectedMetric) {
case "revenue":
return <RevenueDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
case "orders":
return <OrdersDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
case "average_order":
return <AverageOrderDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
case "brands_categories":
return <BrandsAndCategoriesDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
case "shipped":
return <ShippedOrdersDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
case "pre_orders":
return <PreOrdersDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
case "local_pickup":
return <LocalPickupDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
case "on_hold":
return <OnHoldDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
case "refunds":
return <RefundsDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
case "cancellations":
return <CancellationsDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
case "order_range":
return <OrderRangeDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
case "peak_hour":
return <PeakHourDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
default:
return null;
}
}, [selectedMetric, metrics, timeRangeChanging, isLoadingExtraData, extendedData, dataForDetails]);
if (error) {
return (
<Card className={className}>
<CardContent className="pt-6">
<div className="text-red-600 dark:text-red-400">
Error loading metrics: {error}
</div>
</CardContent>
</Card>
);
}return (
<Card className={className}>
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle>Sales Dashboard</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
{timeRange === "today" ? (
`Today (${DateTime.now().setZone('America/New_York').toFormat('M/d h:mm a')} ET)`
) : timeRange === "yesterday" ? (
`Yesterday (${DateTime.now().setZone('America/New_York').minus({ days: 1 }).toFormat('M/d')} 1:00 AM - 12:59 AM ET)`
) : (
`${DateTime.now().setZone('America/New_York').minus({
days: timeRange === "last7days" ? 6 :
timeRange === "last30days" ? 29 : 89
}).toFormat('M/d')} - ${DateTime.now().setZone('America/New_York').toFormat('M/d h:mm a')} ET`
)}
</p>
</div>
<div className="flex items-center gap-4">
<TimeRangeSelect
value={timeRange}
onChange={handleTimeRangeChange}
className="w-40"
/>
{lastUpdate && !timeRangeChanging && (
<span className="text-sm text-gray-500">
{lastUpdate.toFormat("hh:mm a")}
</span>
)}
</div>
</div>
</CardHeader>
<CardContent>
{error ? (
<ErrorState message={error} />
) : timeRangeChanging ? (
<SkeletonMetricGrid />
) : processedMetrics ? (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{RevenueCard}
<MetricCard
title="Orders"
value={processedMetrics.orders.current.toLocaleString()}
subtitle={`${processedMetrics.orders.items.toLocaleString()} items`}
icon={ShoppingCart}
iconColor="text-blue-500"
onClick={() => setSelectedMetric("orders")}
/>
<MetricCard
title="Average Order"
value={formatCurrency(processedMetrics.orders.avgValue)}
subtitle={`${processedMetrics.orders.avgItems.toFixed(
1
)} items/order`}
icon={CircleDollarSign}
iconColor="text-purple-500"
onClick={() => setSelectedMetric("average_order")}
/>
<MetricCard
title="Brands & Categories"
value={processedMetrics.products.brands}
subtitle={`${processedMetrics.products.categories} categories`}
icon={Tags}
iconColor="text-indigo-500"
onClick={() => setSelectedMetric("brands_categories")}
/>
<MetricCard
title="Shipped Orders"
value={processedMetrics.orders.shipped_orders}
subtitle={`${processedMetrics.orders.locations} locations`}
icon={Package}
iconColor="text-teal-500"
onClick={() => setSelectedMetric("shipped")}
/>
<MetricCard
title="Pre-Orders"
value={processedMetrics.orders.preOrders.count}
subtitle={`${processedMetrics.orders.preOrders.percent.toFixed(
1
)}% of orders`}
icon={Clock}
iconColor="text-yellow-500"
onClick={() => setSelectedMetric("pre_orders")}
/>
<MetricCard
title="Local Pickup"
value={processedMetrics.orders.localPickup.count}
subtitle={`${processedMetrics.orders.localPickup.percent.toFixed(
1
)}% of orders`}
icon={Map}
iconColor="text-cyan-500"
onClick={() => setSelectedMetric("local_pickup")}
/>
<MetricCard
title="On Hold"
value={processedMetrics?.orders?.onHold?.count ?? 0}
subtitle={`${(processedMetrics?.orders?.onHold?.percent ?? 0).toFixed(
1
)}% of orders`}
icon={AlertCircle}
iconColor="text-red-500"
onClick={() => setSelectedMetric("on_hold")}
/>
{isSingleDay ? (
<MetricCard
title="Peak Hour"
value={
processedMetrics.peak
? `${processedMetrics.peak.hour
.toString()
.padStart(2, "0")}:00`
: "N/A"
}
subtitle={
processedMetrics.peak
? `${processedMetrics.peak.orders} orders`
: undefined
}
icon={Clock}
iconColor="text-pink-500"
onClick={() => setSelectedMetric("peak_hour")}
/>
) : (
<MetricCard
title="Best Day"
value={formatCurrency(processedMetrics.bestDay?.value || 0)}
subtitle={
processedMetrics.bestDay?.date
? formatChartDate(processedMetrics.bestDay.date)
: undefined
}
icon={TrendingUp}
iconColor="text-emerald-500"
onClick={() => setSelectedMetric("revenue")}
/>
)}
<MetricCard
title="Refunds"
value={processedMetrics.refunds.count}
subtitle={formatCurrency(processedMetrics.refunds.total)}
icon={RefreshCcw}
iconColor="text-orange-500"
onClick={() => setSelectedMetric("refunds")}
/>
<MetricCard
title="Cancellations"
value={processedMetrics.cancellations.count}
subtitle={formatCurrency(processedMetrics.cancellations.total)}
icon={XCircle}
iconColor="text-rose-500"
onClick={() => setSelectedMetric("cancellations")}
/>
<MetricCard
title="Order Range"
value={formatCurrency(processedMetrics.orders.largest.value)}
subtitle={formatCurrency(
processedMetrics.orders.smallest.value
)}
icon={TrendingUp}
iconColor="text-violet-500"
onClick={() => setSelectedMetric("order_range")}
/>
</div>
{selectedMetric && (
<Dialog
open={!!selectedMetric}
onOpenChange={() => setSelectedMetric(null)}
>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-auto">
<DialogHeader>
<DialogTitle>
{selectedMetric
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")}{" "}
Details
</DialogTitle>
</DialogHeader>
{renderMetricDetails()}
</DialogContent>
</Dialog>
)}
</>
) : null}
</CardContent>
</Card>
);
};
export default KlaviyoStats;