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