diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index d6bc85d..294a9bd 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -27,6 +27,7 @@ import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard"; import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics"; import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard"; import TypeformDashboard from "@/components/dashboard/TypeformDashboard"; +import MiniStatCards from "@/components/dashboard/MiniStatCards"; // Public layout const PublicLayout = () => ( @@ -61,23 +62,42 @@ const PinProtectedLayout = ({ children }) => { // Small Layout const SmallLayout = () => { - const SCALE_FACTOR = 2; + const DATETIME_SCALE = 2; + const STATS_SCALE = 1.5; return (
-
-
- - - - +
+ + + + +
+
+ +
+
+ +
diff --git a/dashboard/src/components/dashboard/MiniStatCards.jsx b/dashboard/src/components/dashboard/MiniStatCards.jsx new file mode 100644 index 0000000..6cd8cd7 --- /dev/null +++ b/dashboard/src/components/dashboard/MiniStatCards.jsx @@ -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 ( + + + +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+
+
+ ); + } + + if (error) { + return ( + + + Error + Failed to load stats: {error} + + ); + } + + if (!stats) return null; + + const revenueTrend = calculateRevenueTrend(); + const orderTrend = calculateOrderTrend(); + const aovTrend = calculateAOVTrend(); + + return ( + + +
+
+ + {lastUpdate && !loading && ( + + Last updated {lastUpdate.toFormat("h:mm a")} + {projection?.confidence > 0 && !projectionLoading && ( + + + + + ({Math.round(projection.confidence * 100)}%) + + + +

Confidence level of revenue projection

+
+
+
+ )} +
+ )} +
+ + +
+
+ +
+ + Proj: + {projectionLoading ? ( + + ) : ( + formatCurrency(projection?.projectedRevenue || stats.projectedRevenue) + )} +
+ ) : 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} + /> + + setSelectedMetric("orders")} + isLoading={loading || !stats} + /> + + setSelectedMetric("average_order")} + isLoading={loading || !stats} + /> + + setSelectedMetric("shipping")} + isLoading={loading || !stats} + /> +
+ + setSelectedMetric(null)} + title={ + selectedMetric + ? `${selectedMetric + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" ")} Details` + : "" + } + > + {selectedMetric === "revenue" && } + {selectedMetric === "orders" && } + {selectedMetric === "average_order" && ( + + )} + {selectedMetric === "shipping" && } + + + + ); +}; + +export default MiniStatCards; \ No newline at end of file diff --git a/dashboard/src/components/dashboard/StatCards.jsx b/dashboard/src/components/dashboard/StatCards.jsx index 900edab..8a07091 100644 --- a/dashboard/src/components/dashboard/StatCards.jsx +++ b/dashboard/src/components/dashboard/StatCards.jsx @@ -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;