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