Files
2026-07-08 11:08:24 +05:30

109 lines
3.2 KiB
TypeScript

import { type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function formatValue(value: string | number) {
if (typeof value !== "number") return value
const formatted = Intl.NumberFormat(undefined, {
notation: "compact",
maximumFractionDigits: 1,
}).format(value)
// Normalize compact suffix casing to a consistent form to avoid
// hydration mismatches between server and client environments.
return formatted.replace(/(k|K|m|M|b|B)$/u, (s) => s.toUpperCase())
}
function Sparkline({ points }: { points: number[] }) {
const width = 48
const height = 18
const min = Math.min(...points)
const max = Math.max(...points)
const range = max - min || 1
const coords = points.map((point, index) => {
const x = (index / (points.length - 1)) * width
const y = height - ((point - min) / range) * height
return [x, y] as const
})
const path = coords.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x},${y}`).join(" ")
const [lastX, lastY] = coords[coords.length - 1]
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-4.5 w-12 shrink-0 overflow-visible"
aria-hidden="true"
>
<path d={path} fill="none" stroke="#cbd5e1" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<circle cx={lastX} cy={lastY} r={2.5} className="fill-indigo-600" stroke="white" strokeWidth={1.5} />
</svg>
)
}
export interface StatCardProps {
label: string
value: string | number
icon?: LucideIcon
delta?: number
deltaLabel?: string
/** Set true for metrics where an increase is bad (e.g. churn, costs). */
invert?: boolean
trend?: number[]
className?: string
}
export function StatCard({
label,
value,
icon: Icon,
delta,
deltaLabel,
invert = false,
trend,
className,
}: StatCardProps) {
const hasDelta = typeof delta === "number" && delta !== 0
const isPositive = hasDelta && delta > 0
const isGood = hasDelta && (invert ? !isPositive : isPositive)
return (
<div
className={cn(
"flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-black/5 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-indigo-200",
className
)}
>
<div className="flex items-start justify-between gap-2">
<p className="text-base font-medium text-muted-foreground">{label}</p>
{Icon && (
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50">
<Icon className="size-5 text-indigo-600" />
</div>
)}
</div>
<div className="flex items-end justify-between gap-2">
<p className="text-xl font-bold tracking-tight text-slate-900">{formatValue(value)}</p>
{trend && trend.length > 1 && <Sparkline points={trend} />}
</div>
{hasDelta && (
<div className="flex items-center gap-1 text-sm">
<span
className={cn(
"font-semibold",
isGood ? "text-[#0ca30c]" : "text-[#d03b3b]"
)}
>
{isPositive ? "+" : "-"}
{Math.abs(delta).toFixed(1)}%
</span>
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
</div>
)}
</div>
)
}