feat(admin): add Cmd+K command palette component
Adds a self-contained <CommandPalette /> client component plus a static data file that lists every admin page (per the new IA in docs/superpowers/specs/2026-06-17-admin-redesign.md §4) and a small set of quick actions. Behavior: - Toggles on Cmd+K (mac) / Ctrl+K (win/linux) at document level. - Escape or backdrop click closes; Enter or click navigates. - Fuzzy case-insensitive match on label / category / keywords, top 8 results, highlighted match substring. - ↑/↓ keyboard nav, mouse hover updates selection, auto-focus on input when opened, body scroll lock while open. - Visual: uses existing --admin-card-bg / --admin-border / --admin-primary-soft / --admin-radius-lg / --admin-shadow-lg tokens; fade-in 180ms, scale 0.98→1 animation. - Returns null when closed (no DOM noise). Out of scope for this pass: - Component is NOT mounted in the admin layout (main thread wires it in). The data file is the source of truth; the palette does not import from AdminSidebar. - localStorage-based Recent items are left as a TODO comment in the component; v1 ships without persistence. No new dependencies; uses lucide-react (already in package.json). npx tsc --noEmit is clean.
This commit is contained in:
@@ -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<string, LucideIcon> = {
|
||||||
|
// 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)}
|
||||||
|
<mark
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--admin-primary)",
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text.slice(idx, idx + query.length)}
|
||||||
|
</mark>
|
||||||
|
{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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Command palette"
|
||||||
|
onClick={() => 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. */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes cp-fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes cp-scale-in {
|
||||||
|
from { opacity: 0; transform: scale(0.98); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={(e) => 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 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
padding: "0.875rem 1rem",
|
||||||
|
borderBottom: "1px solid var(--admin-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search
|
||||||
|
size={18}
|
||||||
|
style={{ color: "var(--admin-text-muted)", flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
placeholder="Search pages, actions, anything…"
|
||||||
|
aria-label="Search command palette"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
background: "transparent",
|
||||||
|
fontSize: "1rem",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: "var(--admin-text-primary)",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Results"
|
||||||
|
style={{
|
||||||
|
maxHeight: "60vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "0.375rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
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 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "2rem 1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--admin-text-muted)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No results for “{query}”
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
results.map((entry, i) => {
|
||||||
|
const Icon = ICON_MAP[entry.iconName] ?? Search;
|
||||||
|
const isSelected = i === selected;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onClick={() => navigate(entry.href)}
|
||||||
|
onMouseEnter={() => setSelected(i)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.625rem 0.75rem",
|
||||||
|
borderRadius: "var(--admin-radius-md)",
|
||||||
|
border: "none",
|
||||||
|
background: isSelected
|
||||||
|
? "var(--admin-primary-soft)"
|
||||||
|
: "transparent",
|
||||||
|
color: isSelected
|
||||||
|
? "var(--admin-primary)"
|
||||||
|
: "var(--admin-text-primary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: 1.3,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
transition:
|
||||||
|
"background-color 80ms ease-out, color 80ms ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={16}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: isSelected ? 1 : 0.7,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HighlightedText text={entry.label} query={query} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.6875rem",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--admin-text-muted)",
|
||||||
|
fontWeight: 500,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.category}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer hint */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem 0.625rem",
|
||||||
|
borderTop: "1px solid var(--admin-border)",
|
||||||
|
fontSize: "0.6875rem",
|
||||||
|
color: "var(--admin-text-muted)",
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↑↓ to navigate · ↵ to select · esc to close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string[]>((acc, e) => {
|
||||||
|
if (!acc.includes(e.iconName)) acc.push(e.iconName);
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
Reference in New Issue
Block a user