diff --git a/src/components/admin/CommandPalette.tsx b/src/components/admin/CommandPalette.tsx new file mode 100644 index 0000000..2a33a51 --- /dev/null +++ b/src/components/admin/CommandPalette.tsx @@ -0,0 +1,468 @@ +"use client"; + +/** + * CommandPalette — Cmd+K / Ctrl+K global quick-launcher for the admin. + * + * Behavior: + * - Toggled with Cmd+K (mac) or Ctrl+K (win/linux) on `document`. + * - Closes on Escape, on backdrop click, or after a navigation. + * - Fuzzy case-insensitive match on `label` / `category` / `keywords`. + * - Top 8 results. Renders null when closed (no DOM noise). + * - Keyboard: ↑/↓ to move, Enter to select, type to filter. + * + * Self-contained: does not read from the sidebar, does not depend on any + * server data. The static list lives in `./command-palette-data.ts`. + * + * The component is intentionally NOT mounted in the admin layout by this + * file. The main thread / sidebar agent wires it in once it's reviewed. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + BarChart3, + Clock, + Command, + CreditCard, + Droplets, + FileText, + LayoutDashboard, + LucideIcon, + Map as MapIcon, + MapPin, + Megaphone, + Package, + Plug, + PlusCircle, + Puzzle, + Receipt, + RefreshCw, + Route, + ScrollText, + Search, + Send, + Settings, + ShoppingCart, + Sparkles, + Store, + Tag, + Truck, + Upload, + UserPlus, + Users, + Wallet, +} from "lucide-react"; +import { PALETTE_ENTRIES, type PaletteEntry } from "./command-palette-data"; + +const MAX_RESULTS = 8; + +// --------------------------------------------------------------------------- +// Icon registry +// --------------------------------------------------------------------------- + +const ICON_MAP: Record = { + // Pages + LayoutDashboard, + Command, + ShoppingCart, + MapPin, + Package, + Truck, + Send, + Megaphone, + FileText, + Users, + ScrollText, + Store, + Upload, + Sparkles, + Clock, + Droplets, + Route, + BarChart3, + Receipt, + Settings, + Tag, + CreditCard, + Plug, + Wallet, + Puzzle, + // Quick actions + PlusCircle, + UserPlus, + RefreshCw, + // Misc / fallbacks + Search, + Map: MapIcon, +}; + +// --------------------------------------------------------------------------- +// Filtering / scoring +// --------------------------------------------------------------------------- + +function scoreEntry(entry: PaletteEntry, query: string): number { + if (!query) return 1; + const q = query.toLowerCase(); + const label = entry.label.toLowerCase(); + const category = entry.category.toLowerCase(); + + let score = 0; + if (label === q) score += 100; + else if (label.startsWith(q)) score += 50; + else if (label.includes(q)) score += 25; + + if (category.toLowerCase().includes(q)) score += 10; + if (entry.keywords?.some((k) => k.toLowerCase().includes(q))) score += 5; + + // Quick actions get a small boost when no specific match (so they surface + // on the empty query list, alongside the first batch of pages). + if (!query && entry.type === "action") score = 0.5; + if (!query && entry.type === "page") score = 1 - entry.label.length * 0.001; + + return score; +} + +function filterEntries(query: string): PaletteEntry[] { + const scored = PALETTE_ENTRIES + .map((e) => ({ entry: e, score: scoreEntry(e, query) })) + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, MAX_RESULTS); + return scored.map((r) => r.entry); +} + +// --------------------------------------------------------------------------- +// Highlight helper +// --------------------------------------------------------------------------- + +function HighlightedText({ text, query }: { text: string; query: string }) { + if (!query) return <>{text}; + const q = query.toLowerCase(); + const idx = text.toLowerCase().indexOf(q); + if (idx === -1) return <>{text}; + return ( + <> + {text.slice(0, idx)} + + {text.slice(idx, idx + query.length)} + + {text.slice(idx + query.length)} + + ); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function CommandPalette() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(0); + const inputRef = useRef(null); + const router = useRouter(); + + // Open/close on Cmd+K / Ctrl+K. Toggle so the same shortcut closes it. + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setOpen((o) => !o); + } + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, []); + + // Reset on open, focus the input, and lock body scroll. + useEffect(() => { + if (!open) { + document.body.style.overflow = ""; + return; + } + setQuery(""); + setSelected(0); + document.body.style.overflow = "hidden"; + // Defer focus to next frame so the input is mounted and the modal + // animation has started (keeps the focus ring from flickering in). + const raf = requestAnimationFrame(() => inputRef.current?.focus()); + return () => { + cancelAnimationFrame(raf); + document.body.style.overflow = ""; + }; + }, [open]); + + // Global Escape while open (covers the case where focus is not on input). + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + setOpen(false); + } + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open]); + + // Filtering + const results = useMemo(() => filterEntries(query), [query]); + + // Reset selection on every query change so the top result is always active. + useEffect(() => { + setSelected(0); + }, [query]); + + // Clamp selection to results length + useEffect(() => { + if (selected >= results.length && results.length > 0) { + setSelected(results.length - 1); + } else if (results.length === 0) { + setSelected(0); + } + }, [results, selected]); + + const navigate = useCallback( + (href: string) => { + setOpen(false); + router.push(href); + }, + [router] + ); + + // In-input keyboard nav. Attached to the input itself so it works whether + // the user is typing or the input just has focus. + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (results.length === 0) return; + setSelected((s) => (s + 1) % results.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (results.length === 0) return; + setSelected((s) => (s - 1 + results.length) % results.length); + } else if (e.key === "Enter") { + e.preventDefault(); + const entry = results[selected]; + if (entry) navigate(entry.href); + } + }; + + if (!open) return null; + + return ( +
setOpen(false)} + style={{ + position: "fixed", + inset: 0, + zIndex: 60, + backgroundColor: "rgba(26, 24, 20, 0.4)", + backdropFilter: "blur(4px)", + WebkitBackdropFilter: "blur(4px)", + display: "flex", + alignItems: "flex-start", + justifyContent: "center", + paddingTop: "15vh", + animation: "cp-fade-in 180ms ease-out", + }} + > + {/* Local keyframes — scoped via the dialog's runtime so they don't + leak into the global stylesheet. */} + + +
e.stopPropagation()} + style={{ + width: "100%", + maxWidth: "36rem", + margin: "0 1rem", + backgroundColor: "var(--admin-card-bg)", + border: "1px solid var(--admin-border)", + borderRadius: "var(--admin-radius-lg)", + boxShadow: "var(--admin-shadow-lg)", + overflow: "hidden", + animation: "cp-scale-in 180ms ease-out", + display: "flex", + flexDirection: "column", + }} + > + {/* Search input row */} +
+
+ + {/* Results */} +
+ {/* + TODO (next pass): recent items + - Persist a small ring buffer of last-visited entries in localStorage + under "rc-cmdk-recent" (cap at 5). + - Read on mount, render as a "Recent" section above "Pages" when + present and the query is empty. + - Update on `navigate()` after a successful route push. + Skipped for v1 per the design spec. + */} + {results.length === 0 ? ( +
+ No results for “{query}” +
+ ) : ( + results.map((entry, i) => { + const Icon = ICON_MAP[entry.iconName] ?? Search; + const isSelected = i === selected; + return ( + + ); + }) + )} +
+ + {/* Footer hint */} +
+ ↑↓ to navigate  ·  ↵ to select  ·  esc to close +
+
+
+ ); +} diff --git a/src/components/admin/command-palette-data.ts b/src/components/admin/command-palette-data.ts new file mode 100644 index 0000000..0ef54f2 --- /dev/null +++ b/src/components/admin/command-palette-data.ts @@ -0,0 +1,402 @@ +/** + * Command Palette — static entry list. + * + * Source of truth for the Cmd+K palette. The palette component imports + * `PALETTE_ENTRIES` and filters/scores this list. This file does NOT + * import from the sidebar — keep them independent so the palette + * works even if the sidebar is restructured. + * + * Entries are grouped by the new admin IA (see + * docs/superpowers/specs/2026-06-17-admin-redesign.md §4): + * Workspace · Operations · Communications · Growth · + * Tracking · Insights · Settings · Public + * + * `type` distinguishes pages (jump to a route) from quick actions + * (a route + query string that opens a creation flow / composer). + */ + +export type PaletteEntryType = "page" | "action"; + +export type PaletteEntry = { + /** Stable id, used as React key. */ + id: string; + /** "page" = nav to route, "action" = nav to a creation flow / composer. */ + type: PaletteEntryType; + /** Primary text shown in the result row. */ + label: string; + /** Route to navigate to when selected. */ + href: string; + /** Sidebar group (Pages) or "Quick action" (Actions). */ + category: string; + /** Lucide icon name (PascalCase, e.g. "ShoppingCart"). */ + iconName: string; + /** Extra search terms (lowercase, comma-separated in code). */ + keywords?: string[]; +}; + +export const PALETTE_ENTRIES: PaletteEntry[] = [ + // ── Workspace ────────────────────────────────────────────────────── + { + id: "page-dashboard", + type: "page", + label: "Dashboard", + href: "/admin", + category: "Workspace", + iconName: "LayoutDashboard", + keywords: ["home", "overview"], + }, + { + id: "page-command-center", + type: "page", + label: "Command Center", + href: "/admin/command-center", + category: "Workspace", + iconName: "Command", + keywords: ["platform", "admin", "ops", "triage"], + }, + + // ── Operations ───────────────────────────────────────────────────── + { + id: "page-orders", + type: "page", + label: "Orders", + href: "/admin/orders", + category: "Operations", + iconName: "ShoppingCart", + keywords: ["sales", "purchases", "invoices"], + }, + { + id: "page-stops", + type: "page", + label: "Stops & Routes", + href: "/admin/stops", + category: "Operations", + iconName: "MapPin", + keywords: ["schedule", "pickup locations", "calendar"], + }, + { + id: "page-products", + type: "page", + label: "Products", + href: "/admin/products", + category: "Operations", + iconName: "Package", + keywords: ["catalog", "sku", "inventory"], + }, + { + id: "page-pickup", + type: "page", + label: "Driver Pickup", + href: "/admin/pickup", + category: "Operations", + iconName: "Truck", + keywords: ["fulfillment", "driver"], + }, + { + id: "page-shipping", + type: "page", + label: "Shipping", + href: "/admin/shipping", + category: "Operations", + iconName: "Send", + keywords: ["shipments", "labels", "fedex"], + }, + + // ── Communications ───────────────────────────────────────────────── + { + id: "page-communications", + type: "page", + label: "Harvest Reach", + href: "/admin/communications", + category: "Communications", + iconName: "Megaphone", + keywords: ["email", "sms", "campaigns", "templates", "contacts", "blasts"], + }, + { + id: "page-communications-templates", + type: "page", + label: "Email Templates", + href: "/admin/communications?tab=templates", + category: "Communications", + iconName: "FileText", + keywords: ["harvest reach", "templates"], + }, + { + id: "page-communications-contacts", + type: "page", + label: "Contacts", + href: "/admin/communications?tab=contacts", + category: "Communications", + iconName: "Users", + keywords: ["harvest reach", "audience", "subscribers"], + }, + { + id: "page-communications-logs", + type: "page", + label: "Message Logs", + href: "/admin/communications?tab=logs", + category: "Communications", + iconName: "ScrollText", + keywords: ["harvest reach", "history", "delivered"], + }, + + // ── Growth ───────────────────────────────────────────────────────── + { + id: "page-wholesale", + type: "page", + label: "Wholesale", + href: "/admin/wholesale", + category: "Growth", + iconName: "Store", + keywords: ["b2b", "buyers", "deposits"], + }, + { + id: "page-import", + type: "page", + label: "Import Center", + href: "/admin/import", + category: "Growth", + iconName: "Upload", + keywords: ["csv", "bulk", "upload"], + }, + { + id: "page-ai", + type: "page", + label: "AI Intelligence", + href: "/admin/advanced", + category: "Growth", + iconName: "Sparkles", + keywords: ["automation", "assistant", "smart", "claude"], + }, + + // ── Tracking ─────────────────────────────────────────────────────── + { + id: "page-time-tracking", + type: "page", + label: "Time Tracking", + href: "/admin/time-tracking", + category: "Tracking", + iconName: "Clock", + keywords: ["workers", "pins", "tasks", "shifts"], + }, + { + id: "page-water-log", + type: "page", + label: "Water Log", + href: "/admin/water-log", + category: "Tracking", + iconName: "Droplets", + keywords: ["irrigation", "usage"], + }, + { + id: "page-route-trace", + type: "page", + label: "Route Trace", + href: "/admin/route-trace", + category: "Tracking", + iconName: "Route", + keywords: ["trace", "map", "gps"], + }, + + // ── Insights ─────────────────────────────────────────────────────── + { + id: "page-reports", + type: "page", + label: "Reports", + href: "/admin/reports", + category: "Insights", + iconName: "BarChart3", + keywords: ["analytics", "kpi"], + }, + { + id: "page-taxes", + type: "page", + label: "Tax Dashboard", + href: "/admin/taxes", + category: "Insights", + iconName: "Receipt", + keywords: ["taxes", "compliance", "1099"], + }, + + // ── Settings ─────────────────────────────────────────────────────── + { + id: "page-settings", + type: "page", + label: "General Settings", + href: "/admin/settings", + category: "Settings", + iconName: "Settings", + keywords: ["config", "preferences"], + }, + { + id: "page-settings-brand", + type: "page", + label: "Brand Settings", + href: "/admin/settings/brand", + category: "Settings", + iconName: "Tag", + keywords: ["branding", "logo", "name"], + }, + { + id: "page-settings-billing", + type: "page", + label: "Billing", + href: "/admin/settings/billing", + category: "Settings", + iconName: "CreditCard", + keywords: ["subscription", "plan", "invoice", "stripe"], + }, + { + id: "page-users", + type: "page", + label: "Users & Permissions", + href: "/admin/users", + category: "Settings", + iconName: "Users", + keywords: ["team", "roles", "admins"], + }, + { + id: "page-integrations", + type: "page", + label: "Integrations", + href: "/admin/settings/integrations", + category: "Settings", + iconName: "Plug", + keywords: ["stripe", "square", "resend", "connect"], + }, + { + id: "page-payments", + type: "page", + label: "Payments", + href: "/admin/settings/payments", + category: "Settings", + iconName: "Wallet", + keywords: ["processor", "stripe", "square"], + }, + { + id: "page-shipping-settings", + type: "page", + label: "Shipping Settings", + href: "/admin/settings/shipping", + category: "Settings", + iconName: "Truck", + keywords: ["zones", "rates", "fedex"], + }, + { + id: "page-addons", + type: "page", + label: "Add-ons", + href: "/admin/settings/apps", + category: "Settings", + iconName: "Puzzle", + keywords: ["features", "extensions", "apps"], + }, + + // ── Public storefronts ───────────────────────────────────────────── + { + id: "page-storefront-tuxedo", + type: "page", + label: "Tuxedo Storefront", + href: "/tuxedo", + category: "Public", + iconName: "Store", + keywords: ["public", "site", "shop"], + }, + { + id: "page-storefront-indian-river", + type: "page", + label: "Indian River Direct", + href: "/indian-river-direct", + category: "Public", + iconName: "Store", + keywords: ["public", "site", "shop"], + }, + + // ── Quick actions ────────────────────────────────────────────────── + { + id: "action-new-order", + type: "action", + label: "Create new order", + href: "/admin/orders?new=true", + category: "Quick action", + iconName: "PlusCircle", + keywords: ["new", "add", "order"], + }, + { + id: "action-new-product", + type: "action", + label: "Add new product", + href: "/admin/products/new", + category: "Quick action", + iconName: "PlusCircle", + keywords: ["new", "add", "product", "sku"], + }, + { + id: "action-new-stop", + type: "action", + label: "Add new stop", + href: "/admin/stops/new", + category: "Quick action", + iconName: "PlusCircle", + keywords: ["new", "add", "stop", "location"], + }, + { + id: "action-send-campaign", + type: "action", + label: "Send campaign", + href: "/admin/communications/compose", + category: "Quick action", + iconName: "Send", + keywords: ["email", "sms", "blast", "harvest reach"], + }, + { + id: "action-open-billing", + type: "action", + label: "Open billing", + href: "/admin/settings/billing", + category: "Quick action", + iconName: "CreditCard", + keywords: ["subscription", "plan", "stripe"], + }, + { + id: "action-invite-user", + type: "action", + label: "Invite team member", + href: "/admin/users", + category: "Quick action", + iconName: "UserPlus", + keywords: ["user", "team", "admin"], + }, + { + id: "action-sync-square", + type: "action", + label: "Sync Square", + href: "/admin/settings/square-sync", + category: "Quick action", + iconName: "RefreshCw", + keywords: ["sync", "square", "inventory"], + }, + { + id: "action-generate-report", + type: "action", + label: "Generate report", + href: "/admin/reports", + category: "Quick action", + iconName: "FileText", + keywords: ["report", "export", "analytics"], + }, +]; + +/** + * Map from PaletteEntry.iconName (PascalCase lucide name) to the imported + * component. Centralized here so the palette component doesn't have to + * know about every icon — it just looks up the component by string. + * + * The component imports lucide-react icons lazily (only the ones we + * actually use). Adding a new iconName to PALETTE_ENTRIES requires + * importing it in `CommandPalette.tsx` and adding it to the map here. + */ +export const PALETTE_ICON_NAMES = PALETTE_ENTRIES.reduce((acc, e) => { + if (!acc.includes(e.iconName)) acc.push(e.iconName); + return acc; +}, []);