"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowDownRight, ArrowUpRight, Download, MessageSquareQuote, RotateCcw, ShoppingBag, UserPlus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Donut, Spark, TrendChart, useCountTo } from "./charts";
import { ACTIVITY, DATA, INCOMING, RANGES, type Activity, type Kpi, type RangeData, type RangeId } from "./data";
/* Theme-aware categorical palette (validated for light + dark and colour-vision deficiency). */
const PALETTE =
"[--chart-1:#2a78d6] [--chart-2:#eb6834] [--chart-3:#1baf7a] [--chart-4:#eda100] [--chart-5:#e87ba4] " +
"dark:[--chart-1:#3987e5] dark:[--chart-2:#d95926] dark:[--chart-3:#199e70] dark:[--chart-4:#c98500] dark:[--chart-5:#d55181]";
export type KpiDashboardSectionProps = {
title?: string;
subtitle?: string;
/** Data per range; defaults to seeded demo data. */
data?: Record<RangeId, RangeData>;
defaultRange?: RangeId;
activity?: Activity[];
/** Simulate new activity arriving every few seconds. */
live?: boolean;
currency?: string;
onExport?: (range: RangeId) => void;
className?: string;
};
function Card({ title, action, children, className }: { title: string; action?: React.ReactNode; children: React.ReactNode; className?: string }) {
return (
<section aria-label={title} className={cn("min-w-0 rounded-xl border bg-card p-4 text-card-foreground shadow-xs sm:p-5", className)}>
<div className="mb-3 flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">{title}</h3>
{action}
</div>
{children}
</section>
);
}
function KpiTile({ kpi, format, comparison, index }: { kpi: Kpi; format: (k: Kpi, v: number) => string; comparison: string; index: number }) {
const v = useCountTo(kpi.value);
const change = (kpi.value - kpi.previous) / (kpi.previous || 1);
const good = kpi.invert ? change < 0 : change > 0;
const Icon = change >= 0 ? ArrowUpRight : ArrowDownRight;
return (
<div className="min-w-0 rounded-xl border bg-card p-4 text-card-foreground shadow-xs">
<p className="text-xs font-medium text-muted-foreground">{kpi.label}</p>
<div className="mt-1.5 flex flex-wrap items-baseline gap-x-2 gap-y-1">
<p className="text-2xl font-semibold tracking-tight tabular-nums">{format(kpi, v)}</p>
<span
className={cn(
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[11px] font-medium tabular-nums",
good ? "bg-emerald-500/12 text-emerald-700 dark:text-emerald-400" : "bg-rose-500/12 text-rose-700 dark:text-rose-400",
)}
>
<Icon className="size-3" aria-hidden />
<span className="sr-only">{change >= 0 ? "up" : "down"}</span>
{Math.abs(change * 100).toFixed(1)}%
</span>
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">{comparison}</p>
<div className="mt-3">
<Spark id={`k${index}`} data={kpi.series} color={good ? "var(--chart-1)" : "var(--chart-2)"} />
</div>
</div>
);
}
const KIND = {
order: { Icon: ShoppingBag, cls: "text-[var(--chart-1)] bg-[color-mix(in_oklch,var(--chart-1)_14%,transparent)]" },
signup: { Icon: UserPlus, cls: "text-[var(--chart-3)] bg-[color-mix(in_oklch,var(--chart-3)_14%,transparent)]" },
refund: { Icon: RotateCcw, cls: "text-[var(--chart-2)] bg-[color-mix(in_oklch,var(--chart-2)_14%,transparent)]" },
review: { Icon: MessageSquareQuote, cls: "text-[var(--chart-5)] bg-[color-mix(in_oklch,var(--chart-5)_16%,transparent)]" },
} as const;
const ago = (m: number) => (m < 1 ? "just now" : m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ago`);
export function KpiDashboardSection({
title = "Overview",
subtitle = "Northwind Home · all channels",
data = DATA,
defaultRange = "30d",
activity = ACTIVITY,
live = true,
currency = "USD",
onExport,
className,
}: KpiDashboardSectionProps) {
const reduce = useReducedMotion();
const pillId = React.useId();
const [range, setRange] = React.useState<RangeId>(defaultRange);
const d = data[range];
const comparison = RANGES.find((r) => r.id === range)?.comparison ?? "";
const [feed, setFeed] = React.useState(activity);
React.useEffect(() => {
if (!live) return;
let i = 0;
const id = setInterval(() => {
const next = INCOMING[i % INCOMING.length];
i++;
setFeed((f) => [{ ...next, id: `live-${i}`, minutesAgo: 0 }, ...f.map((a) => ({ ...a, minutesAgo: a.minutesAgo + 1 }))].slice(0, 6));
}, 5000);
return () => clearInterval(id);
}, [live]);
const money = React.useMemo(() => new Intl.NumberFormat("en-US", { style: "currency", currency, maximumFractionDigits: 0 }), [currency]);
const moneyShort = React.useMemo(() => new Intl.NumberFormat("en-US", { style: "currency", currency, notation: "compact", maximumFractionDigits: 1 }), [currency]);
const num = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
const fmtKpi = (k: Kpi, v: number) => (k.format === "currency" ? money.format(v) : k.format === "percent" ? `${v.toFixed(2)}%` : Math.round(v).toLocaleString("en-US"));
const topMax = Math.max(...d.top.map((t) => t.revenue), 1);
return (
<div className={cn("w-full bg-background p-4 text-foreground sm:p-6", PALETTE, className)}>
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
<p className="mt-0.5 text-sm text-muted-foreground">{subtitle}</p>
</div>
<div className="flex items-center gap-2">
<div role="radiogroup" aria-label="Date range" className="flex rounded-lg border bg-card p-0.5 text-xs font-medium shadow-xs">
{RANGES.map((r) => (
<button
key={r.id}
type="button"
role="radio"
aria-checked={range === r.id}
onClick={() => setRange(r.id)}
className="relative rounded-md px-2.5 py-1 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{range === r.id && <motion.span layoutId={`${pillId}-range`} className="absolute inset-0 rounded-md bg-muted" transition={{ type: "spring", stiffness: 500, damping: 40 }} />}
<span className={cn("relative", range === r.id ? "text-foreground" : "text-muted-foreground")}>{r.label}</span>
</button>
))}
</div>
<button
type="button"
onClick={() => onExport?.(range)}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border bg-card px-2.5 text-xs font-medium shadow-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<Download className="size-3.5" aria-hidden />
<span className="hidden sm:inline">Export</span>
</button>
</div>
</header>
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
{d.kpis.map((k, i) => (
<KpiTile key={k.id} kpi={k} index={i} comparison={comparison} format={fmtKpi} />
))}
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
<Card title="Revenue" className="lg:col-span-2" action={<span className="text-xs text-muted-foreground">{comparison}</span>}>
<TrendChart labels={d.labels} current={d.current} previous={d.previous} format={(v) => moneyShort.format(v)} height={230} />
</Card>
<Card title="Traffic sources" className="flex flex-col">
<div className="flex flex-1 items-center">
<Donut data={d.sources} format={(v) => num.format(v)} />
</div>
</Card>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
<Card title="Top products" className="lg:col-span-2" action={<span className="text-xs text-muted-foreground">by revenue</span>}>
<ol className="divide-y">
{d.top.map((t, i) => (
<li key={t.name} className="grid grid-cols-[1.25rem_minmax(0,1fr)_auto] items-center gap-3 py-2.5 text-sm first:pt-0 last:pb-0">
<span className="text-xs text-muted-foreground tabular-nums">{i + 1}</span>
<div className="min-w-0">
<div className="flex items-baseline gap-2">
<p className="truncate font-medium">{t.name}</p>
<p className="hidden shrink-0 text-xs text-muted-foreground sm:block">{t.category}</p>
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-[var(--chart-1)]"
initial={reduce ? false : { width: 0 }}
animate={{ width: `${(t.revenue / topMax) * 100}%` }}
transition={{ duration: 0.8, delay: i * 0.06, ease: [0.22, 1, 0.36, 1] }}
/>
</div>
</div>
<div className="text-right">
<p className="font-medium tabular-nums">{money.format(t.revenue)}</p>
<p className={cn("text-[11px] tabular-nums", t.change >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-rose-700 dark:text-rose-400")}>
{t.change >= 0 ? "+" : "−"}
{Math.abs(t.change).toFixed(1)}% · {t.units.toLocaleString("en-US")} sold
</p>
</div>
</li>
))}
</ol>
</Card>
<Card
title="Recent activity"
action={
live ? (
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
<span className="relative flex size-2">
{!reduce && <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />}
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
</span>
Live
</span>
) : undefined
}
>
<ul className="space-y-3" aria-live="polite" aria-relevant="additions">
<AnimatePresence initial={false} mode="popLayout">
{feed.slice(0, 5).map((a) => {
const k = KIND[a.kind];
return (
<motion.li
key={a.id}
layout={!reduce}
initial={reduce ? false : { opacity: 0, y: -12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 380, damping: 32 }}
className="flex items-start gap-3"
>
<span className={cn("grid size-7 shrink-0 place-items-center rounded-full", k.cls)}>
<k.Icon className="size-3.5" aria-hidden />
</span>
<div className="min-w-0 flex-1 text-xs leading-relaxed">
<p className="text-foreground">
<span className="font-medium">{a.who}</span> <span className="text-muted-foreground">{a.action}</span> <span className="font-medium">{a.target}</span>
</p>
<p className="text-[11px] text-muted-foreground">{ago(a.minutesAgo)}</p>
</div>
</motion.li>
);
})}
</AnimatePresence>
</ul>
</Card>
</div>
</div>
);
}