Fazekit

Code

"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>
  );
}

More in Dashboards

View all →