2026-07-28 10:05:08 +07:00
|
|
|
"use client";
|
|
|
|
|
|
2026-08-01 09:19:39 +07:00
|
|
|
import type { LucideIcon } from "lucide-react";
|
|
|
|
|
import { Area, AreaChart, ResponsiveContainer } from "recharts";
|
2026-07-28 10:05:08 +07:00
|
|
|
import { GlassCard } from "@/components/glass/card";
|
2026-08-01 10:21:23 +07:00
|
|
|
import { useMounted } from "@/lib/hooks/use-mounted";
|
2026-07-28 10:05:08 +07:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
interface StatCardProps {
|
|
|
|
|
label: string;
|
|
|
|
|
value: number | string;
|
|
|
|
|
icon: LucideIcon;
|
|
|
|
|
variant?: "default" | "danger" | "success";
|
|
|
|
|
sparklineData?: { value: number }[];
|
|
|
|
|
formatter?: (v: number) => string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function StatCard({
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
icon: Icon,
|
|
|
|
|
variant = "default",
|
|
|
|
|
sparklineData,
|
|
|
|
|
formatter = (v) => (typeof v === "number" ? v.toLocaleString() : v),
|
|
|
|
|
}: StatCardProps) {
|
2026-08-01 10:21:23 +07:00
|
|
|
const mounted = useMounted();
|
2026-07-28 10:05:08 +07:00
|
|
|
const accentColor = {
|
|
|
|
|
default: "var(--color-primary)",
|
|
|
|
|
danger: "var(--color-destructive)",
|
|
|
|
|
success: "oklch(0.6 0.18 160)",
|
|
|
|
|
}[variant];
|
|
|
|
|
|
|
|
|
|
const bgAccent = {
|
|
|
|
|
default: "bg-primary/10 text-primary",
|
|
|
|
|
danger: "bg-destructive/10 text-destructive",
|
|
|
|
|
success: "bg-emerald-500/10 text-emerald-500",
|
|
|
|
|
}[variant];
|
|
|
|
|
|
|
|
|
|
const numValue = typeof value === "number" ? value : Number(value);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<GlassCard variant="base" className="relative overflow-hidden p-4">
|
|
|
|
|
<div className="flex items-start justify-between mb-2">
|
|
|
|
|
<div className={cn("p-1.5 rounded-md", bgAccent)}>
|
|
|
|
|
<Icon className="size-4" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-08-01 09:19:39 +07:00
|
|
|
<div
|
|
|
|
|
className="text-2xl font-mono font-semibold tracking-tight"
|
|
|
|
|
style={{ color: accentColor }}
|
|
|
|
|
>
|
2026-07-28 10:05:08 +07:00
|
|
|
{formatter(numValue)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-[11px] text-text-secondary font-medium mt-0.5 tracking-wide uppercase">
|
|
|
|
|
{label}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Sparkline background */}
|
2026-08-01 10:21:23 +07:00
|
|
|
{sparklineData && sparklineData.length > 0 && mounted && (
|
2026-07-28 10:05:08 +07:00
|
|
|
<div className="absolute bottom-0 left-0 right-0 h-12 opacity-20">
|
2026-08-01 10:21:23 +07:00
|
|
|
<ResponsiveContainer
|
|
|
|
|
width="100%"
|
|
|
|
|
height={48}
|
|
|
|
|
minWidth={0}
|
|
|
|
|
minHeight={0}
|
|
|
|
|
>
|
2026-07-28 10:05:08 +07:00
|
|
|
<AreaChart data={sparklineData}>
|
|
|
|
|
<defs>
|
2026-08-01 09:19:39 +07:00
|
|
|
<linearGradient
|
|
|
|
|
id={`spark-grad-${label}`}
|
|
|
|
|
x1="0"
|
|
|
|
|
y1="0"
|
|
|
|
|
x2="0"
|
|
|
|
|
y2="1"
|
|
|
|
|
>
|
2026-07-28 10:05:08 +07:00
|
|
|
<stop offset="0%" stopColor={accentColor} stopOpacity={0.5} />
|
|
|
|
|
<stop offset="100%" stopColor={accentColor} stopOpacity={0} />
|
|
|
|
|
</linearGradient>
|
|
|
|
|
</defs>
|
|
|
|
|
<Area
|
|
|
|
|
type="monotone"
|
|
|
|
|
dataKey="value"
|
|
|
|
|
stroke={accentColor}
|
|
|
|
|
strokeWidth={1.5}
|
|
|
|
|
fill={`url(#spark-grad-${label})`}
|
|
|
|
|
dot={false}
|
|
|
|
|
isAnimationActive={false}
|
|
|
|
|
/>
|
|
|
|
|
</AreaChart>
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</GlassCard>
|
|
|
|
|
);
|
|
|
|
|
}
|