Create ministatcards and initial layout changes

This commit is contained in:
2025-01-01 11:26:18 -05:00
parent f0ad4d64a2
commit a563dbfb39
3 changed files with 399 additions and 14 deletions

View File

@@ -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>

View 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;

View File

@@ -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;