"use client"; import { ArrowDownRight, ArrowUpRight } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useMemo } from "react"; import { Sparkline } from "@/components/motion/chart/sparkline"; import { NumberTicker } from "@/components/motion/number-ticker"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type StatFormat = "number" | "currency" | "percent" | "compact"; export type StatTone = "positive" | "negative" | "neutral"; export interface StatCardProps { label: ReactNode; /** Headline value. For `format="percent"` pass a fraction (0.034 = 3.4%). */ value: number; /** Preset formatting. Default "number". */ format?: StatFormat; /** ISO 4217 code for `format="currency"`. Default "USD". */ currency?: string; /** Formatting locale. Default "en-US" so server and client agree. */ locale?: string; /** Extra Intl options merged over the preset (e.g. fraction digits). */ formatOptions?: Intl.NumberFormatOptions; /** Relative change as a fraction (0.12 = +12%). Omit to hide the badge. */ delta?: number; /** Which direction is good news. Default "up"; use "down" for churn, latency… */ goodDirection?: "up" | "down"; /** Force the badge tone instead of deriving it from delta and goodDirection. */ deltaTone?: StatTone; /** Trend values for the inline sparkline. */ sparkline?: number[]; /** Sparkline color (any CSS color). Default follows the delta tone. */ sparklineColor?: string; /** Small caption under the value, e.g. "vs previous 30 days". */ caption?: ReactNode; /** Optional icon beside the label. */ icon?: ReactNode; /** Render a skeleton instead of the value. */ loading?: boolean; className?: string; } const TONE_CLASS: Record = { positive: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", negative: "bg-destructive/10 text-destructive", neutral: "bg-muted text-muted-foreground", }; const TONE_COLOR: Record = { positive: "var(--chart-3, #1baf7a)", negative: "var(--destructive, #e5484d)", neutral: "var(--chart-1, #2a78d6)", }; function presetOptions( format: StatFormat, currency: string, ): Intl.NumberFormatOptions { switch (format) { case "currency": return { style: "currency", currency, maximumFractionDigits: 0 }; case "percent": return { style: "percent", maximumFractionDigits: 1 }; case "compact": return { notation: "compact", maximumFractionDigits: 1 }; default: return { maximumFractionDigits: 0 }; } } /** * KPI tile: label, a headline number whose digits roll to each new value, a * delta badge toned by whether the move is good news, an optional sparkline * that redraws when its data changes, and a caption. `loading` swaps in a * skeleton of the same footprint so the grid never jumps. */ export function StatCard({ label, value, format = "number", currency = "USD", locale = "en-US", formatOptions, delta, goodDirection = "up", deltaTone, sparkline, sparklineColor, caption, icon, loading = false, className, }: StatCardProps) { const reduce = useReducedMotion(); const formatter = useMemo( () => new Intl.NumberFormat(locale, { ...presetOptions(format, currency), ...formatOptions, }), [locale, format, currency, formatOptions], ); const deltaFormatter = useMemo( () => new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1, signDisplay: "exceptZero", }), [locale], ); // NumberTicker rolls integers, so feed it the value scaled by the shown // precision and divide back in the formatter — decimals survive the roll. const fractionDigits = formatter.resolvedOptions().maximumFractionDigits ?? 0; const scale = 10 ** (fractionDigits + (format === "percent" ? 2 : 0)); const tickerValue = Math.round(value * scale); const formatTicker = (n: number) => formatter.format(n / scale); const tone: StatTone = deltaTone ?? (delta === undefined || delta === 0 ? "neutral" : (delta > 0) === (goodDirection === "up") ? "positive" : "negative"); const DeltaIcon = (delta ?? 0) < 0 ? ArrowDownRight : ArrowUpRight; const deltaText = delta === undefined ? "" : deltaFormatter.format(delta); const sparkLabel = typeof label === "string" ? label : "Trend"; return (
{icon ? {icon} : null} {label}
{loading ? (
{sparkline ? : null}
) : ( <>
{delta !== undefined ? ( {tone === "positive" ? "Improved " : tone === "negative" ? "Worsened " : "Changed "} {deltaText} ) : null}
{caption ? (

{caption}

) : null} {sparkline && sparkline.length > 1 ? ( formatter.format(v)} className="mt-3" /> ) : null} )}
); } export interface StatGridProps { children: ReactNode; className?: string; } /** * Container-query grid for StatCards: one column when narrow, two from * 28rem, four from 48rem of its own width. */ export function StatGrid({ children, className }: StatGridProps) { return (
{children}
); }