Create ministatcards and initial layout changes
This commit is contained in:
@@ -27,6 +27,7 @@ import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
|
|||||||
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
|
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
|
||||||
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
|
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
|
||||||
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
|
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
|
||||||
|
import MiniStatCards from "@/components/dashboard/MiniStatCards";
|
||||||
|
|
||||||
// Public layout
|
// Public layout
|
||||||
const PublicLayout = () => (
|
const PublicLayout = () => (
|
||||||
@@ -61,23 +62,42 @@ const PinProtectedLayout = ({ children }) => {
|
|||||||
|
|
||||||
// Small Layout
|
// Small Layout
|
||||||
const SmallLayout = () => {
|
const SmallLayout = () => {
|
||||||
const SCALE_FACTOR = 2;
|
const DATETIME_SCALE = 2;
|
||||||
|
const STATS_SCALE = 1.5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-screen overflow-hidden">
|
<div className="min-h-screen w-screen overflow-hidden">
|
||||||
<div className="flex">
|
<div className="flex flex-col">
|
||||||
<div
|
<span className="absolute top-4 left-4 z-50">
|
||||||
style={{
|
|
||||||
transform: `scale(${SCALE_FACTOR})`,
|
|
||||||
transformOrigin: "top left",
|
|
||||||
padding: "1.5rem",
|
|
||||||
marginBottom: "1.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="absolute top-0 left-0">
|
|
||||||
<LockButton />
|
<LockButton />
|
||||||
</span>
|
</span>
|
||||||
<DateTimeWeatherDisplay scaleFactor={SCALE_FACTOR} />
|
|
||||||
|
<div className="p-4 space-y-4 grid grid-cols-12 gap-0">
|
||||||
|
<div
|
||||||
|
className="col-span-4"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${DATETIME_SCALE})`,
|
||||||
|
transformOrigin: "top left",
|
||||||
|
width: `${100/DATETIME_SCALE}%`,
|
||||||
|
height: `${100/DATETIME_SCALE}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DateTimeWeatherDisplay />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="col-span-8"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${STATS_SCALE})`,
|
||||||
|
transformOrigin: "top left",
|
||||||
|
width: `${100/STATS_SCALE}%`,
|
||||||
|
height: `${100/STATS_SCALE}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MiniStatCards
|
||||||
|
title="Live Stats"
|
||||||
|
timeRange="today"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
352
dashboard/src/components/dashboard/MiniStatCards.jsx
Normal file
352
dashboard/src/components/dashboard/MiniStatCards.jsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, memo } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { TIME_RANGES } from "@/lib/constants";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
DollarSign,
|
||||||
|
ShoppingCart,
|
||||||
|
Package,
|
||||||
|
AlertCircle,
|
||||||
|
CircleDollarSign,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
// Import the detail view components and utilities from StatCards
|
||||||
|
import {
|
||||||
|
RevenueDetails,
|
||||||
|
OrdersDetails,
|
||||||
|
AverageOrderDetails,
|
||||||
|
ShippingDetails,
|
||||||
|
StatCard,
|
||||||
|
DetailDialog,
|
||||||
|
formatCurrency,
|
||||||
|
formatPercentage,
|
||||||
|
SkeletonCard,
|
||||||
|
} from "./StatCards";
|
||||||
|
|
||||||
|
const MiniStatCards = ({
|
||||||
|
timeRange: initialTimeRange = "today",
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
title = "Quick Stats",
|
||||||
|
description = "",
|
||||||
|
compact = false
|
||||||
|
}) => {
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState(null);
|
||||||
|
const [timeRange, setTimeRange] = useState(initialTimeRange);
|
||||||
|
const [selectedMetric, setSelectedMetric] = useState(null);
|
||||||
|
const [detailDataLoading, setDetailDataLoading] = useState({});
|
||||||
|
const [detailData, setDetailData] = useState({});
|
||||||
|
const [projection, setProjection] = useState(null);
|
||||||
|
const [projectionLoading, setProjectionLoading] = useState(false);
|
||||||
|
|
||||||
|
// Reuse the trend calculation functions
|
||||||
|
const calculateTrend = useCallback((current, previous) => {
|
||||||
|
if (!current || !previous) return null;
|
||||||
|
const trend = current >= previous ? "up" : "down";
|
||||||
|
const diff = Math.abs(current - previous);
|
||||||
|
const percentage = (diff / previous) * 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
trend,
|
||||||
|
value: percentage,
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const calculateRevenueTrend = useCallback(() => {
|
||||||
|
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null;
|
||||||
|
const currentRevenue = stats.periodProgress < 100 ? stats.projectedRevenue : stats.revenue;
|
||||||
|
const prevRevenue = stats.prevPeriodRevenue;
|
||||||
|
|
||||||
|
if (!currentRevenue || !prevRevenue) return null;
|
||||||
|
|
||||||
|
const trend = currentRevenue >= prevRevenue ? "up" : "down";
|
||||||
|
const diff = Math.abs(currentRevenue - prevRevenue);
|
||||||
|
const percentage = (diff / prevRevenue) * 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
trend,
|
||||||
|
value: percentage,
|
||||||
|
current: currentRevenue,
|
||||||
|
previous: prevRevenue,
|
||||||
|
};
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
const calculateOrderTrend = useCallback(() => {
|
||||||
|
if (!stats?.prevPeriodOrders) return null;
|
||||||
|
return calculateTrend(stats.orderCount, stats.prevPeriodOrders);
|
||||||
|
}, [stats, calculateTrend]);
|
||||||
|
|
||||||
|
const calculateAOVTrend = useCallback(() => {
|
||||||
|
if (!stats?.prevPeriodAOV) return null;
|
||||||
|
return calculateTrend(stats.averageOrderValue, stats.prevPeriodAOV);
|
||||||
|
}, [stats, calculateTrend]);
|
||||||
|
|
||||||
|
// Initial load effect
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setStats(null);
|
||||||
|
|
||||||
|
const params = timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
||||||
|
const response = await axios.get("/api/klaviyo/events/stats", { params });
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
setStats(response.data.stats);
|
||||||
|
setLastUpdate(DateTime.now().setZone("America/New_York"));
|
||||||
|
setError(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading data:", error);
|
||||||
|
if (isMounted) {
|
||||||
|
setError(error.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [timeRange, startDate, endDate]);
|
||||||
|
|
||||||
|
// Load smart projection separately
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadProjection = async () => {
|
||||||
|
if (!stats?.periodProgress || stats.periodProgress >= 100) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProjectionLoading(true);
|
||||||
|
const params = timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
||||||
|
const response = await axios.get("/api/klaviyo/events/projection", { params });
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
setProjection(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading projection:", error);
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setProjectionLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadProjection();
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [timeRange, startDate, endDate, stats?.periodProgress]);
|
||||||
|
|
||||||
|
// Auto-refresh for 'today' view
|
||||||
|
useEffect(() => {
|
||||||
|
if (timeRange !== "today") return;
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const [statsResponse, projectionResponse] = await Promise.all([
|
||||||
|
axios.get("/api/klaviyo/events/stats", { params: { timeRange: "today" } }),
|
||||||
|
axios.get("/api/klaviyo/events/projection", { params: { timeRange: "today" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setStats(statsResponse.data.stats);
|
||||||
|
setProjection(projectionResponse.data);
|
||||||
|
setLastUpdate(DateTime.now().setZone("America/New_York"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error auto-refreshing stats:", error);
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [timeRange]);
|
||||||
|
|
||||||
|
if (loading && !stats) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||||
|
|
||||||
|
<CardContent className="p-4 pt-0">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<SkeletonCard key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>Failed to load stats: {error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
const revenueTrend = calculateRevenueTrend();
|
||||||
|
const orderTrend = calculateOrderTrend();
|
||||||
|
const aovTrend = calculateAOVTrend();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||||
|
<CardHeader className="p-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
|
||||||
|
{lastUpdate && !loading && (
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Last updated {lastUpdate.toFormat("h:mm a")}
|
||||||
|
{projection?.confidence > 0 && !projectionLoading && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
({Math.round(projection.confidence * 100)}%)
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[250px]">
|
||||||
|
<p>Confidence level of revenue projection</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-0">
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<StatCard
|
||||||
|
title="Total Revenue"
|
||||||
|
value={formatCurrency(stats?.revenue || 0)}
|
||||||
|
description={
|
||||||
|
stats?.periodProgress < 100 ? (
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<span>Proj: </span>
|
||||||
|
{projectionLoading ? (
|
||||||
|
<Skeleton className="h-4 w-15" />
|
||||||
|
) : (
|
||||||
|
formatCurrency(projection?.projectedRevenue || stats.projectedRevenue)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
progress={stats?.periodProgress < 100 ? stats.periodProgress : undefined}
|
||||||
|
trend={projectionLoading && stats?.periodProgress < 100 ? undefined : revenueTrend?.trend}
|
||||||
|
trendValue={revenueTrend?.value ? formatPercentage(revenueTrend.value) : null}
|
||||||
|
colorClass="text-green-600 dark:text-green-400"
|
||||||
|
icon={DollarSign}
|
||||||
|
iconColor="text-green-500"
|
||||||
|
onDetailsClick={() => setSelectedMetric("revenue")}
|
||||||
|
isLoading={loading || !stats}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
title="Orders"
|
||||||
|
value={stats?.orderCount}
|
||||||
|
description={`${stats?.itemCount} items`}
|
||||||
|
trend={orderTrend?.trend}
|
||||||
|
trendValue={orderTrend?.value ? formatPercentage(orderTrend.value) : null}
|
||||||
|
colorClass="text-blue-600 dark:text-blue-400"
|
||||||
|
icon={ShoppingCart}
|
||||||
|
iconColor="text-blue-500"
|
||||||
|
onDetailsClick={() => setSelectedMetric("orders")}
|
||||||
|
isLoading={loading || !stats}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
title="AOV"
|
||||||
|
value={stats?.averageOrderValue?.toFixed(2)}
|
||||||
|
valuePrefix="$"
|
||||||
|
description={`${stats?.averageItemsPerOrder?.toFixed(1)}/order`}
|
||||||
|
trend={aovTrend?.trend}
|
||||||
|
trendValue={aovTrend?.value ? formatPercentage(aovTrend.value) : null}
|
||||||
|
colorClass="text-purple-600 dark:text-purple-400"
|
||||||
|
icon={CircleDollarSign}
|
||||||
|
iconColor="text-purple-500"
|
||||||
|
onDetailsClick={() => setSelectedMetric("average_order")}
|
||||||
|
isLoading={loading || !stats}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCard
|
||||||
|
title="Shipped"
|
||||||
|
value={stats?.shipping?.shippedCount || 0}
|
||||||
|
description={`${stats?.shipping?.locations?.total || 0} locations`}
|
||||||
|
colorClass="text-teal-600 dark:text-teal-400"
|
||||||
|
icon={Package}
|
||||||
|
iconColor="text-teal-500"
|
||||||
|
onDetailsClick={() => setSelectedMetric("shipping")}
|
||||||
|
isLoading={loading || !stats}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetailDialog
|
||||||
|
open={!!selectedMetric}
|
||||||
|
onOpenChange={() => setSelectedMetric(null)}
|
||||||
|
title={
|
||||||
|
selectedMetric
|
||||||
|
? `${selectedMetric
|
||||||
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ")} Details`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedMetric === "revenue" && <RevenueDetails data={detailData.revenue || []} />}
|
||||||
|
{selectedMetric === "orders" && <OrdersDetails data={detailData.orders || []} />}
|
||||||
|
{selectedMetric === "average_order" && (
|
||||||
|
<AverageOrderDetails
|
||||||
|
data={detailData.average_order || []}
|
||||||
|
orderCount={stats.orderCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedMetric === "shipping" && <ShippingDetails data={[stats]} timeRange={timeRange} />}
|
||||||
|
</DetailDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MiniStatCards;
|
||||||
@@ -2138,4 +2138,17 @@ const StatCards = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export components and utilities for MiniStatCards
|
||||||
|
export {
|
||||||
|
RevenueDetails,
|
||||||
|
OrdersDetails,
|
||||||
|
AverageOrderDetails,
|
||||||
|
ShippingDetails,
|
||||||
|
StatCard,
|
||||||
|
DetailDialog,
|
||||||
|
formatCurrency,
|
||||||
|
formatPercentage,
|
||||||
|
SkeletonCard,
|
||||||
|
};
|
||||||
|
|
||||||
export default StatCards;
|
export default StatCards;
|
||||||
|
|||||||
Reference in New Issue
Block a user