Fix styling regression on main statcards component
This commit is contained in:
@@ -29,6 +29,7 @@ import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard"
|
||||
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
|
||||
import MiniStatCards from "@/components/dashboard/MiniStatCards";
|
||||
import MiniRealtimeAnalytics from "@/components/dashboard/MiniRealtimeAnalytics";
|
||||
import MiniSalesChart from "@/components/dashboard/MiniSalesChart";
|
||||
|
||||
// Public layout
|
||||
const PublicLayout = () => (
|
||||
@@ -66,6 +67,7 @@ const SmallLayout = () => {
|
||||
const DATETIME_SCALE = 2;
|
||||
const STATS_SCALE = 1.65;
|
||||
const ANALYTICS_SCALE = 1.65;
|
||||
const SALES_SCALE = 1.65;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-screen">
|
||||
@@ -81,31 +83,51 @@ const SmallLayout = () => {
|
||||
transformOrigin: 'top left',
|
||||
width: `${100/DATETIME_SCALE}%`
|
||||
}}>
|
||||
<DateTimeWeatherDisplay />
|
||||
<DateTimeWeatherDisplay scaleFactor={DATETIME_SCALE} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats and Analytics */}
|
||||
<div className="col-span-9">
|
||||
<div>
|
||||
<div style={{
|
||||
transform: `scale(${STATS_SCALE})`,
|
||||
transformOrigin: 'top left',
|
||||
width: `${100/STATS_SCALE}%`
|
||||
}}>
|
||||
<MiniStatCards
|
||||
title="Live Stats"
|
||||
timeRange="today"
|
||||
/>
|
||||
<div className="">
|
||||
{/* Mini Stat Cards */}
|
||||
<div>
|
||||
<div style={{
|
||||
transform: `scale(${STATS_SCALE})`,
|
||||
transformOrigin: 'top left',
|
||||
width: `${100/STATS_SCALE}%`
|
||||
}}>
|
||||
<MiniStatCards
|
||||
title="Live Stats"
|
||||
timeRange="today"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2 ml-auto mt-28 pl-1">
|
||||
<div style={{
|
||||
transform: `scale(${ANALYTICS_SCALE})`,
|
||||
transformOrigin: 'top left',
|
||||
width: `${100/ANALYTICS_SCALE}%`
|
||||
}}>
|
||||
<MiniRealtimeAnalytics />
|
||||
|
||||
{/* Mini Charts Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-28">
|
||||
{/* Mini Sales Chart */}
|
||||
<div>
|
||||
<div style={{
|
||||
transform: `scale(${SALES_SCALE})`,
|
||||
transformOrigin: 'top left',
|
||||
width: `${100/SALES_SCALE}%`
|
||||
}}>
|
||||
<MiniSalesChart />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Realtime Analytics */}
|
||||
<div>
|
||||
<div style={{
|
||||
transform: `scale(${ANALYTICS_SCALE})`,
|
||||
transformOrigin: 'top left',
|
||||
width: `${100/ANALYTICS_SCALE}%`,
|
||||
height: `${100/ANALYTICS_SCALE}%`
|
||||
}}>
|
||||
<MiniRealtimeAnalytics />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
245
dashboard/src/components/dashboard/MiniSalesChart.jsx
Normal file
245
dashboard/src/components/dashboard/MiniSalesChart.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import React, { useState, useEffect, useCallback, memo } from "react";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { DateTime } from "luxon";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertCircle, TrendingUp, DollarSign, ShoppingCart } from "lucide-react";
|
||||
import { formatCurrency, CustomTooltip, processData, StatCard } from "./SalesChart.jsx";
|
||||
|
||||
const SkeletonChart = () => (
|
||||
<div className="h-[200px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-4">
|
||||
<div className="h-full relative">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-full h-px bg-muted"
|
||||
style={{ top: `${(i + 1) * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-6 bg-muted rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-8 bg-muted rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-x-8 bottom-6 top-4">
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
className="absolute inset-0 bg-muted rounded-sm"
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MiniStatCard = memo(({ title, value, icon: Icon, colorClass, iconColor, iconBackground, background }) => (
|
||||
<Card className={`w-full ${background || 'bg-gradient-to-br from-gray-800 to-gray-900 backdrop-blur-md'}`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className="text-sm font-bold text-gray-100">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{Icon && (
|
||||
<div className="relative p-3">
|
||||
<div className={`absolute inset-0 rounded-full ${iconBackground}`} />
|
||||
<Icon className={`h-4 w-4 ${iconColor} relative`} />
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className={`text-3xl font-extrabold ${colorClass}`}>
|
||||
{value}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
));
|
||||
|
||||
MiniStatCard.displayName = "MiniStatCard";
|
||||
|
||||
const MiniSalesChart = ({ className = "" }) => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [summaryStats, setSummaryStats] = useState({
|
||||
totalRevenue: 0,
|
||||
totalOrders: 0,
|
||||
});
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await axios.get("/api/klaviyo/events/stats/details", {
|
||||
params: {
|
||||
timeRange: "last30days",
|
||||
metric: "revenue",
|
||||
daily: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Invalid response format");
|
||||
}
|
||||
|
||||
const stats = Array.isArray(response.data)
|
||||
? response.data
|
||||
: response.data.stats || [];
|
||||
|
||||
const processedData = processData(stats);
|
||||
|
||||
// Calculate totals
|
||||
const totals = stats.reduce((acc, day) => ({
|
||||
totalRevenue: acc.totalRevenue + (Number(day.revenue) || 0),
|
||||
totalOrders: acc.totalOrders + (Number(day.orders) || 0),
|
||||
}), { totalRevenue: 0, totalOrders: 0 });
|
||||
|
||||
setData(processedData);
|
||||
setSummaryStats(totals);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const intervalId = setInterval(fetchData, 300000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [fetchData]);
|
||||
|
||||
const formatXAxis = (value) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric"
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive" className="bg-white/10 backdrop-blur-sm">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>Failed to load sales data: {error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MiniStatCard
|
||||
title="30 Days Revenue"
|
||||
value={formatCurrency(summaryStats.totalRevenue, false)}
|
||||
colorClass="text-emerald-200"
|
||||
icon={DollarSign}
|
||||
iconColor="text-emerald-900"
|
||||
iconBackground="bg-emerald-300"
|
||||
background="bg-gradient-to-br from-emerald-900 to-emerald-800"
|
||||
/>
|
||||
<MiniStatCard
|
||||
title="30 Days Orders"
|
||||
value={summaryStats.totalOrders.toLocaleString()}
|
||||
colorClass="text-blue-200"
|
||||
icon={ShoppingCart}
|
||||
iconColor="text-blue-900"
|
||||
iconBackground="bg-blue-300"
|
||||
background="bg-gradient-to-br from-blue-900 to-blue-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
{loading ? (
|
||||
<SkeletonChart />
|
||||
) : !data.length ? (
|
||||
<div className="flex items-center justify-center h-[200px] text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<TrendingUp className="h-8 w-8 mx-auto mb-2" />
|
||||
<div className="text-sm text-gray-300">No sales data available</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: 5, left: -20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-700" />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickFormatter={formatXAxis}
|
||||
className="text-[10px] text-gray-300"
|
||||
tick={{ fill: "currentColor" }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="revenue"
|
||||
tickFormatter={(value) => formatCurrency(value, false)}
|
||||
className="text-[10px] text-gray-300"
|
||||
tick={{ fill: "currentColor" }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="orders"
|
||||
orientation="right"
|
||||
tickFormatter={(value) => value.toLocaleString()}
|
||||
className="text-[10px] text-gray-300"
|
||||
tick={{ fill: "currentColor" }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
yAxisId="revenue"
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="orders"
|
||||
type="monotone"
|
||||
dataKey="orders"
|
||||
name="Orders"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniSalesChart;
|
||||
@@ -109,16 +109,15 @@ const calculatePreviousPeriodDates = (timeRange, startDate, endDate) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Enhanced helper function for consistent currency formatting with explicit rounding
|
||||
const formatCurrency = (value, useFractionDigits = true) => {
|
||||
if (typeof value !== "number") return "$0.00";
|
||||
const roundedValue = parseFloat(value.toFixed(useFractionDigits ? 2 : 0));
|
||||
// Move formatCurrency to top and export it
|
||||
export const formatCurrency = (value, minimumFractionDigits = 0) => {
|
||||
if (!value || isNaN(value)) return "$0";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: useFractionDigits ? 2 : 0,
|
||||
maximumFractionDigits: useFractionDigits ? 2 : 0,
|
||||
}).format(roundedValue);
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits: minimumFractionDigits,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// Add a helper function for percentage formatting
|
||||
@@ -139,7 +138,7 @@ const METRIC_COLORS = {
|
||||
};
|
||||
|
||||
// Memoize the StatCard component
|
||||
const StatCard = memo(
|
||||
export const StatCard = memo(
|
||||
({
|
||||
title,
|
||||
value,
|
||||
@@ -189,99 +188,34 @@ const StatCard = memo(
|
||||
)
|
||||
);
|
||||
|
||||
// Add display name for debugging
|
||||
StatCard.displayName = "StatCard";
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
// Export CustomTooltip
|
||||
export const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const date = new Date(label);
|
||||
const formattedDate = date.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
// Group metrics by type (current vs previous)
|
||||
const currentMetrics = payload.filter(
|
||||
(p) => !p.dataKey.toLowerCase().includes("prev")
|
||||
);
|
||||
const previousMetrics = payload.filter((p) =>
|
||||
p.dataKey.toLowerCase().includes("prev")
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="p-3 shadow-lg bg-white dark:bg-gray-800 border-none">
|
||||
<CardContent className="p-0 space-y-2">
|
||||
<p className="font-medium text-sm border-b pb-1 mb-2">
|
||||
{formattedDate}
|
||||
</p>
|
||||
<Card className="p-2 shadow-lg bg-white dark:bg-gray-800 border-none">
|
||||
<CardContent className="p-0 space-y-1">
|
||||
<p className="font-medium text-xs">{formattedDate}</p>
|
||||
{payload.map((entry, index) => {
|
||||
const value = entry.dataKey.toLowerCase().includes('revenue') || entry.dataKey === 'avgOrderValue'
|
||||
? formatCurrency(entry.value)
|
||||
: entry.value.toLocaleString();
|
||||
|
||||
<div className="space-y-1">
|
||||
{currentMetrics.map((entry, index) => {
|
||||
const value =
|
||||
entry.dataKey.toLowerCase().includes("revenue") ||
|
||||
entry.dataKey === "avgOrderValue" ||
|
||||
entry.dataKey === "movingAverage" ||
|
||||
entry.dataKey === "aovMovingAverage"
|
||||
? formatCurrency(entry.value)
|
||||
: entry.value.toLocaleString();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
entry.stroke ||
|
||||
METRIC_COLORS[entry.dataKey.toLowerCase()],
|
||||
}}
|
||||
>
|
||||
{entry.name}:
|
||||
</span>
|
||||
<span className="font-medium ml-4">{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{previousMetrics.length > 0 && (
|
||||
<>
|
||||
<div className="border-t my-2"></div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
Previous Period
|
||||
</p>
|
||||
{previousMetrics.map((entry, index) => {
|
||||
const value =
|
||||
entry.dataKey.toLowerCase().includes("revenue") ||
|
||||
entry.dataKey.includes("avgOrderValue")
|
||||
? formatCurrency(entry.value)
|
||||
: entry.value.toLocaleString();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
entry.stroke ||
|
||||
METRIC_COLORS[entry.dataKey.toLowerCase()],
|
||||
}}
|
||||
>
|
||||
{entry.name.replace("Previous ", "")}:
|
||||
</span>
|
||||
<span className="font-medium ml-4">{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<div key={index} className="flex justify-between items-center text-xs gap-3">
|
||||
<span style={{ color: entry.stroke }}>{entry.name}:</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -332,7 +266,8 @@ const calculate7DayAverage = (data) => {
|
||||
});
|
||||
};
|
||||
|
||||
const processData = (stats = []) => {
|
||||
// Export processData
|
||||
export const processData = (stats = []) => {
|
||||
if (!Array.isArray(stats)) return [];
|
||||
|
||||
// First, convert the stats array into the base format
|
||||
|
||||
Reference in New Issue
Block a user