Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { Bell, Download, LayoutDashboard, Menu, Package, PanelLeftClose, PanelLeftOpen, Receipt, Settings, Users, X, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { computeMetrics, defaultAnalyticsData, downloadCsv, makeFormatters, ordersToCsv } from "./data";
import type { AnalyticsData, Order, RangeKey, StoreSettings, ViewKey } from "./types";
import { CustomersView, OrdersView, OverviewView, ProductsView, SettingsView } from "./views";

export type { AnalyticsData, Order, RangeKey, StoreSettings, ViewKey } from "./types";
export { generateAnalyticsData } from "./data";

const NAV: { key: ViewKey; label: string; icon: LucideIcon }[] = [
  { key: "overview", label: "Overview", icon: LayoutDashboard },
  { key: "orders", label: "Orders", icon: Receipt },
  { key: "products", label: "Products", icon: Package },
  { key: "customers", label: "Customers", icon: Users },
  { key: "settings", label: "Settings", icon: Settings },
];

const RANGES: { key: RangeKey; label: string }[] = [
  { key: "7d", label: "7D" },
  { key: "30d", label: "30D" },
  { key: "90d", label: "90D" },
];

const DEFAULT_SETTINGS: StoreSettings = { storeName: "Northwind", currency: "USD", dailySummary: true, lowStockAlerts: true, weeklyReport: false };

export interface AnalyticsDashboardAppProps {
  /** Orders, products, customers and traffic. Defaults to a seeded, deterministic demo store. */
  data?: AnalyticsData;
  defaultView?: ViewKey;
  defaultRange?: RangeKey;
  settings?: Partial<StoreSettings>;
  user?: { name: string; role: string };
  onViewChange?: (view: ViewKey) => void;
  onRangeChange?: (range: RangeKey) => void;
  /** Called with the exported rows and CSV text (the file download still happens). */
  onExport?: (rows: Order[], csv: string) => void;
  onSettingsChange?: (settings: StoreSettings) => void;
  className?: string;
}

function BrandMark() {
  return (
    <svg viewBox="0 0 32 32" className="size-8 shrink-0" aria-hidden>
      <rect width="32" height="32" rx="9" className="fill-primary" />
      <path d="M9 22V10l7 8 7-8v12" fill="none" className="stroke-primary-foreground" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

export function AnalyticsDashboardApp({
  data = defaultAnalyticsData,
  defaultView = "overview",
  defaultRange = "30d",
  settings: settingsProp,
  user = { name: "Jordan Diaz", role: "Owner" },
  onViewChange,
  onRangeChange,
  onExport,
  onSettingsChange,
  className,
}: AnalyticsDashboardAppProps) {
  const [view, setView] = React.useState<ViewKey>(defaultView);
  const [range, setRange] = React.useState<RangeKey>(defaultRange);
  const [collapsed, setCollapsed] = React.useState(false);
  const [mobileNav, setMobileNav] = React.useState(false);
  const [settings, setSettings] = React.useState<StoreSettings>({ ...DEFAULT_SETTINGS, ...settingsProp });
  const reduce = useReducedMotion();
  const rangeGroup = React.useId();
  const mainRef = React.useRef<HTMLElement>(null);

  const metrics = React.useMemo(() => computeMetrics(data, range), [data, range]);
  const fmt = React.useMemo(() => makeFormatters(settings.currency), [settings.currency]);
  const pending = metrics.orders.filter((o) => o.status === "pending").length;

  const go = (v: ViewKey) => {
    setView(v);
    setMobileNav(false);
    mainRef.current?.scrollTo({ top: 0 });
    onViewChange?.(v);
  };
  const pickRange = (r: RangeKey) => {
    setRange(r);
    onRangeChange?.(r);
  };
  const exportRows = (rows: Order[]) => {
    const csv = ordersToCsv(rows);
    downloadCsv(csv, `${settings.storeName.toLowerCase().replace(/\s+/g, "-") || "store"}-orders-${range}.csv`);
    onExport?.(rows, csv);
  };

  React.useEffect(() => {
    if (!mobileNav) return;
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMobileNav(false);
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [mobileNav]);

  const title = NAV.find((n) => n.key === view)?.label ?? "";

  const navList = (compact: boolean, groupId: string) => (
    <nav aria-label="Main" className="flex flex-col gap-0.5">
      <LayoutGroup id={groupId}>
        {NAV.map((n) => {
          const active = n.key === view;
          return (
            <button
              key={n.key}
              type="button"
              onClick={() => go(n.key)}
              aria-current={active ? "page" : undefined}
              title={compact ? n.label : undefined}
              aria-label={compact ? n.label : undefined}
              className={cn(
                "relative flex h-9 items-center gap-3 rounded-lg px-2.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                active ? "text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
              )}
            >
              {active && <motion.span layoutId="nav-active" className="absolute inset-0 rounded-lg bg-accent shadow-xs ring-1 ring-border" transition={{ type: "spring", stiffness: 420, damping: 34 }} />}
              <n.icon className={cn("relative size-4 shrink-0", active && "text-primary")} aria-hidden />
              {!compact && <span className="relative truncate">{n.label}</span>}
              {n.key === "orders" && pending > 0 && (
                <span className={cn("relative rounded-full bg-primary px-1.5 text-[10px] font-semibold leading-4 text-primary-foreground tabular-nums", compact ? "absolute right-1 top-1 px-1" : "ml-auto")}>{pending}</span>
              )}
            </button>
          );
        })}
      </LayoutGroup>
    </nav>
  );

  const profile = (compact: boolean) => (
    <div className={cn("flex items-center gap-2.5 rounded-lg p-1.5", !compact && "border bg-background/60")}>
      <span className="grid size-8 shrink-0 place-items-center rounded-full bg-gradient-to-br from-primary to-primary/60 text-xs font-semibold text-primary-foreground" aria-hidden>
        {user.name
          .split(" ")
          .map((w) => w[0])
          .join("")
          .slice(0, 2)}
      </span>
      {!compact && (
        <div className="min-w-0">
          <div className="truncate text-sm font-medium">{user.name}</div>
          <div className="truncate text-xs text-muted-foreground">{user.role}</div>
        </div>
      )}
    </div>
  );

  return (
    <div className={cn("relative flex h-[760px] w-full overflow-hidden bg-background text-foreground", className)}>
      {/* Desktop sidebar */}
      <motion.aside
        initial={false}
        animate={{ width: collapsed ? 68 : 232 }}
        transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 38 }}
        className="hidden shrink-0 flex-col gap-4 border-r bg-muted/30 p-3 md:flex"
      >
        <div className={cn("flex h-9 items-center gap-2.5", collapsed ? "justify-center" : "px-1")}>
          <BrandMark />
          {!collapsed && <span className="truncate text-sm font-semibold tracking-tight">{settings.storeName || "Store"}</span>}
        </div>
        {navList(collapsed, `${rangeGroup}-desk`)}
        <div className="mt-auto space-y-2">
          <button
            type="button"
            onClick={() => setCollapsed((c) => !c)}
            aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
            aria-expanded={!collapsed}
            className={cn("flex h-9 w-full items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", collapsed && "justify-center px-0")}
          >
            {collapsed ? <PanelLeftOpen className="size-4" /> : <PanelLeftClose className="size-4" />}
            {!collapsed && "Collapse"}
          </button>
          {profile(collapsed)}
        </div>
      </motion.aside>

      {/* Mobile drawer */}
      <AnimatePresence>
        {mobileNav && (
          <>
            <motion.button
              type="button"
              aria-label="Close menu"
              className="absolute inset-0 z-30 bg-black/40 backdrop-blur-[2px] md:hidden"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              onClick={() => setMobileNav(false)}
            />
            <motion.aside
              role="dialog"
              aria-modal="true"
              aria-label="Navigation"
              className="absolute inset-y-0 left-0 z-40 flex w-64 flex-col gap-4 border-r bg-background p-3 shadow-2xl md:hidden"
              initial={{ x: "-100%" }}
              animate={{ x: 0 }}
              exit={{ x: "-100%" }}
              transition={{ type: "spring", stiffness: 380, damping: 38 }}
            >
              <div className="flex h-9 items-center gap-2.5 px-1">
                <BrandMark />
                <span className="truncate text-sm font-semibold">{settings.storeName || "Store"}</span>
                <button type="button" autoFocus onClick={() => setMobileNav(false)} aria-label="Close menu" className="ml-auto grid size-8 place-items-center rounded-lg hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  <X className="size-4" />
                </button>
              </div>
              {navList(false, `${rangeGroup}-mob`)}
              <div className="mt-auto">{profile(false)}</div>
            </motion.aside>
          </>
        )}
      </AnimatePresence>

      <div className="flex min-w-0 flex-1 flex-col">
        <header className="flex h-14 shrink-0 items-center gap-2 border-b bg-background/80 px-3 backdrop-blur sm:gap-3 sm:px-5">
          <button type="button" onClick={() => setMobileNav(true)} aria-label="Open menu" className="grid size-9 place-items-center rounded-lg hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:hidden">
            <Menu className="size-4" />
          </button>
          <div className="min-w-0">
            <h1 className="truncate text-sm font-semibold sm:text-base">{title}</h1>
            <p className="hidden truncate text-xs text-muted-foreground tabular-nums sm:block">
              {fmt.day(metrics.startDate)}{fmt.day(data.endDate)}, {data.endDate.slice(0, 4)}
            </p>
          </div>
          <div className="ml-auto flex items-center gap-2">
            <div role="radiogroup" aria-label="Date range" className="flex rounded-lg border bg-muted/50 p-0.5">
              <LayoutGroup id={`${rangeGroup}-range`}>
                {RANGES.map((r) => (
                  <button
                    key={r.key}
                    type="button"
                    role="radio"
                    aria-checked={range === r.key}
                    onClick={() => pickRange(r.key)}
                    className={cn(
                      "relative h-7 rounded-md px-2.5 text-xs font-semibold tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring sm:px-3",
                      range === r.key ? "text-foreground" : "text-muted-foreground hover:text-foreground",
                    )}
                  >
                    {range === r.key && <motion.span layoutId="range-pill" className="absolute inset-0 rounded-md bg-background shadow-sm ring-1 ring-border" transition={{ type: "spring", stiffness: 500, damping: 36 }} />}
                    <span className="relative">{r.label}</span>
                  </button>
                ))}
              </LayoutGroup>
            </div>
            <button
              type="button"
              onClick={() => exportRows(metrics.orders)}
              aria-label="Export orders as CSV"
              className="inline-flex h-8 items-center gap-2 rounded-lg bg-primary px-2.5 text-xs font-semibold text-primary-foreground shadow-sm transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:px-3"
            >
              <Download className="size-3.5" aria-hidden />
              <span className="hidden sm:inline">Export</span>
            </button>
            <button type="button" aria-label="Notifications" className="relative hidden size-8 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:grid">
              <Bell className="size-4" />
              <span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-primary" />
            </button>
          </div>
        </header>

        <main ref={mainRef} className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-3 sm:p-5">
          <AnimatePresence mode="wait" initial={false}>
            <motion.div key={view} initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={reduce ? undefined : { opacity: 0, y: -4 }} transition={{ duration: 0.2 }}>
              {view === "overview" && <OverviewView metrics={metrics} fmt={fmt} range={range} onViewAll={() => go("orders")} />}
              {view === "orders" && <OrdersView metrics={metrics} fmt={fmt} onExport={exportRows} />}
              {view === "products" && <ProductsView metrics={metrics} fmt={fmt} />}
              {view === "customers" && <CustomersView metrics={metrics} fmt={fmt} />}
              {view === "settings" && (
                <SettingsView
                  settings={settings}
                  onSave={(s) => {
                    setSettings(s);
                    onSettingsChange?.(s);
                  }}
                />
              )}
            </motion.div>
          </AnimatePresence>
        </main>
      </div>
    </div>
  );
}

export default AnalyticsDashboardApp;