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 TypeformDashboard from "@/components/dashboard/TypeformDashboard";
|
||||||
import MiniStatCards from "@/components/dashboard/MiniStatCards";
|
import MiniStatCards from "@/components/dashboard/MiniStatCards";
|
||||||
import MiniRealtimeAnalytics from "@/components/dashboard/MiniRealtimeAnalytics";
|
import MiniRealtimeAnalytics from "@/components/dashboard/MiniRealtimeAnalytics";
|
||||||
|
import MiniSalesChart from "@/components/dashboard/MiniSalesChart";
|
||||||
|
|
||||||
// Public layout
|
// Public layout
|
||||||
const PublicLayout = () => (
|
const PublicLayout = () => (
|
||||||
@@ -66,6 +67,7 @@ const SmallLayout = () => {
|
|||||||
const DATETIME_SCALE = 2;
|
const DATETIME_SCALE = 2;
|
||||||
const STATS_SCALE = 1.65;
|
const STATS_SCALE = 1.65;
|
||||||
const ANALYTICS_SCALE = 1.65;
|
const ANALYTICS_SCALE = 1.65;
|
||||||
|
const SALES_SCALE = 1.65;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-screen">
|
<div className="min-h-screen w-screen">
|
||||||
@@ -81,12 +83,14 @@ const SmallLayout = () => {
|
|||||||
transformOrigin: 'top left',
|
transformOrigin: 'top left',
|
||||||
width: `${100/DATETIME_SCALE}%`
|
width: `${100/DATETIME_SCALE}%`
|
||||||
}}>
|
}}>
|
||||||
<DateTimeWeatherDisplay />
|
<DateTimeWeatherDisplay scaleFactor={DATETIME_SCALE} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats and Analytics */}
|
{/* Stats and Analytics */}
|
||||||
<div className="col-span-9">
|
<div className="col-span-9">
|
||||||
|
<div className="">
|
||||||
|
{/* Mini Stat Cards */}
|
||||||
<div>
|
<div>
|
||||||
<div style={{
|
<div style={{
|
||||||
transform: `scale(${STATS_SCALE})`,
|
transform: `scale(${STATS_SCALE})`,
|
||||||
@@ -99,11 +103,27 @@ const SmallLayout = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-1/2 ml-auto mt-28 pl-1">
|
|
||||||
|
{/* 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={{
|
<div style={{
|
||||||
transform: `scale(${ANALYTICS_SCALE})`,
|
transform: `scale(${ANALYTICS_SCALE})`,
|
||||||
transformOrigin: 'top left',
|
transformOrigin: 'top left',
|
||||||
width: `${100/ANALYTICS_SCALE}%`
|
width: `${100/ANALYTICS_SCALE}%`,
|
||||||
|
height: `${100/ANALYTICS_SCALE}%`
|
||||||
}}>
|
}}>
|
||||||
<MiniRealtimeAnalytics />
|
<MiniRealtimeAnalytics />
|
||||||
</div>
|
</div>
|
||||||
@@ -111,6 +131,8 @@ const SmallLayout = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enhanced helper function for consistent currency formatting with explicit rounding
|
// Move formatCurrency to top and export it
|
||||||
const formatCurrency = (value, useFractionDigits = true) => {
|
export const formatCurrency = (value, minimumFractionDigits = 0) => {
|
||||||
if (typeof value !== "number") return "$0.00";
|
if (!value || isNaN(value)) return "$0";
|
||||||
const roundedValue = parseFloat(value.toFixed(useFractionDigits ? 2 : 0));
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
minimumFractionDigits: useFractionDigits ? 2 : 0,
|
minimumFractionDigits,
|
||||||
maximumFractionDigits: useFractionDigits ? 2 : 0,
|
maximumFractionDigits: minimumFractionDigits,
|
||||||
}).format(roundedValue);
|
}).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add a helper function for percentage formatting
|
// Add a helper function for percentage formatting
|
||||||
@@ -139,7 +138,7 @@ const METRIC_COLORS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Memoize the StatCard component
|
// Memoize the StatCard component
|
||||||
const StatCard = memo(
|
export const StatCard = memo(
|
||||||
({
|
({
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
@@ -189,99 +188,34 @@ const StatCard = memo(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add display name for debugging
|
|
||||||
StatCard.displayName = "StatCard";
|
StatCard.displayName = "StatCard";
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }) => {
|
// Export CustomTooltip
|
||||||
|
export const CustomTooltip = ({ active, payload, label }) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
const date = new Date(label);
|
const date = new Date(label);
|
||||||
const formattedDate = date.toLocaleDateString("en-US", {
|
const formattedDate = date.toLocaleDateString("en-US", {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
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 (
|
return (
|
||||||
<Card className="p-3 shadow-lg bg-white dark:bg-gray-800 border-none">
|
<Card className="p-2 shadow-lg bg-white dark:bg-gray-800 border-none">
|
||||||
<CardContent className="p-0 space-y-2">
|
<CardContent className="p-0 space-y-1">
|
||||||
<p className="font-medium text-sm border-b pb-1 mb-2">
|
<p className="font-medium text-xs">{formattedDate}</p>
|
||||||
{formattedDate}
|
{payload.map((entry, index) => {
|
||||||
</p>
|
const value = entry.dataKey.toLowerCase().includes('revenue') || entry.dataKey === 'avgOrderValue'
|
||||||
|
|
||||||
<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)
|
? formatCurrency(entry.value)
|
||||||
: entry.value.toLocaleString();
|
: entry.value.toLocaleString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={index} className="flex justify-between items-center text-xs gap-3">
|
||||||
key={index}
|
<span style={{ color: entry.stroke }}>{entry.name}:</span>
|
||||||
className="flex justify-between items-center text-sm"
|
<span className="font-medium">{value}</span>
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
entry.stroke ||
|
|
||||||
METRIC_COLORS[entry.dataKey.toLowerCase()],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entry.name}:
|
|
||||||
</span>
|
|
||||||
<span className="font-medium ml-4">{value}</span>
|
|
||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -332,7 +266,8 @@ const calculate7DayAverage = (data) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const processData = (stats = []) => {
|
// Export processData
|
||||||
|
export const processData = (stats = []) => {
|
||||||
if (!Array.isArray(stats)) return [];
|
if (!Array.isArray(stats)) return [];
|
||||||
|
|
||||||
// First, convert the stats array into the base format
|
// First, convert the stats array into the base format
|
||||||
|
|||||||
Reference in New Issue
Block a user