Remove scrollbars and last updated
This commit is contained in:
@@ -63,7 +63,7 @@ const PinProtectedLayout = ({ children }) => {
|
|||||||
// Small Layout
|
// Small Layout
|
||||||
const SmallLayout = () => {
|
const SmallLayout = () => {
|
||||||
const DATETIME_SCALE = 2;
|
const DATETIME_SCALE = 2;
|
||||||
const STATS_SCALE = 1.6;
|
const STATS_SCALE = 1.65;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-screen">
|
<div className="min-h-screen w-screen">
|
||||||
@@ -80,7 +80,7 @@ const SmallLayout = () => {
|
|||||||
width: `${100/DATETIME_SCALE}%`,
|
width: `${100/DATETIME_SCALE}%`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DateTimeWeatherDisplay />
|
<DateTimeWeatherDisplay scaleFactor={DATETIME_SCALE} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipProvider,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -147,7 +152,7 @@ const MiniStatCards = ({
|
|||||||
endDate,
|
endDate,
|
||||||
title = "Quick Stats",
|
title = "Quick Stats",
|
||||||
description = "",
|
description = "",
|
||||||
compact = false
|
compact = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -176,8 +181,10 @@ const MiniStatCards = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const calculateRevenueTrend = useCallback(() => {
|
const calculateRevenueTrend = useCallback(() => {
|
||||||
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null;
|
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0)
|
||||||
const currentRevenue = stats.periodProgress < 100 ? stats.projectedRevenue : stats.revenue;
|
return null;
|
||||||
|
const currentRevenue =
|
||||||
|
stats.periodProgress < 100 ? stats.projectedRevenue : stats.revenue;
|
||||||
const prevRevenue = stats.prevPeriodRevenue;
|
const prevRevenue = stats.prevPeriodRevenue;
|
||||||
|
|
||||||
if (!currentRevenue || !prevRevenue) return null;
|
if (!currentRevenue || !prevRevenue) return null;
|
||||||
@@ -213,8 +220,11 @@ const MiniStatCards = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStats(null);
|
setStats(null);
|
||||||
|
|
||||||
const params = timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
const params =
|
||||||
const response = await axios.get("/api/klaviyo/events/stats", { params });
|
timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
||||||
|
const response = await axios.get("/api/klaviyo/events/stats", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
|
|
||||||
@@ -248,8 +258,11 @@ const MiniStatCards = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setProjectionLoading(true);
|
setProjectionLoading(true);
|
||||||
const params = timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
const params =
|
||||||
const response = await axios.get("/api/klaviyo/events/projection", { params });
|
timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
||||||
|
const response = await axios.get("/api/klaviyo/events/projection", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
setProjection(response.data);
|
setProjection(response.data);
|
||||||
@@ -275,8 +288,12 @@ const MiniStatCards = ({
|
|||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const [statsResponse, projectionResponse] = await Promise.all([
|
const [statsResponse, projectionResponse] = await Promise.all([
|
||||||
axios.get("/api/klaviyo/events/stats", { params: { timeRange: "today" } }),
|
axios.get("/api/klaviyo/events/stats", {
|
||||||
axios.get("/api/klaviyo/events/projection", { params: { timeRange: "today" } }),
|
params: { timeRange: "today" },
|
||||||
|
}),
|
||||||
|
axios.get("/api/klaviyo/events/projection", {
|
||||||
|
params: { timeRange: "today" },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setStats(statsResponse.data.stats);
|
setStats(statsResponse.data.stats);
|
||||||
@@ -291,26 +308,29 @@ const MiniStatCards = ({
|
|||||||
}, [timeRange]);
|
}, [timeRange]);
|
||||||
|
|
||||||
// Add function to fetch detail data
|
// Add function to fetch detail data
|
||||||
const fetchDetailData = useCallback(async (metric) => {
|
const fetchDetailData = useCallback(
|
||||||
if (detailData[metric]) return;
|
async (metric) => {
|
||||||
|
if (detailData[metric]) return;
|
||||||
|
|
||||||
setDetailDataLoading((prev) => ({ ...prev, [metric]: true }));
|
setDetailDataLoading((prev) => ({ ...prev, [metric]: true }));
|
||||||
try {
|
try {
|
||||||
const response = await axios.get("/api/klaviyo/events/stats/details", {
|
const response = await axios.get("/api/klaviyo/events/stats/details", {
|
||||||
params: {
|
params: {
|
||||||
timeRange: "last30days",
|
timeRange: "last30days",
|
||||||
metric,
|
metric,
|
||||||
daily: true,
|
daily: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setDetailData((prev) => ({ ...prev, [metric]: response.data.stats }));
|
setDetailData((prev) => ({ ...prev, [metric]: response.data.stats }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching detail data for ${metric}:`, error);
|
console.error(`Error fetching detail data for ${metric}:`, error);
|
||||||
} finally {
|
} finally {
|
||||||
setDetailDataLoading((prev) => ({ ...prev, [metric]: false }));
|
setDetailDataLoading((prev) => ({ ...prev, [metric]: false }));
|
||||||
}
|
}
|
||||||
}, [detailData]);
|
},
|
||||||
|
[detailData]
|
||||||
|
);
|
||||||
|
|
||||||
// Add effect to load detail data when metric is selected
|
// Add effect to load detail data when metric is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -323,7 +343,7 @@ const MiniStatCards = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Preload all detail data when component mounts
|
// Preload all detail data when component mounts
|
||||||
const metrics = ["revenue", "orders", "average_order", "shipping"];
|
const metrics = ["revenue", "orders", "average_order", "shipping"];
|
||||||
metrics.forEach(metric => {
|
metrics.forEach((metric) => {
|
||||||
fetchDetailData(metric);
|
fetchDetailData(metric);
|
||||||
});
|
});
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -331,7 +351,6 @@ const MiniStatCards = ({
|
|||||||
if (loading && !stats) {
|
if (loading && !stats) {
|
||||||
return (
|
return (
|
||||||
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||||
|
|
||||||
<CardContent className="p-4 pt-0">
|
<CardContent className="p-4 pt-0">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
@@ -360,155 +379,147 @@ const MiniStatCards = ({
|
|||||||
const aovTrend = calculateAOVTrend();
|
const aovTrend = calculateAOVTrend();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
<div>
|
<StatCard
|
||||||
|
title="Total Revenue"
|
||||||
{lastUpdate && !loading && (
|
value={formatCurrency(stats?.revenue || 0)}
|
||||||
<CardDescription className="text-xs">
|
description={
|
||||||
Last updated {lastUpdate.toFormat("h:mm a")}
|
stats?.periodProgress < 100 ? (
|
||||||
{projection?.confidence > 0 && !projectionLoading && (
|
<div className="flex items-center gap-1 text-sm">
|
||||||
<TooltipProvider>
|
<span>Proj: </span>
|
||||||
<Tooltip delayDuration={300}>
|
{projectionLoading ? (
|
||||||
<TooltipTrigger asChild>
|
<Skeleton className="h-4 w-15" />
|
||||||
<span className="ml-1 text-muted-foreground">
|
) : (
|
||||||
({Math.round(projection.confidence * 100)}%)
|
formatCurrency(
|
||||||
</span>
|
projection?.projectedRevenue || stats.projectedRevenue
|
||||||
</TooltipTrigger>
|
)
|
||||||
<TooltipContent className="max-w-[250px]">
|
|
||||||
<p>Confidence level of revenue projection</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</div>
|
||||||
)}
|
) : null
|
||||||
</div>
|
}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<StatCard
|
||||||
<StatCard
|
title="Shipped"
|
||||||
title="Total Revenue"
|
value={stats?.shipping?.shippedCount || 0}
|
||||||
value={formatCurrency(stats?.revenue || 0)}
|
description={`${stats?.shipping?.locations?.total || 0} locations`}
|
||||||
description={
|
colorClass="text-teal-600 dark:text-teal-400"
|
||||||
stats?.periodProgress < 100 ? (
|
icon={Package}
|
||||||
<div className="flex items-center gap-1 text-sm">
|
iconColor="text-teal-500"
|
||||||
<span>Proj: </span>
|
onDetailsClick={() => setSelectedMetric("shipping")}
|
||||||
{projectionLoading ? (
|
isLoading={loading || !stats}
|
||||||
<Skeleton className="h-4 w-15" />
|
/>
|
||||||
) : (
|
</div>
|
||||||
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
|
<Dialog
|
||||||
title="Orders"
|
open={!!selectedMetric}
|
||||||
value={stats?.orderCount}
|
onOpenChange={() => setSelectedMetric(null)}
|
||||||
description={`${stats?.itemCount} items`}
|
>
|
||||||
trend={orderTrend?.trend}
|
<DialogContent className="w-[80vw] h-[80vh] max-w-none p-0">
|
||||||
trendValue={orderTrend?.value ? formatPercentage(orderTrend.value) : null}
|
<div className="transform scale-[2] origin-top-left h-[40vh] w-[40vw]">
|
||||||
colorClass="text-blue-600 dark:text-blue-400"
|
<div className="h-full w-full p-6">
|
||||||
icon={ShoppingCart}
|
<DialogHeader>
|
||||||
iconColor="text-blue-500"
|
<DialogTitle>
|
||||||
onDetailsClick={() => setSelectedMetric("orders")}
|
{selectedMetric
|
||||||
isLoading={loading || !stats}
|
? `${selectedMetric
|
||||||
/>
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
<StatCard
|
.join(" ")} Details`
|
||||||
title="AOV"
|
: ""}
|
||||||
value={stats?.averageOrderValue?.toFixed(2)}
|
</DialogTitle>
|
||||||
valuePrefix="$"
|
</DialogHeader>
|
||||||
description={`${stats?.averageItemsPerOrder?.toFixed(1)}/order`}
|
<div className="mt-4 h-[calc(40vh-4rem)] overflow-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
|
||||||
trend={aovTrend?.trend}
|
{detailDataLoading[selectedMetric] ? (
|
||||||
trendValue={aovTrend?.value ? formatPercentage(aovTrend.value) : null}
|
<div className="space-y-4 h-full">
|
||||||
colorClass="text-purple-600 dark:text-purple-400"
|
{selectedMetric === "shipping" ? (
|
||||||
icon={CircleDollarSign}
|
<MiniSkeletonTable rows={8} />
|
||||||
iconColor="text-purple-500"
|
) : (
|
||||||
onDetailsClick={() => setSelectedMetric("average_order")}
|
<>
|
||||||
isLoading={loading || !stats}
|
<MiniSkeletonChart
|
||||||
/>
|
type={selectedMetric === "orders" ? "bar" : "line"}
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<Dialog open={!!selectedMetric} onOpenChange={() => setSelectedMetric(null)}>
|
|
||||||
<DialogContent className="w-[80vw] h-[80vh] max-w-none p-0">
|
|
||||||
<div className="transform scale-[2] origin-top-left h-[40vh] w-[40vw]">
|
|
||||||
<div className="h-full w-full p-6">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{selectedMetric
|
|
||||||
? `${selectedMetric
|
|
||||||
.split("_")
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
||||||
.join(" ")} Details`
|
|
||||||
: ""}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="mt-4 h-[calc(40vh-4rem)] overflow-auto">
|
|
||||||
{detailDataLoading[selectedMetric] ? (
|
|
||||||
<div className="space-y-4 h-full">
|
|
||||||
{selectedMetric === "shipping" ? (
|
|
||||||
<MiniSkeletonTable rows={8} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MiniSkeletonChart type={selectedMetric === "orders" ? "bar" : "line"} />
|
|
||||||
{selectedMetric === "orders" && (
|
|
||||||
<div className="mt-8">
|
|
||||||
<h3 className="text-lg font-medium mb-4">Hourly Distribution</h3>
|
|
||||||
<MiniSkeletonChart type="bar" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-full">
|
|
||||||
{selectedMetric === "revenue" && (
|
|
||||||
<RevenueDetails data={detailData.revenue || []} />
|
|
||||||
)}
|
|
||||||
{selectedMetric === "orders" && (
|
|
||||||
<OrdersDetails data={detailData.orders || []} />
|
|
||||||
)}
|
|
||||||
{selectedMetric === "average_order" && (
|
|
||||||
<AverageOrderDetails
|
|
||||||
data={detailData.average_order || []}
|
|
||||||
orderCount={stats.orderCount}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{selectedMetric === "orders" && (
|
||||||
{selectedMetric === "shipping" && (
|
<div className="mt-8">
|
||||||
<ShippingDetails data={[stats]} timeRange={timeRange} />
|
<h3 className="text-lg font-medium mb-4">
|
||||||
)}
|
Hourly Distribution
|
||||||
</div>
|
</h3>
|
||||||
)}
|
<MiniSkeletonChart type="bar" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full">
|
||||||
|
{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} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
</DialogContent>
|
||||||
</>
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user