109 lines
3.2 KiB
TypeScript
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>
|
|
)
|
|
}
|