10 Commits

Author SHA1 Message Date
Tyler 7047e086d6 chore(design): mark Phase 5 (Orders, Products, Stops) done
Deploy to route.crispygoat.com / deploy (push) Successful in 5m24s
2026-06-17 00:49:08 -06:00
Tyler 1e0e278451 feat(admin): apply new design system to Products pages
- List page: new PageHeader with 'OPERATIONS' eyebrow + 'Add product' CTA
- New/Edit pages: PageHeader with eyebrow, icon, status badge (edit)
- ProductsClient: EmptyState for zero products, AdminBadge for status,
  Fragment Mono on stats, token-based colors throughout
- ProductTableBody: AdminBadge tones for status/taxable, token-based
  hover/selected states, Fragment Mono on price column
- NewProductForm / ProductEditForm / ProductFormModal: surrounding
  chrome on tokens; save bars use .ha-btn-primary / .ha-btn-ghost
- .atelier-* body preserved as editorial treatment
2026-06-17 00:48:26 -06:00
Tyler 467f7e63fd feat(admin): apply design system to Orders list/detail/edit/table
- Add PageHeader + Operations eyebrow on list page
- Detail page: PageHeader with order-id/date eyebrow, customer title,
  total/status subtitle, AdminBadge tone for pickup + payment processor
- AdminOrdersPanel: KPIStat for stat cards, EmptyState with Create your
  first order CTA, AdminBadge tone for status pills, lucide-react icons,
  all hardcoded Tailwind colors replaced with var(--admin-*) tokens
- OrderTableBody: AdminBadge tone for status + pickup pills, all hardcoded
  colors replaced with tokens
- OrderEditForm: ha-field-label + ha-field-input / ha-field-textarea classes
  for form fields, ha-segment control for status buttons, semantic pickup
  toggle with primary/warning tokens, all hardcoded colors replaced

Behavior preserved end-to-end. Type-check clean.
2026-06-17 00:34:58 -06:00
Tyler 685a1269a4 feat(admin): apply design system to Stops pages
- Stops list, new, and detail pages use new PageHeader with Operations
  eyebrow, Stops & Routes title, and editorial subtitle.
- StopsCalendarClient: replace hardcoded amber (#f59e0b) and brown (#92400e)
  draft markers with --admin-warning / --admin-warning-soft tokens. Calendar
  structure unchanged (drag/click/edit preserved).
- Add/Edit/StopDetail modals: replace hardcoded red rgba with --admin-danger-soft
  and color-mix derived borders.
- StopsLocationsTabs: swap hardcoded emerald-* for --admin-primary /
  --admin-primary-soft tokens.
- StopMessagingForm: align with design system (ha-field, ha-eyebrow, tokens).
- StopProductAssignment error surface: tokens instead of red rgba.
- StopsHeaderActions already tokenized via AdminButton; no change.

TS clean. Calendar functionality unchanged.
2026-06-17 00:31:33 -06:00
Tyler 6c0a282765 feat(admin): unified command center dashboard
- KPI strip uses new <KPIStat> component (4 cards)
- New 'What needs attention' feed replaces 4 tabs of cards:
  * no orders today → suggest campaign
  * pending stops → link to stops
  * usage > 85% → warn
  * starter plan → upgrade prompt
- Quick actions + plan usage consolidated into one card
- Recent orders uses new <EmptyState>
- All 15 sections in a single grid (not 4 tabs), with 'All / Operations / Fulfillment / Management / Tools' filter
- 'Press ⌘K to search' hint under section grid
- All inline SVGs replaced with lucide-react
- All hardcoded colors replaced with new design tokens
2026-06-17 00:19:39 -06:00
Tyler 1a47bbea2f chore(design): mark Phase 2 + 3 done in checklist 2026-06-17 00:17:53 -06:00
Tyler 442c16d572 feat(admin): mount command palette + plumb addons to sidebar
- mount <CommandPalette /> in admin layout (Cmd+K)
- fetch getEnabledAddons(activeBrandId) for sidebar gating
- pass enabledAddons to AdminSidebar so Water Log / Route Trace hide when off
2026-06-17 00:17:32 -06:00
Claude 8e937344ff feat(admin): extract KPIStat, EmptyState, LoadingState primitives; tighten AdminBadge palette
Phase 2 pattern extraction from the design/ui-revamp-2026-06 spec.

- KPIStat: extract the stat card pattern from DashboardClient (used four
  times inline). Supports tone (default/primary/accent/warning/danger)
  and an optional trend indicator. Reuses .admin-stat-card* CSS classes.
- EmptyState: generic centered icon + title + description + single
  primary CTA. Action renders as <Link> when href is provided, else a
  <button>; both use .ha-btn-primary.
- LoadingState: skeleton list driven by .ha-skeleton. Renders an
  optional ha-eyebrow label above N rows (icon tile + label + value
  placeholders). Each row uses the new admin design tokens.
- AdminBadge: tighten variants to the new design tokens. Legacy
  variant= prop kept for back-compat (default/success/warning/danger/
  info); new tone= prop adds neutral/primary/accent/success. All
  colors now reference --admin-* tokens; no hardcoded hex values.
  Added a 1px tone-aware border for stronger definition on the cream
  canvas. AdminStatusBadge + AdminCountBadge updated to use the new
  tone prop directly.
- design-system/index.tsx: re-export KPIStat, EmptyState, LoadingState
  from the design-system barrel alongside AdminBadge.

npx tsc --noEmit clean.
2026-06-17 00:15:44 -06:00
Tyler 4ded68cec2 feat(admin): grouped sidebar IA 2026-06-17 00:13:28 -06:00
Tyler 750efdd318 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.
2026-06-17 00:13:23 -06:00
35 changed files with 3437 additions and 1434 deletions
@@ -41,28 +41,29 @@ git branch -D design/ui-revamp-2026-06 # delete the branch
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [ ] | Replace `--admin-*` color tokens in admin-design-system.css | `src/styles/admin-design-system.css` | use spec §3 table. Drop the muddy `aba278` warning, the purple stat icons, the blue stat icons. | `style(admin): unify color tokens` |
| [ ] | Update `globals.css` if any admin colors live there | `src/app/globals.css` | grep for `admin-accent`, `admin-bg` | (same commit) |
| [ ] | Remove inline `#fef3c7` / `#dbeafe` / `#f3e8ff` from stat cards in DashboardClient | `src/components/admin/DashboardClient.tsx` | semantic colors only | `style(admin): semantic stat colors` |
| [x] | Replace `--admin-*` color tokens in admin-design-system.css | `src/styles/admin-design-system.css` | use spec §3 table. Drop the muddy `aba278` warning, the purple stat icons, the blue stat icons. | `style(admin): unify color tokens` (18fb44e) |
| [x] | Update `globals.css` if any admin colors live there | `src/app/globals.css` | grep for `admin-accent`, `admin-bg` | (same commit) |
| [x] | Remove inline `#fef3c7` / `#dbeafe` / `#f3e8ff` from stat cards in DashboardClient | `src/components/admin/DashboardClient.tsx` | semantic colors only | (same commit) |
## Phase 2 — Discoverability (sidebar + command palette)
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [ ] | Create `SideNavGroup` component (label + items + active highlight) | `src/components/admin/SideNavGroup.tsx` (new) | matches new IA: Workspace / Operations / Communications / Growth / Tracking / Insights / Settings | `feat(admin): SideNavGroup component` |
| [ ] | Refactor `AdminSidebar` to use grouped nav | `src/components/admin/AdminSidebar.tsx` | replace flat `NAV_ITEMS` with groups; keep role-gating, brand selector, sign-out | `feat(admin): grouped sidebar IA` |
| [ ] | Create `CommandPalette` (Cmd+K) | `src/components/admin/CommandPalette.tsx` (new) | global search across pages + recent items + quick actions; mounts in admin layout | `feat(admin): command palette` |
| [ ] | Mount `CommandPalette` in admin layout | `src/app/admin/layout.tsx` | wraps children, listens for `Cmd/Ctrl+K` | (same commit) |
| [x] | Create `SideNavGroup` component (label + items + active highlight) | `src/components/admin/SideNavGroup.tsx` (new) | matches new IA: Workspace / Operations / Communications / Growth / Tracking / Insights / Settings | (4ded68c) |
| [x] | Refactor `AdminSidebar` to use grouped nav | `src/components/admin/AdminSidebar.tsx` | replace flat `NAV_ITEMS` with groups; keep role-gating, brand selector, sign-out | `feat(admin): grouped sidebar IA` (4ded68c) |
| [x] | Create `CommandPalette` (Cmd+K) | `src/components/admin/CommandPalette.tsx` (new) | global search across pages + recent items + quick actions; mounts in admin layout | `feat(admin): command palette` (750efdd) |
| [x] | Mount `CommandPalette` in admin layout | `src/app/admin/layout.tsx` | wraps children, listens for `Cmd/Ctrl+K` | (442c16d) |
| [x] | Plumb `enabledAddons` to sidebar (gates Water Log / Route Trace) | `src/app/admin/layout.tsx` | `getEnabledAddons(activeBrandId)` from `actions/billing/stripe-portal` | (442c16d) |
| [ ] | Settings pages get a top tab bar (no more hidden sub-nav) | `src/app/admin/settings/*/page.tsx` | General / Brand / Billing / Users / Integrations / Payments / Shipping / Add-ons | `feat(admin): settings top tabs` |
## Phase 3 — Component patterns
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [ ] | Extract `KPIStat` from DashboardClient | `src/components/admin/KPIStat.tsx` (new) | label + value + optional trend | `feat(admin): KPIStat component` |
| [ ] | Tighten `AdminBadge` variants (status / tier / addon) | `src/components/admin/design-system/AdminBadge.tsx` | semantic: success / warning / danger / info / neutral | `style(admin): badge variants` |
| [ ] | Add `EmptyState` admin variant | `src/components/admin/EmptyState.tsx` (new) | icon + title + body + CTA; matches color tokens | `feat(admin): EmptyState component` |
| [ ] | Add `LoadingState` admin variant | `src/components/admin/LoadingState.tsx` (new) | uses `.ha-skeleton` | `feat(admin): LoadingState component` |
| [x] | Extract `KPIStat` from DashboardClient | `src/components/admin/KPIStat.tsx` (new) | label + value + optional trend | `feat(admin): KPIStat component` (8e93734) |
| [x] | Tighten `AdminBadge` variants (status / tier / addon) | `src/components/admin/design-system/AdminBadge.tsx` | semantic: success / warning / danger / info / neutral | (8e93734) |
| [x] | Add `EmptyState` admin variant | `src/components/admin/EmptyState.tsx` (new) | icon + title + body + CTA; matches color tokens | (8e93734) |
| [x] | Add `LoadingState` admin variant | `src/components/admin/LoadingState.tsx` (new) | uses `.ha-skeleton` | (8e93734) |
| [ ] | Audit `PageHeader` usage; fix inconsistencies | `src/components/admin/design-system/PageHeader.tsx` + callers | eyebrow + title + subtitle + actions; consistent across all pages | `chore(admin): PageHeader audit` |
## Phase 4 — Dashboard (unified command center)
@@ -76,9 +77,9 @@ git branch -D design/ui-revamp-2026-06 # delete the branch
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [ ] | `/admin/orders` list + detail | `src/app/admin/orders/page.tsx`, `[id]/page.tsx`, `AdminOrdersPanel.tsx` | new PageHeader, StatusPill, EmptyState | `style(admin): orders page` |
| [ ] | `/admin/products` list + new + edit | `src/app/admin/products/page.tsx`, `new/page.tsx`, `[id]/page.tsx`, `ProductsClient.tsx` | same | `style(admin): products page` |
| [ ] | `/admin/stops` calendar | `src/app/admin/stops/page.tsx`, `StopsCalendarClient.tsx` | same | `style(admin): stops page` |
| [x] | `/admin/orders` list + detail | `src/app/admin/orders/page.tsx`, `[id]/page.tsx`, `AdminOrdersPanel.tsx` | new PageHeader, StatusPill, EmptyState | `feat(admin): apply design system to Orders` (467f7e6) |
| [x] | `/admin/products` list + new + edit | `src/app/admin/products/page.tsx`, `new/page.tsx`, `[id]/page.tsx`, `ProductsClient.tsx` | same | `feat(admin): apply new design system to Products pages` (1e0e278) |
| [x] | `/admin/stops` calendar | `src/app/admin/stops/page.tsx`, `StopsCalendarClient.tsx` | same | `feat(admin): apply design system to Stops pages` (685a126) |
| [ ] | `/admin/communications` composer | `src/components/admin/HarvestReach/CampaignComposerPage.tsx` | same | `style(admin): comms composer` |
| [ ] | `/admin/settings` index + tab bar | `src/app/admin/settings/page.tsx` | overview of all settings w/ quick links | `style(admin): settings index` |
+16
View File
@@ -10,6 +10,8 @@ import { ToastProvider } from "@/components/admin/Toast";
import { ToastContainer } from "@/components/admin/ToastContainer";
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer";
import CommandPalette from "@/components/admin/CommandPalette";
import { getEnabledAddons } from "@/actions/billing/stripe-portal";
// Admin layout calls getAdminUser() which reads cookies(). Without this,
// Next.js tries to prerender the entire /admin/* tree statically and the
@@ -99,6 +101,17 @@ export default async function AdminLayout({ children }: { children: React.ReactN
console.error("[admin/layout] listBrandsForAdmin failed:", err);
}
// Fetch enabled add-ons for the active brand. Used to gate Water Log /
// Route Trace visibility in the sidebar. Empty object = all show.
let enabledAddons: Record<string, boolean> = {};
if (activeBrandId) {
try {
enabledAddons = await getEnabledAddons(activeBrandId);
} catch (err) {
console.error("[admin/layout] getEnabledAddons failed:", err);
}
}
return (
<ToastProviderWrapper>
<AdminSidebar
@@ -106,7 +119,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
brandIds={adminUser.brand_ids}
activeBrandId={activeBrandId}
brands={brands}
enabledAddons={enabledAddons}
/>
{/* Cmd+K command palette — mounted globally so any admin page can summon it */}
<CommandPalette />
{/* The main content area swaps on every navigation. Wrapping
it in <SmoothViewTransition> opts the swap into a soft
crossfade via the View Transitions API — sidebar and
+244 -70
View File
@@ -4,6 +4,8 @@ import OrderEditForm from "@/components/admin/OrderEditForm";
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
import OrderPickupAction from "@/components/admin/OrderPickupAction";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import AdminBadge from "@/components/admin/design-system/AdminBadge";
import { PageHeader } from "@/components/admin/design-system";
import { formatDate } from "@/lib/format-date";
import { redirect } from "next/navigation";
import Link from "next/link";
@@ -34,16 +36,31 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
if (!order) {
return (
<main className="min-h-screen px-6 py-10">
<main
className="min-h-screen px-6 py-10"
style={{ backgroundColor: "var(--admin-bg)" }}
>
<div className="mx-auto max-w-4xl">
<Link
href="/admin/orders"
className="inline-flex items-center gap-2 text-sm text-stone-500 hover:text-stone-700"
className="inline-flex items-center gap-2 text-sm transition-colors"
style={{ color: "var(--admin-text-muted)" }}
>
Back to Orders
</Link>
<div className="mt-8 rounded-2xl border border-red-200 bg-red-50 p-8 text-center">
<p className="text-lg font-semibold text-red-700">Order not found</p>
<div
className="mt-8 rounded-2xl border p-8 text-center"
style={{
borderColor: "var(--admin-danger-soft)",
backgroundColor: "var(--admin-danger-soft)",
}}
>
<p
className="text-lg font-semibold"
style={{ color: "var(--admin-danger)" }}
>
Order not found
</p>
</div>
</div>
</main>
@@ -67,46 +84,66 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
const total = subtotal + taxAmount - discount_amount;
return (
<main className="min-h-screen px-6 py-8">
<main
className="min-h-screen px-6 py-8"
style={{ backgroundColor: "var(--admin-bg)" }}
>
<div className="mx-auto max-w-4xl space-y-6">
{/* Back link */}
<Link
href="/admin/orders"
className="inline-flex items-center gap-2 text-xs font-semibold transition-colors"
style={{ color: "var(--admin-text-muted)" }}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to Orders
</Link>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href="/admin/orders"
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 bg-white text-stone-500 hover:bg-stone-50 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Order Details</h1>
<p className="text-sm text-stone-500">{formatDate(order.created_at)}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`rounded-full px-3 py-1 text-xs font-bold ${
order.pickup_complete
? "bg-green-50 text-green-700 border border-green-200"
: "bg-amber-50 text-amber-700 border border-amber-200"
}`}>
{order.pickup_complete ? "✓ Picked Up" : "⏳ Pending"}
</span>
{order.payment_processor && (
<span className="rounded-full bg-violet-50 px-2.5 py-0.5 text-xs font-semibold text-violet-700 border border-violet-200">
{order.payment_processor}
</span>
)}
</div>
<div>
<p className="ha-eyebrow mb-2">
Order #{order.id.slice(0, 8).toUpperCase()} · {formatDate(order.created_at)}
</p>
<PageHeader
title={order.customer_name}
subtitle={`${formatCurrency(total)} · ${order.pickup_complete ? "Picked Up" : "Awaiting Pickup"}${order.stops ? ` · ${order.stops.city}, ${order.stops.state}` : ""}`}
actions={
<div className="flex items-center gap-2">
<AdminBadge tone={order.pickup_complete ? "success" : "warning"} dot>
{order.pickup_complete ? "Picked Up" : "Pending"}
</AdminBadge>
{order.payment_processor && (
<AdminBadge tone="info">{order.payment_processor}</AdminBadge>
)}
</div>
}
/>
</div>
{/* Customer info */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400">Customer</p>
<h2 className="mt-1 text-2xl font-bold text-stone-950">{order.customer_name}</h2>
<p
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--admin-text-muted)" }}
>
Customer
</p>
<h2
className="mt-1 text-2xl font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_name}
</h2>
</div>
<OrderPickupAction
orderId={order.id}
@@ -118,14 +155,34 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
<div className="mt-5 grid grid-cols-2 gap-4">
{order.customer_phone && (
<div>
<p className="text-xs font-semibold text-stone-400">Phone</p>
<p className="mt-0.5 text-sm font-medium text-stone-700">{order.customer_phone}</p>
<p
className="text-xs font-semibold"
style={{ color: "var(--admin-text-muted)" }}
>
Phone
</p>
<p
className="mt-0.5 text-sm font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_phone}
</p>
</div>
)}
{order.customer_email && (
<div>
<p className="text-xs font-semibold text-stone-400">Email</p>
<p className="mt-0.5 text-sm font-medium text-stone-700">{order.customer_email}</p>
<p
className="text-xs font-semibold"
style={{ color: "var(--admin-text-muted)" }}
>
Email
</p>
<p
className="mt-0.5 text-sm font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_email}
</p>
</div>
)}
</div>
@@ -133,64 +190,133 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
{/* Stop info */}
{order.stops && (
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400">Pickup Location</p>
<p className="mt-1 text-lg font-bold text-stone-950">
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<p
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--admin-text-muted)" }}
>
Pickup Location
</p>
<p
className="mt-1 text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{order.stops.city}, {order.stops.state}
</p>
<p className="mt-0.5 text-sm text-stone-500">{formatDate(order.stops.date)}</p>
<p
className="mt-0.5 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{formatDate(order.stops.date)}
</p>
</div>
)}
{/* Order items */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<h3 className="text-lg font-bold text-stone-950">Order Items</h3>
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<h3
className="text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Order Items
</h3>
{order.order_items && order.order_items.length > 0 ? (
<div className="mt-4 divide-y divide-stone-100">
<div
className="mt-4 divide-y"
style={{ borderColor: "var(--admin-border-light)" }}
>
{order.order_items.map((item: OrderItem) => (
<div
key={item.id}
className="flex items-center justify-between py-3"
>
<div>
<p className="font-medium text-stone-900">
<p
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{item.products?.name ?? "Unknown Product"}
</p>
<p className="text-xs text-stone-400">Qty: {item.quantity} × {formatCurrency(Number(item.price))}</p>
<p
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Qty: {item.quantity} × {formatCurrency(Number(item.price))}
</p>
</div>
<p className="font-semibold text-stone-900">
<p
className="font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(Number(item.price) * item.quantity)}
</p>
</div>
))}
</div>
) : (
<p className="mt-4 text-sm text-stone-400">No items found</p>
<p
className="mt-4 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
No items found
</p>
)}
{/* Totals */}
<div className="mt-6 border-t border-stone-100 pt-6 space-y-2">
<div
className="mt-6 border-t pt-6 space-y-2"
style={{ borderColor: "var(--admin-border-light)" }}
>
<div className="flex justify-between text-sm">
<span className="text-stone-500">Subtotal</span>
<span className="text-stone-900">{formatCurrency(subtotal)}</span>
<span style={{ color: "var(--admin-text-muted)" }}>Subtotal</span>
<span style={{ color: "var(--admin-text-primary)" }}>
{formatCurrency(subtotal)}
</span>
</div>
{taxAmount > 0 && (
<div className="flex justify-between text-sm">
<span className="text-stone-500">Tax</span>
<span className="text-stone-900">{formatCurrency(taxAmount)}</span>
<span style={{ color: "var(--admin-text-muted)" }}>Tax</span>
<span style={{ color: "var(--admin-text-primary)" }}>
{formatCurrency(taxAmount)}
</span>
</div>
)}
{discount_amount > 0 && (
<div className="flex justify-between text-sm">
<span className="text-stone-500">Discount</span>
<span className="text-red-600">-{formatCurrency(discount_amount)}</span>
<span style={{ color: "var(--admin-text-muted)" }}>Discount</span>
<span style={{ color: "var(--admin-danger)" }}>
-{formatCurrency(discount_amount)}
</span>
{order.discount_reason && (
<span className="text-xs text-stone-400 ml-2">({order.discount_reason})</span>
<span
className="text-xs ml-2"
style={{ color: "var(--admin-text-muted)" }}
>
({order.discount_reason})
</span>
)}
</div>
)}
<div className="flex justify-between text-lg font-bold text-stone-950 pt-2 border-t border-stone-200">
<div
className="flex justify-between text-lg font-bold pt-2 border-t"
style={{
color: "var(--admin-text-primary)",
borderColor: "var(--admin-border)",
}}
>
<span>Total</span>
<span>{formatCurrency(total)}</span>
</div>
@@ -198,9 +324,25 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
</div>
{/* Payment & Refunds */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<h3 className="text-lg font-bold text-stone-950">Payment & Refunds</h3>
<p className="mt-1 text-sm text-stone-500">Record payment details and manage refunds</p>
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<h3
className="text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Payment & Refunds
</h3>
<p
className="mt-1 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
Record payment details and manage refunds
</p>
<div className="mt-5">
<OrderPaymentSection
@@ -218,9 +360,25 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
</div>
{/* Edit form */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<h3 className="text-lg font-bold text-stone-950">Edit Order</h3>
<p className="mt-1 text-sm text-stone-500">Update customer details, pricing, and status</p>
<div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<h3
className="text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Edit Order
</h3>
<p
className="mt-1 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
Update customer details, pricing, and status
</p>
<div className="mt-5">
<OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} />
@@ -229,9 +387,25 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
{/* Internal notes */}
{order.internal_notes && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-6">
<p className="text-xs font-semibold uppercase tracking-wider text-amber-600">Internal Notes</p>
<p className="mt-1 text-sm text-stone-700">{order.internal_notes}</p>
<div
className="rounded-2xl border p-6"
style={{
borderColor: "var(--admin-warning-soft)",
backgroundColor: "var(--admin-warning-soft)",
}}
>
<p
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--admin-warning)" }}
>
Internal Notes
</p>
<p
className="mt-1 text-sm"
style={{ color: "var(--admin-text-primary)" }}
>
{order.internal_notes}
</p>
</div>
)}
</div>
+10 -3
View File
@@ -65,11 +65,12 @@ export default async function AdminOrdersPage() {
}
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader
title="Orders"
subtitle="Manage customer orders and pickup status"
subtitle="View, filter, and manage customer orders."
icon={
<svg className="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
@@ -82,7 +83,13 @@ export default async function AdminOrdersPage() {
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div
className="rounded-2xl border overflow-hidden"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<AdminOrdersPanel
initialOrders={brandOrders}
initialStops={brandStops}
+41 -31
View File
@@ -1,6 +1,8 @@
import { supabase } from "@/lib/supabase";
import ProductEditForm from "@/components/admin/ProductEditForm";
import { getAdminUser } from "@/lib/admin-permissions";
import { PageHeader, AdminBadge } from "@/components/admin/design-system";
import { Package as PackageIcon } from "lucide-react";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { redirect } from "next/navigation";
import Link from "next/link";
@@ -51,15 +53,15 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
if (error || !product) {
return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-red-600">Product not found</h1>
<pre className="mt-4 rounded-xl bg-white p-4 text-sm text-stone-600">
<h1 className="text-3xl font-bold text-[var(--admin-danger)]">Product not found</h1>
<pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
{error?.message ?? "Product not found"}
</pre>
<Link
href="/admin/products"
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
className="mt-4 inline-block text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]"
>
Back to Products
</Link>
@@ -69,58 +71,66 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
}
return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl">
<Link
href="/admin/products"
className="text-sm text-stone-500 hover:text-stone-700"
>
Back to Products
</Link>
<div className="ha-eyebrow mb-2">Operations</div>
<PageHeader
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
title="Edit product"
subtitle="Update product details, pricing, and availability."
actions={
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"}
</AdminBadge>
}
/>
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
<div className="mb-6">
<Link
href="/admin/products"
className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
Back to Products
</Link>
</div>
<div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
{product.brands?.name}
</p>
<h1 className="mt-2 text-3xl font-bold text-stone-950">
<h1 className="mt-2 text-3xl font-bold text-[var(--admin-text-primary)]">
{product.name}
</h1>
<p className="mt-2 text-lg text-stone-600">
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">
{product.description}
</p>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
product.active
? "bg-emerald-100 text-emerald-600"
: "bg-stone-200 text-stone-500"
}`}
>
{product.active ? "Active" : "Inactive"}
</span>
</div>
<div className="mt-6 grid grid-cols-2 gap-6">
<div>
<p className="text-sm font-medium text-stone-500">Price</p>
<p className="mt-1 text-2xl font-bold text-stone-950">
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Price</p>
<p
className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)}
</p>
</div>
<div>
<p className="text-sm font-medium text-stone-500">Type</p>
<p className="mt-1 text-lg font-semibold text-stone-950">
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Type</p>
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
{product.type}
</p>
</div>
</div>
</div>
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
<h2 className="text-2xl font-bold text-stone-950">Edit Product</h2>
<p className="mt-1 text-stone-500">
<div className="mt-6 rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Product</h2>
<p className="mt-1 text-[var(--admin-text-muted)]">
Update product details, pricing, and availability.
</p>
+13 -14
View File
@@ -1,5 +1,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import NewProductForm from "@/components/admin/NewProductForm";
import { PageHeader } from "@/components/admin/design-system";
import { Package as PackageIcon } from "lucide-react";
import { getBrands } from "@/actions/admin/users";
import { redirect } from "next/navigation";
import Link from "next/link";
@@ -19,28 +21,25 @@ export default async function NewProductPage() {
}
return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl">
<div className="mb-8">
<div className="ha-eyebrow mb-2">Operations</div>
<PageHeader
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
title="New product"
subtitle="Add a product to your catalog with pricing, type, and availability."
/>
<div className="mb-6">
<Link
href="/admin/products"
className="text-sm text-stone-500 hover:text-stone-700"
className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
Back to Products
</Link>
</div>
<div className="rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
<h1 className="text-3xl font-bold text-stone-950">
Create Product
</h1>
<p className="mt-2 text-stone-500">
{isPlatformAdmin
? "Add a new product to any brand you administer."
: "Add a new product to your brand's catalog."}
</p>
<div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
<NewProductForm
defaultBrandId={adminUser.brand_id ?? ""}
brands={brands}
+4 -14
View File
@@ -7,16 +7,6 @@ import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import ProductsClient from "@/components/admin/ProductsClient";
import { getBrands } from "@/actions/admin/users";
// Icon for page header
const PackageIcon = () => (
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m7.5 4.27 9 5.15"/>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
<path d="m3.3 7 8.7 5 8.7-5"/>
<path d="M12 22V12"/>
</svg>
);
// Shape ProductsClient expects (legacy columns mapped from the new schema).
type ProductRow = {
id: string;
@@ -41,8 +31,8 @@ export default async function AdminProductsPage() {
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-6xl">
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Access Denied</h1>
<p className="mt-2 text-sm text-stone-500">You do not have permission to manage products.</p>
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-danger)]">Access Denied</h1>
<p className="mt-2 text-sm text-[var(--admin-text-muted)]">You do not have permission to manage products.</p>
</div>
</main>
);
@@ -133,8 +123,8 @@ export default async function AdminProductsPage() {
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-6xl">
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Error loading products</h1>
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-stone-600">
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-danger)]">Error loading products</h1>
<pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
{queryError}
</pre>
</div>
+169 -131
View File
@@ -4,9 +4,17 @@ import StopProductAssignment from "@/components/admin/StopProductAssignment";
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
import { getAdminUser } from "@/lib/admin-permissions";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
import Link from "next/link";
const StopIcon = () => (
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
);
type StopDetailPageProps = {
params: Promise<{
id: string;
@@ -68,18 +76,30 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
if (!stopRow) {
return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-red-600">Stop not found</h1>
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
{id}
</pre>
<Link
href="/admin/stops"
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
>
Back to Stops
</Link>
<main className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader
title="Stop not found"
subtitle="This stop may have been removed or you don't have access."
icon={<StopIcon />}
/>
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-5">
<pre className="rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border-light)] p-4 text-sm text-[var(--admin-text-secondary)] overflow-auto">
{id}
</pre>
<Link
href="/admin/stops"
className="ha-btn-ghost mt-4 inline-flex"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to Stops
</Link>
</div>
</div>
</div>
</main>
);
@@ -148,135 +168,153 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
},
}));
const detailSubtitle = stop.brands?.name
? `${stop.brands.name} · ${stop.location}`
: stop.location;
return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-4xl">
<Link
href="/admin/stops"
className="text-sm text-stone-500 hover:text-stone-700"
>
Back to Stops
</Link>
<main className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader
title={`${stop.city}, ${stop.state}`}
subtitle={detailSubtitle}
icon={<StopIcon />}
/>
</div>
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
{stop.brands?.name}
</p>
<h1 className="mt-2 text-4xl font-bold text-stone-950">
{stop.city}, {stop.state}
</h1>
<p className="mt-2 text-lg text-stone-600">{stop.location}</p>
</div>
<span
className={`shrink-0 rounded-full px-4 py-2 text-sm font-medium ${
stop.active
? "bg-emerald-100 text-emerald-700"
: "bg-stone-200 text-stone-500"
}`}
>
{stop.active ? "Active" : "Inactive"}
</span>
</div>
<div className="mt-8 grid grid-cols-2 gap-6">
<div>
<p className="text-sm font-medium text-stone-500">Date</p>
<p className="mt-1 text-lg font-semibold text-stone-950">
{stop.date}
</p>
</div>
<div>
<p className="text-sm font-medium text-stone-500">Time</p>
<p className="mt-1 text-lg font-semibold text-stone-950">
{stop.time}
</p>
</div>
{stop.address && (
<div>
<p className="text-sm font-medium text-stone-500">Address</p>
<p className="mt-1 text-lg font-semibold text-stone-950">
{stop.address}
{stop.zip ? `, ${stop.zip}` : ""}
</p>
</div>
)}
{stop.cutoff_date && (
<div>
<p className="text-sm font-medium text-stone-500">Cutoff</p>
<p className="mt-1 text-lg font-semibold text-stone-950">
{new Date(stop.cutoff_date + "T00:00:00").toLocaleDateString()}
</p>
</div>
)}
</div>
</div>
<div className="mt-4 flex justify-end">
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
className="rounded-xl border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50"
<div className="px-4 sm:px-6 md:px-8 pb-8 sm:pb-10">
<div className="mx-auto max-w-4xl">
<Link
href="/admin/stops"
className="ha-btn-ghost mb-4 inline-flex"
>
Duplicate Stop
</a>
</div>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to Stops
</Link>
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
<h2 className="text-2xl font-bold text-stone-950">
Assigned Products
</h2>
<p className="mt-1 text-stone-600">
Manage which products are available at this stop.
</p>
<div className="rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
{stop.brands?.name}
</p>
<h1 className="mt-2 text-4xl font-bold text-[var(--admin-text-primary)]">
{stop.city}, {stop.state}
</h1>
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">{stop.location}</p>
</div>
<div className="mt-6">
<StopProductAssignment
stopId={stop.id}
allProducts={allProducts}
assignedProducts={assignedProducts}
callerUid={adminUser.user_id}
/>
<span
className={`shrink-0 rounded-full px-4 py-2 text-sm font-medium ${
stop.active
? "bg-[var(--admin-success-soft)] text-[var(--admin-success)] border border-[var(--admin-success)]/30"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
}`}
>
{stop.active ? "Active" : "Inactive"}
</span>
</div>
<div className="mt-8 grid grid-cols-2 gap-6">
<div>
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Date</p>
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
{stop.date}
</p>
</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Time</p>
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
{stop.time}
</p>
</div>
{stop.address && (
<div>
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Address</p>
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
{stop.address}
{stop.zip ? `, ${stop.zip}` : ""}
</p>
</div>
)}
{stop.cutoff_date && (
<div>
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Cutoff</p>
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
{new Date(stop.cutoff_date + "T00:00:00").toLocaleDateString()}
</p>
</div>
)}
</div>
</div>
</div>
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
<h2 className="text-2xl font-bold text-stone-950">Edit Stop</h2>
<p className="mt-1 text-stone-600">
Update stop details, location, and availability.
</p>
<div className="mt-6">
<StopEditForm
stop={{
id: stop.id,
city: stop.city,
state: stop.state,
date: stop.date,
time: stop.time,
location: stop.location,
slug: stop.brands?.slug ?? "",
active: stop.active,
brand_id: stop.brand_id,
address: stop.address,
zip: stop.zip,
cutoff_time: stop.cutoff_time,
}}
brands={brandRows ?? []}
/>
<div className="mt-4 flex justify-end">
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
className="ha-btn-ghost"
>
Duplicate Stop
</a>
</div>
</div>
{/* Message Customers */}
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
<h2 className="text-2xl font-bold text-stone-950">Message Customers</h2>
<p className="mt-1 text-stone-600">
Send updates to customers with pending pickups at this stop.
</p>
<div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">
Assigned Products
</h2>
<p className="mt-1 text-[var(--admin-text-secondary)]">
Manage which products are available at this stop.
</p>
<div className="mt-6">
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
<div className="mt-6">
<StopProductAssignment
stopId={stop.id}
allProducts={allProducts}
assignedProducts={assignedProducts}
callerUid={adminUser.user_id}
/>
</div>
</div>
<div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Stop</h2>
<p className="mt-1 text-[var(--admin-text-secondary)]">
Update stop details, location, and availability.
</p>
<div className="mt-6">
<StopEditForm
stop={{
id: stop.id,
city: stop.city,
state: stop.state,
date: stop.date,
time: stop.time,
location: stop.location,
slug: stop.brands?.slug ?? "",
active: stop.active,
brand_id: stop.brand_id,
address: stop.address,
zip: stop.zip,
cutoff_time: stop.cutoff_time,
}}
brands={brandRows ?? []}
/>
</div>
</div>
{/* Message Customers */}
<div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Message Customers</h2>
<p className="mt-1 text-[var(--admin-text-secondary)]">
Send updates to customers with pending pickups at this stop.
</p>
<div className="mt-6">
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
</div>
</div>
</div>
</div>
+33 -18
View File
@@ -3,9 +3,17 @@ import NewStopForm from "@/components/admin/NewStopForm";
import StopProductAssignment from "@/components/admin/StopProductAssignment";
import { getAdminUser } from "@/lib/admin-permissions";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
import Link from "next/link";
const StopIcon = () => (
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
);
type Stop = {
city: string;
state: string;
@@ -62,30 +70,37 @@ export default async function NewStopPage({
[brandId]
);
const pageTitle = duplicateFrom ? "Duplicate Stop" : "Create Stop";
const pageSubtitle = duplicateFrom
? `Pre-filled from ${duplicateFrom.city}, ${duplicateFrom.state}. Edit and save to create.`
: "Add a new tour stop for Tuxedo Corn or Indian River Direct.";
return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-4xl">
<div className="mb-8">
<main className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader
title={pageTitle}
subtitle={pageSubtitle}
icon={<StopIcon />}
/>
</div>
<div className="px-4 sm:px-6 md:px-8 pb-8 sm:pb-10">
<div className="mx-auto max-w-4xl">
<Link
href="/admin/stops"
className="text-sm text-stone-500 hover:text-stone-700"
className="ha-btn-ghost mb-4 inline-flex"
>
Back to Stops
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to Stops
</Link>
</div>
<div className="rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
<h1 className="text-3xl font-bold text-stone-950">
{duplicateFrom ? "Duplicate Stop" : "Create Stop"}
</h1>
<p className="mt-2 text-stone-600">
{duplicateFrom
? `Pre-filled from ${duplicateFrom.city}, ${duplicateFrom.state}. Edit and save to create.`
: "Add a new tour stop for Tuxedo Corn or Indian River Direct."}
</p>
<NewStopForm duplicateFrom={duplicateFrom} />
<div className="rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
<NewStopForm duplicateFrom={duplicateFrom} />
</div>
</div>
</div>
</main>
+27 -15
View File
@@ -3,6 +3,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import StopTableClient from "@/components/admin/StopTableClient";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
const StopIcon = () => (
@@ -75,20 +76,23 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
<main className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="max-w-6xl mx-auto">
<nav className="flex items-center gap-2 text-xs sm:text-sm mb-6">
<a href="/admin" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Admin</a>
<span className="text-[var(--admin-text-muted)]">/</span>
<span className="text-[var(--admin-text-primary)] font-medium">Stops & Routes</span>
</nav>
<h1 className="text-2xl sm:text-3xl font-black text-red-600 tracking-tight">Error loading stops</h1>
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)] overflow-auto">
{error}
</pre>
<div className="mt-4 p-4 rounded-xl bg-stone-100 text-sm">
<p className="font-semibold">Debug info:</p>
<p>adminUser.brand_id: {adminUser.brand_id ?? "null"}</p>
<p>adminUser.role: {adminUser.role}</p>
<p>adminUser.email: {adminUser.email}</p>
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader
title="Stops & Routes"
subtitle="Schedule and manage pickup locations and dates."
icon={<StopIcon />}
/>
<div className="rounded-2xl border border-[var(--admin-danger)]/30 bg-[var(--admin-danger-soft)] p-5">
<h2 className="text-lg font-semibold text-[var(--admin-danger)]">Error loading stops</h2>
<pre className="mt-3 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)] overflow-auto">
{error}
</pre>
<div className="mt-4 p-4 rounded-xl bg-[var(--admin-bg-subtle)] text-sm text-[var(--admin-text-secondary)]">
<p className="font-semibold text-[var(--admin-text-primary)]">Debug info:</p>
<p>adminUser.brand_id: {adminUser.brand_id ?? "null"}</p>
<p>adminUser.role: {adminUser.role}</p>
<p>adminUser.email: {adminUser.email}</p>
</div>
</div>
</div>
</div>
@@ -115,7 +119,15 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader
title="Stops & Routes"
subtitle="Schedule and manage pickup locations and dates."
icon={<StopIcon />}
/>
</div>
<div className="px-4 sm:px-6 md:px-8 pb-6 sm:pb-8">
<StopTableClient stops={stopsForClient} />
</div>
</div>
+1 -1
View File
@@ -145,7 +145,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
<div
role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{ background: "rgba(220, 38, 38, 0.06)", border: "1px solid rgba(220, 38, 38, 0.15)" }}
style={{ background: "var(--admin-danger-soft)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)" }}
>
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" />
+423 -141
View File
@@ -3,11 +3,31 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import {
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
MapPin,
Package,
X,
} from "lucide-react";
import { markPickupComplete } from "@/actions/pickup";
import { createAdminOrder, type AdminCreateOrderItem } from "@/actions/orders/create-admin-order";
import { formatDate } from "@/lib/format-date";
import AdminBadge from "./design-system/AdminBadge";
import { AdminButton, AdminSearchInput, AdminFilterTabs, AdminIconButton, useToast, AdminInput, AdminTextInput, AdminSelect } from "./design-system";
import {
AdminButton,
AdminSearchInput,
AdminFilterTabs,
AdminIconButton,
useToast,
AdminInput,
AdminTextInput,
AdminSelect,
EmptyState,
KPIStat,
} from "./design-system";
import { Skeleton } from "./design-system";
type OrderItem = {
@@ -66,55 +86,13 @@ function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
}
// Icons
const Icons = {
mapPin: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0z"/>
</svg>
),
check: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
),
x: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
),
chevronDown: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
),
chevronLeft: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6"/>
</svg>
),
chevronRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
),
package: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4 7.55 4.24"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.29 7 12 12 20.71 7"/>
<line x1="12" y1="22" x2="12" y2="12"/>
</svg>
),
selectAll: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<polyline points="9 11 12 14 22 4"/>
</svg>
),
};
// Icon wrappers (lucide-react) — kept compact for inline use.
const MapPinIcon = () => <MapPin className="h-4 w-4" strokeWidth={2} />;
const CheckIcon = () => <Check className="h-4 w-4" strokeWidth={2} />;
const XIcon = () => <X className="h-3 w-3" strokeWidth={2} />;
const ChevronDownIcon = () => <ChevronDown className="h-4 w-4" strokeWidth={2} />;
const ChevronLeftIcon = () => <ChevronLeft className="h-4 w-4" strokeWidth={2} />;
const ChevronRightIcon = () => <ChevronRight className="h-4 w-4" strokeWidth={2} />;
export default function AdminOrdersPanel({
initialOrders,
@@ -355,12 +333,23 @@ export default function AdminOrdersPanel({
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)]">
{Icons.package("h-5 w-5 text-white")}
<div
className="flex h-10 w-10 items-center justify-center rounded-xl"
style={{ backgroundColor: "var(--admin-accent)" }}
>
<Package className="h-5 w-5 text-white" strokeWidth={2} />
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Orders</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
<h2
className="text-base sm:text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Orders
</h2>
<p
className="text-[10px] sm:text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
{filteredOrders.length} order{filteredOrders.length !== 1 ? "s" : ""}
</p>
</div>
@@ -368,7 +357,7 @@ export default function AdminOrdersPanel({
<div className="flex items-center gap-2">
{brandId && (
<AdminBadge variant="info">Brand scoped</AdminBadge>
<AdminBadge tone="info">Brand scoped</AdminBadge>
)}
<AdminButton
onClick={openNewOrderModal}
@@ -382,18 +371,21 @@ export default function AdminOrdersPanel({
{/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{isLoading ? <Skeleton variant="text" className="w-16 h-8" /> : orders.length}</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Pending</p>
<p className="text-lg sm:text-xl md:text-2xl font-bold text-amber-600 mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pendingCount}</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Picked Up</p>
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-accent)] mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pickedUpCount}</p>
</div>
<KPIStat
label="Total"
value={isLoading ? <Skeleton variant="text" className="w-16 h-8" /> : orders.length}
tone="default"
/>
<KPIStat
label="Pending"
value={isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pendingCount}
tone="warning"
/>
<KPIStat
label="Picked Up"
value={isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pickedUpCount}
tone="primary"
/>
</div>
{/* Filters */}
@@ -423,40 +415,92 @@ export default function AdminOrdersPanel({
<div className="relative">
<button
onClick={() => setShowStopDropdown(!showStopDropdown)}
className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors ${
className="flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors"
style={
selectedStops.length > 0
? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]"
: "border-[var(--admin-border)] bg-white text-stone-600 hover:bg-stone-50"
}`}
? {
borderColor: "var(--admin-accent)",
backgroundColor: "var(--admin-accent-light)",
color: "var(--admin-accent-text)",
}
: {
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
color: "var(--admin-text-secondary)",
}
}
>
{Icons.mapPin("h-4 w-4")}
<MapPinIcon />
<span>Stops</span>
{selectedStops.length > 0 && (
<span className="rounded-full bg-[var(--admin-accent)] text-white px-1.5 py-0.5 text-xs font-semibold">{selectedStops.length}</span>
<span
className="rounded-full text-white px-1.5 py-0.5 text-xs font-semibold"
style={{ backgroundColor: "var(--admin-accent)" }}
>
{selectedStops.length}
</span>
)}
{Icons.chevronDown("h-4 w-4")}
<ChevronDownIcon />
</button>
{showStopDropdown && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowStopDropdown(false)} />
<div className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border border-[var(--admin-border)] bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
<span className="text-sm font-semibold text-stone-700">Filter by Stop</span>
<button onClick={clearStops} className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] font-medium">Clear all</button>
<div
className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border shadow-lg"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div
className="flex items-center justify-between border-b px-4 py-3"
style={{ borderColor: "var(--admin-border-light)" }}
>
<span
className="text-sm font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
Filter by Stop
</span>
<button
onClick={clearStops}
className="text-xs font-medium"
style={{ color: "var(--admin-accent)" }}
>
Clear all
</button>
</div>
<div className="max-h-64 overflow-y-auto p-2">
{stops.map((stop) => (
<label key={stop.id} className="flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-stone-50 cursor-pointer">
<label
key={stop.id}
className="flex items-center gap-3 rounded-lg px-3 py-2 cursor-pointer transition-colors"
style={{ color: "var(--admin-text-primary)" }}
>
<input
type="checkbox"
checked={selectedStops.includes(stop.id)}
onChange={() => toggleStop(stop.id)}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
className="h-4 w-4 rounded"
style={{
borderColor: "var(--admin-border-strong)",
accentColor: "var(--admin-accent)",
}}
/>
<div className="flex-1">
<span className="text-sm font-medium text-stone-700">{stop.city}, {stop.state}</span>
<span className="ml-2 text-xs text-stone-400">{formatDate(stop.date)}</span>
<span
className="text-sm font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{stop.city}, {stop.state}
</span>
<span
className="ml-2 text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
{formatDate(stop.date)}
</span>
</div>
</label>
))}
@@ -474,10 +518,23 @@ export default function AdminOrdersPanel({
const stop = stops.find((s) => s.id === stopId);
if (!stop) return null;
return (
<span key={stopId} className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)] px-3 py-1 text-xs font-medium text-[var(--admin-accent-text)]">
<span
key={stopId}
className="inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium"
style={{
backgroundColor: "var(--admin-accent-light)",
borderColor: "var(--admin-accent)",
color: "var(--admin-accent-text)",
}}
>
{stop.city}, {stop.state}
<button onClick={() => toggleStop(stopId)} className="ml-1 hover:text-[var(--admin-accent-hover)]">
{Icons.x("h-3 w-3")}
<button
onClick={() => toggleStop(stopId)}
className="ml-1"
style={{ color: "var(--admin-accent-hover)" }}
aria-label={`Remove ${stop.city}, ${stop.state}`}
>
<XIcon />
</button>
</span>
);
@@ -487,14 +544,24 @@ export default function AdminOrdersPanel({
{/* Bulk actions bar */}
{selectedOrders.size > 0 && (
<div className="mb-4 flex items-center justify-between rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-4 py-3">
<div
className="mb-4 flex items-center justify-between rounded-xl border px-4 py-3"
style={{
borderColor: "var(--admin-accent)",
backgroundColor: "var(--admin-accent-light)",
}}
>
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-[var(--admin-accent-text)]">
<span
className="text-sm font-semibold"
style={{ color: "var(--admin-accent-text)" }}
>
{selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={() => setSelectedOrders(new Set())}
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]"
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Clear
</button>
@@ -504,7 +571,7 @@ export default function AdminOrdersPanel({
size="sm"
onClick={handleBulkMarkPickup}
isLoading={bulkMarkingUp}
icon={Icons.check("h-4 w-4")}
icon={<CheckIcon />}
>
Mark All as Picked Up
</AdminButton>
@@ -513,7 +580,13 @@ export default function AdminOrdersPanel({
{/* Orders Table */}
{isLoading ? (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<div
className="rounded-xl border p-6"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
@@ -526,80 +599,184 @@ export default function AdminOrdersPanel({
</div>
</div>
) : paginatedOrders.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-[var(--admin-border)] bg-white">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
{Icons.package("h-8 w-8 text-stone-400")}
</div>
<p className="text-sm font-medium text-stone-600">No orders found</p>
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
<div
className="rounded-xl border"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<EmptyState
icon={<Package className="h-9 w-9" strokeWidth={1.25} />}
title={orders.length === 0 ? "No orders yet" : "No orders match your filters"}
description={
orders.length === 0
? "Create your first order to get started."
: "Try adjusting your filters or clearing your search."
}
action={
orders.length === 0
? { label: "Create your first order", href: "/admin/orders?new=true" }
: undefined
}
/>
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-[var(--admin-border)] bg-white">
<div
className="overflow-x-auto rounded-xl border"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<table className="w-full text-sm min-w-[700px]">
<thead className="bg-stone-50">
<tr className="border-b border-[var(--admin-border)]">
<thead style={{ backgroundColor: "var(--admin-bg)" }}>
<tr style={{ borderBottom: "1px solid var(--admin-border)" }}>
<th className="w-10 px-4 py-3">
<input
type="checkbox"
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
onChange={toggleSelectAll}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
className="h-4 w-4 rounded cursor-pointer"
style={{
borderColor: "var(--admin-border-strong)",
accentColor: "var(--admin-accent)",
}}
/>
</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Order</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Customer</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden md:table-cell">Stop</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden lg:table-cell">Date</th>
<th className="text-center px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Items</th>
<th className="text-right px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Total</th>
<th className="text-center px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th>
<th
className="text-left px-4 py-3 font-semibold text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Order
</th>
<th
className="text-left px-4 py-3 font-semibold text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Customer
</th>
<th
className="text-left px-4 py-3 font-semibold text-xs hidden md:table-cell"
style={{ color: "var(--admin-text-muted)" }}
>
Stop
</th>
<th
className="text-left px-4 py-3 font-semibold text-xs hidden lg:table-cell"
style={{ color: "var(--admin-text-muted)" }}
>
Date
</th>
<th
className="text-center px-4 py-3 font-semibold text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Items
</th>
<th
className="text-right px-4 py-3 font-semibold text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Total
</th>
<th
className="text-center px-4 py-3 font-semibold text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
<tbody style={{ borderColor: "var(--admin-border)" }}>
{paginatedOrders.map((order) => (
<tr key={order.id} className="hover:bg-stone-50 transition-colors">
<tr
key={order.id}
className="transition-colors"
style={{ borderTop: "1px solid var(--admin-border-light)" }}
>
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedOrders.has(order.id)}
onChange={() => toggleOrderSelection(order.id)}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
className="h-4 w-4 rounded cursor-pointer"
style={{
borderColor: "var(--admin-border-strong)",
accentColor: "var(--admin-accent)",
}}
/>
</td>
<td className="px-4 py-3">
<Link href={`/admin/orders/${order.id}`} className="font-mono text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]">
<Link
href={`/admin/orders/${order.id}`}
className="font-mono text-xs"
style={{ color: "var(--admin-accent)" }}
>
{shortId(order.id)}
</Link>
</td>
<td className="px-4 py-3">
<div className="font-medium text-[var(--admin-text-primary)]">{order.customer_name}</div>
{order.customer_phone && <div className="font-mono text-xs text-stone-400">{order.customer_phone}</div>}
<div
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_name}
</div>
{order.customer_phone && (
<div
className="font-mono text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
{order.customer_phone}
</div>
)}
</td>
<td className="px-4 py-3 hidden md:table-cell">
{order.stops ? (
<span className="text-sm text-[var(--admin-text-primary)]">{order.stops.city}, {order.stops.state}</span>
<span
className="text-sm"
style={{ color: "var(--admin-text-primary)" }}
>
{order.stops.city}, {order.stops.state}
</span>
) : (
<span className="text-stone-300"></span>
<span style={{ color: "var(--admin-text-muted)" }}></span>
)}
</td>
<td className="px-4 py-3 hidden lg:table-cell">
<span className="text-sm text-stone-500">{formatDate(order.created_at)}</span>
<span
className="text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{formatDate(order.created_at)}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-mono text-sm text-stone-600">{order.order_items?.length ?? 0}</span>
<span
className="font-mono text-sm"
style={{ color: "var(--admin-text-secondary)" }}
>
{order.order_items?.length ?? 0}
</span>
</td>
<td className="px-4 py-3 text-right">
<span className="font-mono font-semibold text-[var(--admin-text-primary)]">{formatCurrency(order.subtotal)}</span>
<span
className="font-mono font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(order.subtotal)}
</span>
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2">
{order.pickup_complete ? (
<AdminBadge variant="success" dot>Picked Up</AdminBadge>
<AdminBadge tone="success" dot>Picked Up</AdminBadge>
) : (
<AdminBadge variant="warning" dot>Pending</AdminBadge>
<AdminBadge tone="warning" dot>Pending</AdminBadge>
)}
{order.payment_processor === "square" && (
<AdminBadge variant="info">Square</AdminBadge>
<AdminBadge tone="info">Square</AdminBadge>
)}
{!order.pickup_complete && (
<AdminButton
@@ -623,8 +800,14 @@ export default function AdminOrdersPanel({
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]">
<p className="text-xs text-[var(--admin-text-muted)]">
<div
className="flex items-center justify-between mt-4 pt-4 border-t"
style={{ borderColor: "var(--admin-border)" }}
>
<p
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, filteredOrders.length)} of {filteredOrders.length}
</p>
<div className="flex items-center gap-2">
@@ -636,9 +819,14 @@ export default function AdminOrdersPanel({
disabled={page === 0}
className="!rounded-lg"
>
{Icons.chevronLeft("h-4 w-4")}
<ChevronLeftIcon />
</AdminIconButton>
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
<span
className="px-3 text-sm font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{page + 1} / {totalPages}
</span>
<AdminIconButton
variant="secondary"
size="sm"
@@ -647,7 +835,7 @@ export default function AdminOrdersPanel({
disabled={page >= totalPages - 1}
className="!rounded-lg"
>
{Icons.chevronRight("h-4 w-4")}
<ChevronRightIcon />
</AdminIconButton>
</div>
</div>
@@ -656,19 +844,54 @@ export default function AdminOrdersPanel({
{/* New Order Modal (admin manual entry) - sibling to the main panel content */}
{showNewOrderModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-2xl rounded-2xl border border-[var(--admin-border)] bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-4">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ backgroundColor: "rgba(26, 24, 20, 0.4)" }}>
<div
className="w-full max-w-2xl rounded-2xl border shadow-2xl"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div
className="flex items-center justify-between border-b px-6 py-4"
style={{ borderColor: "var(--admin-border)" }}
>
<div>
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">New Order (Admin)</h3>
<p className="text-xs text-[var(--admin-text-muted)]">Manual entry for testing / phone orders</p>
<h3
className="text-lg font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
New Order (Admin)
</h3>
<p
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Manual entry for testing / phone orders
</p>
</div>
<button onClick={closeNewOrderModal} className="text-2xl leading-none text-stone-400 hover:text-stone-600">×</button>
<button
onClick={closeNewOrderModal}
className="text-2xl leading-none"
style={{ color: "var(--admin-text-muted)" }}
aria-label="Close"
>
×
</button>
</div>
<div className="p-6 space-y-5 max-h-[70vh] overflow-auto">
{newOrderError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-2 text-sm text-red-700">{newOrderError}</div>
<div
className="rounded-lg border px-4 py-2 text-sm"
style={{
backgroundColor: "var(--admin-danger-soft)",
borderColor: "var(--admin-danger-soft)",
color: "var(--admin-danger)",
}}
>
{newOrderError}
</div>
)}
{/* Customer */}
@@ -715,8 +938,18 @@ export default function AdminOrdersPanel({
{/* Items */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-semibold text-[var(--admin-text-secondary)]">Items</label>
<span className="text-xs text-[var(--admin-text-muted)]">Total: ${newOrderTotal.toFixed(2)}</span>
<label
className="block text-xs font-semibold"
style={{ color: "var(--admin-text-secondary)" }}
>
Items
</label>
<span
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Total: ${newOrderTotal.toFixed(2)}
</span>
</div>
{products.length > 0 ? (
@@ -733,20 +966,41 @@ export default function AdminOrdersPanel({
/>
</div>
) : (
<p className="text-xs text-amber-600 mb-2">No products loaded for this brand. You can still enter custom items if the action supports it.</p>
<p
className="text-xs mb-2"
style={{ color: "var(--admin-warning)" }}
>
No products loaded for this brand. You can still enter custom items if the action supports it.
</p>
)}
{newOrderItems.length === 0 && (
<p className="text-xs text-[var(--admin-text-muted)]">No items yet. Use the selector above.</p>
<p
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
No items yet. Use the selector above.
</p>
)}
{newOrderItems.length > 0 && (
<div className="space-y-2 border border-[var(--admin-border)] rounded-xl p-3 bg-[var(--admin-bg)]">
<div
className="space-y-2 border rounded-xl p-3"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-bg)",
}}
>
{newOrderItems.map((item, idx) => {
const prod = products.find((p) => p.id === item.product_id);
return (
<div key={idx} className="grid grid-cols-12 gap-2 items-center text-sm">
<div className="col-span-5 font-medium truncate">{prod?.name ?? item.product_id}</div>
<div
className="col-span-5 font-medium truncate"
style={{ color: "var(--admin-text-primary)" }}
>
{prod?.name ?? item.product_id}
</div>
<div className="col-span-2">
<input
type="number"
@@ -754,6 +1008,11 @@ export default function AdminOrdersPanel({
value={item.quantity}
onChange={(e) => updateNewOrderItem(idx, { quantity: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full rounded border px-2 py-1 text-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
color: "var(--admin-text-primary)",
}}
/>
</div>
<div className="col-span-2">
@@ -763,6 +1022,11 @@ export default function AdminOrdersPanel({
value={item.price}
onChange={(e) => updateNewOrderItem(idx, { price: parseFloat(e.target.value) || 0 })}
className="w-full rounded border px-2 py-1 text-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
color: "var(--admin-text-primary)",
}}
/>
</div>
<div className="col-span-2">
@@ -770,13 +1034,25 @@ export default function AdminOrdersPanel({
value={item.fulfillment}
onChange={(e) => updateNewOrderItem(idx, { fulfillment: e.target.value as "pickup" | "ship" })}
className="w-full rounded border px-2 py-1 text-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
color: "var(--admin-text-primary)",
}}
>
<option value="pickup">pickup</option>
<option value="ship">ship</option>
</select>
</div>
<div className="col-span-1 text-right">
<button type="button" onClick={() => removeNewOrderItem(idx)} className="text-red-500 text-xs hover:underline">remove</button>
<button
type="button"
onClick={() => removeNewOrderItem(idx)}
className="text-xs hover:underline"
style={{ color: "var(--admin-danger)" }}
>
remove
</button>
</div>
</div>
);
@@ -786,7 +1062,13 @@ export default function AdminOrdersPanel({
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-[var(--admin-border)] px-6 py-4 bg-stone-50 rounded-b-2xl">
<div
className="flex items-center justify-end gap-3 border-t px-6 py-4 rounded-b-2xl"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-bg-subtle)",
}}
>
<AdminButton variant="secondary" onClick={closeNewOrderModal} disabled={newOrderSubmitting}>
Cancel
</AdminButton>
+323 -322
View File
@@ -1,226 +1,146 @@
"use client";
import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react";
import { useState, useEffect, useRef, useCallback, KeyboardEvent, ComponentType } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import {
LayoutDashboard,
ShoppingCart,
MapPin,
Package,
Truck,
Ship,
Mail,
Store,
Sparkles,
Upload,
BrainCircuit,
Clock,
Droplets,
Route,
ChartBar,
Receipt,
Settings,
LogOut,
Menu,
X,
ArrowLeft,
} from "lucide-react";
import { signOutAction } from "@/actions/auth-actions";
import BrandSelector from "@/components/admin/BrandSelector";
import SideNavGroup from "@/components/admin/SideNavGroup";
// Elegant warm sidebar design
// Colors: parchment 100 bg, soft linen text, powder petal accent
// Sidebar tokens used (from src/styles/admin-design-system.css):
// --admin-sidebar-bg (#2A2520)
// --admin-sidebar-text (#B8B4A8)
// --admin-sidebar-hover (#3A352F)
// --admin-sidebar-active (#4A443C)
// --admin-sidebar-accent (#D4A24C — amber, used for active left-border + dot)
// The "amber accent" replaces the old `--admin-accent` (citrus orange) in the
// sidebar only — primary buttons elsewhere still use the deep botanical green.
type NavItem = {
href?: string;
type IconComponent = ComponentType<{ className?: string; size?: number | string }>;
type NavItemDef = {
href: string;
label: string;
icon?: string;
divider?: boolean;
icon: IconComponent;
/** If set, this item is hidden when `enabledAddons[key] === false`. */
addonKey?: string;
/** When true, only `userRole === "platform_admin"` sees this item. */
requiresPlatformAdmin?: boolean;
};
const NAV_ITEMS: NavItem[] = [
// Main
{ href: "/admin", label: "Dashboard", icon: "grid" },
{ href: "/admin/orders", label: "Orders", icon: "shopping-cart" },
{ href: "/admin/stops", label: "Stops & Routes", icon: "map-pin" },
{ href: "/admin/products", label: "Products", icon: "package" },
{ href: "/admin/communications", label: "Communications", icon: "mail" },
// Settings section
{ divider: true, label: "Settings" },
{ href: "/admin/settings", label: "General", icon: "settings" },
{ href: "/admin/time-tracking", label: "Workers & PINs", icon: "users" },
{ href: "/admin/time-tracking?tab=tasks", label: "Tasks", icon: "clipboard" },
{ href: "/admin/users", label: "Users & Permissions", icon: "shield" },
{ href: "/admin/settings/integrations", label: "Integrations", icon: "plug" },
{ href: "/admin/settings/billing", label: "Billing", icon: "billing" },
type NavGroup = {
label: string;
items: NavItemDef[];
};
/**
* Grouped nav IA per `docs/superpowers/specs/2026-06-17-admin-redesign.md` §4.
* Order is intentional (top-to-bottom = frequency-of-use + mental flow).
*
* - Workspace : Dashboard is always shown; Command Center is platform_admin-only.
* - Operations : Day-to-day order/stop/product flow.
* - Communications: Harvest Reach only — the other sub-items live inside that page.
* - Growth : Wholesale portal, data imports, AI assistant.
* - Tracking : Time tracking is always on; Water Log / Route Trace are
* gated on their respective add-ons via `enabledAddons`.
* - Insights : Reporting + tax.
* - Settings : Single entry; sub-pages are tabs inside /admin/settings.
*/
const NAV_GROUPS: NavGroup[] = [
{
label: "Workspace",
items: [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{
href: "/admin/command-center",
label: "Command Center",
icon: Sparkles,
requiresPlatformAdmin: true,
},
],
},
{
label: "Operations",
items: [
{ href: "/admin/orders", label: "Orders", icon: ShoppingCart },
{ href: "/admin/stops", label: "Stops & Routes", icon: MapPin },
{ href: "/admin/products", label: "Products", icon: Package },
{ href: "/admin/pickup", label: "Driver Pickup", icon: Truck },
{ href: "/admin/shipping", label: "Shipping", icon: Ship },
],
},
{
label: "Communications",
items: [
{ href: "/admin/communications", label: "Harvest Reach", icon: Mail },
],
},
{
label: "Growth",
items: [
{ href: "/admin/wholesale", label: "Wholesale", icon: Store },
{ href: "/admin/import", label: "Import Center", icon: Upload },
{ href: "/admin/settings/ai", label: "AI Intelligence", icon: BrainCircuit },
],
},
{
label: "Tracking",
items: [
{ href: "/admin/time-tracking", label: "Time Tracking", icon: Clock },
{ href: "/admin/water-log", label: "Water Log", icon: Droplets, addonKey: "water_log" },
{ href: "/admin/route-trace", label: "Route Trace", icon: Route, addonKey: "route_trace" },
],
},
{
label: "Insights",
items: [
{ href: "/admin/reports", label: "Reports", icon: ChartBar },
{ href: "/admin/taxes", label: "Tax Dashboard", icon: Receipt },
],
},
{
label: "Settings",
items: [
{ href: "/admin/settings", label: "Settings", icon: Settings },
],
},
];
// Icon components
function GridIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25a2.25 2.25 0 01-2.25 2.25H15.75a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H15.75a2.25 2.25 0 01-2.25-2.25v-2.25z" />
</svg>
);
}
function CartIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
</svg>
);
}
function MapPinIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
);
}
function PackageIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
);
}
function ClipboardIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
);
}
function ClockIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
function MailIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
);
}
function SparkleIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
);
}
function SettingsCogIcon({ open }: { open: boolean }) {
return (
<svg className={`w-4 h-4 transition-transform duration-200 ${open ? "rotate-45" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09.542.56.94 1.11.94h2.64c.55 0 1.02-.398 1.11-.94l.213-1.999c.018-.158.04-.315.062-.472a.563.563 0 00-.122-.519l-.79-2.758A.562.562 0 0014.56 0H9.44a.563.563 0 00-.424.264l-.79 2.758a.563.563 0 00-.122.519c.022.157.044.314.062.472l.213 1.999zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
}
function HamburgerIcon() {
return (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
);
}
function CloseIcon() {
return (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
function BillingIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 0h3m-3.75 0h3m-3.75 0h3m3.75 0h3m-3.75 0h3m3.75 0h3m3.75 0h3" />
</svg>
);
}
function PuzzleIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 00.658-.663 48.422 48.422 0 00-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 01-.61-.58v0z" />
</svg>
);
}
function PlugIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
);
}
function TruckIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
</svg>
);
}
function SquareIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" />
</svg>
);
}
function LogoutIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
);
}
function UsersIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
);
}
function ShieldIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
);
}
const ICON_MAP: Record<string, React.ReactNode> = {
grid: <GridIcon />,
"shopping-cart": <CartIcon />,
"map-pin": <MapPinIcon />,
package: <PackageIcon />,
clipboard: <ClipboardIcon />,
clock: <ClockIcon />,
mail: <MailIcon />,
settings: <SettingsCogIcon open={false} />,
advanced: <SparkleIcon />,
billing: <BillingIcon />,
puzzle: <PuzzleIcon />,
plug: <PlugIcon />,
sparkles: <SparkleIcon />,
truck: <TruckIcon />,
square: <SquareIcon />,
users: <UsersIcon />,
shield: <ShieldIcon />,
};
type SidebarProps = {
userRole?: string | null;
brandIds?: string[];
activeBrandId?: string | null;
brands?: { id: string; name: string; slug: string; logo_url: string | null }[];
/**
* Map of add-on key → enabled. When a key is `false` (or omitted, with the
* add-on not in the map), any nav item with that `addonKey` is hidden.
* When the prop itself is omitted, all add-on-gated items are shown
* (back-compat with the current admin layout, which doesn't pass it).
*/
enabledAddons?: Record<string, boolean>;
};
export default function AdminSidebar({
@@ -228,6 +148,7 @@ export default function AdminSidebar({
brandIds,
activeBrandId,
brands,
enabledAddons,
}: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
@@ -237,15 +158,52 @@ export default function AdminSidebar({
const mobileMenuRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin"
: userRole === "store_employee" ? "Store Employee"
: null;
const roleLabel =
userRole === "platform_admin"
? "Platform Admin"
: userRole === "brand_admin"
? "Brand Admin"
: userRole === "store_employee"
? "Store Employee"
: null;
const isActive = useCallback((href: string) => {
if (href === "/admin") return pathname === "/admin";
return pathname.startsWith(href);
}, [pathname]);
/**
* Build the filtered list of visible nav items for the current viewer.
* Honors role-gating (Command Center) and add-on gating (Water Log, Route Trace).
* If `enabledAddons` is undefined, add-on-gated items are shown (no gating).
*/
const visibleItems: NavItemDef[] = NAV_GROUPS.flatMap((group) => {
return group.items.filter((item) => {
if (item.requiresPlatformAdmin && userRole !== "platform_admin") {
return false;
}
if (item.addonKey && enabledAddons && enabledAddons[item.addonKey] === false) {
return false;
}
return true;
});
});
/**
* Groups rendered, with empty ones removed. The Communications group is
* always rendered today (Harvest Reach is always visible) — but if a
* future iteration hides Harvest Reach for some role, the entire group
* header goes away rather than rendering an empty section.
*/
const visibleGroups: NavGroup[] = NAV_GROUPS
.map((group) => ({
...group,
items: group.items.filter((item) => visibleItems.includes(item)),
}))
.filter((group) => group.items.length > 0);
const isActive = useCallback(
(href: string) => {
if (href === "/admin") return pathname === "/admin";
return pathname.startsWith(href);
},
[pathname],
);
// Close mobile menu with animation
const closeMobileMenu = useCallback(() => {
@@ -283,31 +241,106 @@ export default function AdminSidebar({
};
}, [mobileOpen]);
// Keyboard navigation for nav items
const handleNavKeyDown = useCallback((e: KeyboardEvent<HTMLAnchorElement>, href: string, index: number) => {
const navLinks = NAV_ITEMS.filter(item => !item.divider);
// Keyboard navigation for nav items (arrow up/down loops, enter/space activates)
const handleNavKeyDown = useCallback(
(e: KeyboardEvent<HTMLAnchorElement>, href: string, index: number) => {
const total = visibleItems.length;
if (total === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
const nextIndex = (index + 1) % navLinks.length;
const nextLink = document.querySelector(`[data-nav-index="${nextIndex}"]`) as HTMLAnchorElement;
nextLink?.focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prevIndex = index === 0 ? navLinks.length - 1 : index - 1;
const prevLink = document.querySelector(`[data-nav-index="${prevIndex}"]`) as HTMLAnchorElement;
prevLink?.focus();
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
router.push(href);
if (mobileOpen) closeMobileMenu();
}
}, [router, mobileOpen, closeMobileMenu]);
if (e.key === "ArrowDown") {
e.preventDefault();
const nextIndex = (index + 1) % total;
const nextLink = document.querySelector(
`[data-nav-index="${nextIndex}"]`,
) as HTMLAnchorElement | null;
nextLink?.focus();
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prevIndex = index === 0 ? total - 1 : index - 1;
const prevLink = document.querySelector(
`[data-nav-index="${prevIndex}"]`,
) as HTMLAnchorElement | null;
prevLink?.focus();
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
router.push(href);
if (mobileOpen) closeMobileMenu();
}
},
[router, mobileOpen, closeMobileMenu, visibleItems.length],
);
async function handleLogout() {
await signOutAction();
}
// Shared nav-link renderer. Used by both the desktop sidebar and the mobile
// slide-in panel — they're the same list, just in a different container.
const renderNavLink = (item: NavItemDef, index: number) => {
const active = isActive(item.href);
const Icon = item.icon;
return (
<li key={item.href}>
<Link
href={item.href}
data-nav-index={index}
onClick={() => closeMobileMenu()}
onKeyDown={(e) => handleNavKeyDown(e, item.href, index)}
className={[
"group flex items-center gap-2.5 pl-3 pr-2.5 py-2 rounded-r-md text-xs font-medium",
"border-l-[3px] transition-all duration-200",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--admin-sidebar-bg)]",
].join(" ")}
style={
active
? {
backgroundColor: "var(--admin-sidebar-active)",
color: "#F4F1E8",
borderLeftColor: "var(--admin-sidebar-accent)",
}
: {
color: "var(--admin-sidebar-text)",
borderLeftColor: "transparent",
}
}
onMouseEnter={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor =
"var(--admin-sidebar-hover)";
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = "transparent";
}
}}
aria-current={active ? "page" : undefined}
tabIndex={0}
>
<span
className="flex-shrink-0 transition-colors duration-200"
style={{
color: active
? "var(--admin-sidebar-accent)"
: "rgba(208, 203, 180, 0.6)",
}}
aria-hidden="true"
>
<Icon className="w-4 h-4" />
</span>
<span className="flex-1 truncate">{item.label}</span>
{active && (
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
aria-hidden="true"
/>
)}
</Link>
</li>
);
};
return (
<>
{/* Mobile hamburger button - accessible */}
@@ -319,7 +352,7 @@ export default function AdminSidebar({
aria-controls="admin-sidebar"
type="button"
>
<HamburgerIcon />
<Menu className="w-5 h-5" aria-hidden="true" />
</button>
{/* Mobile overlay backdrop with blur */}
@@ -332,20 +365,20 @@ export default function AdminSidebar({
/>
)}
{/* Sidebar panel - Elegant warm dark with smooth transitions */}
{/* Sidebar panel - dark canvas, grouped nav, amber accent for active */}
<aside
ref={sidebarRef}
id="admin-sidebar"
className={[
"fixed top-0 left-0 h-full w-56 z-50",
"fixed top-0 left-0 h-full w-60 z-50",
"border-r flex flex-col",
"transition-transform duration-300 ease-out lg:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full",
isClosing ? "opacity-90" : "opacity-100"
isClosing ? "opacity-90" : "opacity-100",
].join(" ")}
style={{
backgroundColor: "var(--admin-sidebar-bg)",
borderColor: "rgba(208, 203, 180, 0.2)"
borderColor: "rgba(208, 203, 180, 0.2)",
}}
role="navigation"
aria-label="Admin navigation"
@@ -364,11 +397,9 @@ export default function AdminSidebar({
>
<div
className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm transition-transform hover:scale-105"
style={{ backgroundColor: "var(--admin-accent)" }}
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
>
<svg className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<ArrowLeft className="h-5 w-5 text-white" aria-hidden="true" />
</div>
<span className="text-sm font-semibold tracking-tight">Admin</span>
</Link>
@@ -381,101 +412,66 @@ export default function AdminSidebar({
aria-label="Close navigation menu"
type="button"
>
<CloseIcon />
<X className="w-5 h-5" aria-hidden="true" />
</button>
</div>
</div>
{/* Back to site link */}
<div className="px-5 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
<div
className="px-5 py-3 border-b flex-shrink-0"
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
>
<Link
href="/"
className="text-xs transition-colors flex items-center gap-1.5 hover:text-white"
style={{ color: "var(--admin-sidebar-text)" }}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
<ArrowLeft className="w-3.5 h-3.5" aria-hidden="true" />
Back to Site
</Link>
</div>
{/* Nav links with keyboard navigation */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
<ul className="space-y-1" role="list">
{NAV_ITEMS.map((item, index) => {
// Divider with optional label
if (item.divider) {
return (
<li key={`divider-${index}`} role="separator" aria-hidden="true">
<div className="flex items-center gap-2 px-3 py-3 mt-2">
<span className="text-[10px] font-semibold uppercase tracking-widest" style={{ color: "rgba(195, 195, 193, 0.5)" }}>
{item.label}
</span>
</div>
</li>
);
}
const active = isActive(item.href!);
const icon = item.icon ? ICON_MAP[item.icon] : null;
const navIndex = NAV_ITEMS.filter(i => !i.divider).findIndex(i => i.href === item.href);
return (
<li key={item.href}>
<Link
href={item.href!}
data-nav-index={navIndex}
onClick={() => closeMobileMenu()}
onKeyDown={(e) => handleNavKeyDown(e, item.href!, navIndex)}
className={[
"group flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs font-medium transition-all duration-200",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--admin-sidebar-bg)]",
active
? "border-l-[3px]"
: "border-l-[3px] hover:border-l-[3px]",
].join(" ")}
style={active ? {
backgroundColor: "rgba(202, 117, 67, 0.15)",
color: "#dea889",
borderColor: "var(--admin-accent)",
borderLeftColor: "var(--admin-accent)"
} : {
color: "var(--admin-sidebar-text)",
borderColor: "transparent"
}}
aria-current={active ? "page" : undefined}
tabIndex={0}
>
<span
className="flex-shrink-0 transition-colors duration-200"
style={{
color: active ? "var(--admin-accent)" : "rgba(208, 203, 180, 0.6)"
}}
aria-hidden="true"
>
{icon}
</span>
<span className="flex-1">{item.label}</span>
{active && (
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0 animate-pulse"
style={{ backgroundColor: "var(--admin-accent)" }}
aria-hidden="true"
/>
)}
</Link>
</li>
);
})}
</ul>
{/* Nav groups with keyboard navigation */}
<nav
ref={mobileMenuRef}
className="flex-1 overflow-y-auto overflow-x-hidden pt-3 pb-2 scrollbar-thin"
>
{visibleGroups.map((group) => (
<SideNavGroup key={group.label} label={group.label}>
{group.items.map((item) => {
const idx = visibleItems.findIndex((v) => v.href === item.href);
return renderNavLink(item, idx);
})}
</SideNavGroup>
))}
</nav>
{/* Bottom: brand picker + role + sign out */}
{/* Bottom: command-palette tip + brand picker + role + sign out */}
<div
className="px-4 py-5 border-t flex-shrink-0 space-y-3"
className="px-4 py-4 border-t flex-shrink-0 space-y-3"
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
>
{/* Cmd+K hint — the command palette itself is mounted separately
in the admin layout; this is just a discoverability nudge. */}
<div
className="flex items-center justify-between text-[10px] uppercase tracking-widest"
style={{ color: "rgba(195, 195, 193, 0.5)" }}
aria-hidden="true"
>
<span>Tip</span>
<kbd
className="font-mono px-1.5 py-0.5 rounded border"
style={{
color: "rgba(195, 195, 193, 0.7)",
backgroundColor: "rgba(208, 203, 180, 0.1)",
borderColor: "rgba(208, 203, 180, 0.2)",
}}
>
K
</kbd>
</div>
{/* Brand selector — only show when admin has access to brands */}
{brands && brands.length > 0 && (
<BrandSelector
@@ -491,13 +487,18 @@ export default function AdminSidebar({
className="px-3 py-2.5 rounded-xl border"
style={{
backgroundColor: "rgba(208, 203, 180, 0.1)",
borderColor: "rgba(208, 203, 180, 0.2)"
borderColor: "rgba(208, 203, 180, 0.2)",
}}
>
<p className="text-[10px] font-medium uppercase tracking-widest mb-0.5" style={{ color: "rgba(195, 195, 193, 0.6)" }}>
<p
className="text-[10px] font-medium uppercase tracking-widest mb-0.5"
style={{ color: "rgba(195, 195, 193, 0.6)" }}
>
{roleLabel}
</p>
<p className="text-xs" style={{ color: "rgba(195, 195, 193, 0.8)" }}>Signed in</p>
<p className="text-xs" style={{ color: "rgba(195, 195, 193, 0.8)" }}>
Signed in
</p>
</div>
)}
<button
@@ -507,7 +508,7 @@ export default function AdminSidebar({
aria-label="Sign out of admin"
type="button"
>
<LogoutIcon />
<LogOut className="w-4 h-4" aria-hidden="true" />
Sign out
</button>
</div>
+468
View File
@@ -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 &ldquo;{query}&rdquo;
</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 &nbsp;·&nbsp; to select &nbsp;·&nbsp; esc to close
</div>
</div>
</div>
);
}
+315 -315
View File
@@ -3,7 +3,8 @@
import { useState, type ReactNode } from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge } from "@/components/admin/design-system";
import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge, KPIStat, EmptyState } from "@/components/admin/design-system";
import { ShoppingCart, MapPin, Package, DollarSign, Plus, Send, AlertCircle, Calendar, Inbox, Sparkles, BarChart3, BrainCircuit, Upload, Clock, Droplets, Route, Truck, Receipt, Settings as SettingsIcon, Store, type LucideIcon } from "lucide-react";
import type { DashboardStats } from "@/actions/dashboard";
// Lazy-load the upgrade modal — only needed when starter tier + brandId present
@@ -20,69 +21,35 @@ type Section = {
addonKey?: string;
upgradeText?: string;
prominent?: boolean;
icon: LucideIcon;
};
const sections: Section[] = [
{ title: "Orders", href: "/admin/orders", description: "View orders, pickup status, fulfillment, and customer details.", group: "operations", prominent: true },
{ title: "Products", href: "/admin/products", description: "Manage products, pricing, shipping type, and availability.", group: "operations", prominent: true },
{ title: "Tours & Stops", href: "/admin/stops", description: "Manage routes, pickup locations, dates, and cutoff times.", group: "fulfillment" },
{ title: "Driver Pickup", href: "/admin/pickup", description: "Mobile pickup lookup, QR scanning, and completion tools.", group: "fulfillment" },
{ title: "Shipping", href: "/admin/shipping", description: "FedEx integration, label creation, and shipment tracking.", group: "fulfillment" },
{ title: "Reports", href: "/admin/reports", description: "Sales, route, product, pickup, and customer reports.", group: "management" },
{ title: "Tax Dashboard", href: "/admin/taxes", description: "Sales tax collected, breakdown by state, and exportable reports.", group: "management" },
{ title: "Settings", href: "/admin/settings", description: "Users, billing, brand, integrations, payments, and shipping.", group: "management" },
{ title: "Harvest Reach", href: "/admin/communications", description: "Email campaigns, stop blast, templates, and audience segments.", group: "tools", addonKey: "harvest_reach", upgradeText: "Enable to access email & SMS marketing" },
{ title: "Wholesale Portal", href: "/admin/wholesale", description: "Standalone B2B portal with custom pricing, credit limits, and net-30.", group: "tools", addonKey: "wholesale_portal", upgradeText: "Enable to unlock B2B buyer portal" },
{ title: "Import Center", href: "/admin/import", description: "AI-powered import for products, orders, contacts, and stops.", group: "tools", addonKey: "ai_tools", upgradeText: "Enable AI import with smart column mapping" },
{ title: "AI Intelligence", href: "/admin/settings/ai", description: "Campaign writer, pricing advisor, and report explainer.", group: "tools", addonKey: "ai_tools", upgradeText: "Enable AI tools for marketing and pricing" },
{ title: "Time Tracking", href: "/admin/time-tracking", description: "Worker clock-in/out, hours tracking, and overtime management.", group: "tools", addonKey: "time_tracking", upgradeText: "Enable for field worker time tracking" },
{ title: "Water Log", href: "/admin/water-log", description: "Irrigation tracking and water usage reporting.", group: "tools", addonKey: "water_log", upgradeText: "Enable for agricultural water tracking" },
{ title: "Route Trace", href: "/admin/route-trace", description: "Lot tracking, QR stickers, hauling board, and supply chain traceability.", group: "tools", addonKey: "route_trace", upgradeText: "Enable for field-to-delivery traceability" },
{ title: "Orders", href: "/admin/orders", description: "View orders, pickup status, fulfillment, and customer details.", group: "operations", prominent: true, icon: ShoppingCart },
{ title: "Products", href: "/admin/products", description: "Manage products, pricing, shipping type, and availability.", group: "operations", prominent: true, icon: Package },
{ title: "Tours & Stops", href: "/admin/stops", description: "Manage routes, pickup locations, dates, and cutoff times.", group: "fulfillment", icon: MapPin },
{ title: "Driver Pickup", href: "/admin/pickup", description: "Mobile pickup lookup, QR scanning, and completion tools.", group: "fulfillment", icon: Truck },
{ title: "Shipping", href: "/admin/shipping", description: "FedEx integration, label creation, and shipment tracking.", group: "fulfillment", icon: Receipt },
{ title: "Reports", href: "/admin/reports", description: "Sales, route, product, pickup, and customer reports.", group: "management", icon: BarChart3 },
{ title: "Tax Dashboard", href: "/admin/taxes", description: "Sales tax collected, breakdown by state, and exportable reports.", group: "management", icon: Receipt },
{ title: "Settings", href: "/admin/settings", description: "Users, billing, brand, integrations, payments, and shipping.", group: "management", icon: SettingsIcon },
{ title: "Harvest Reach", href: "/admin/communications", description: "Email campaigns, stop blast, templates, and audience segments.", group: "tools", addonKey: "harvest_reach", upgradeText: "Enable to access email & SMS marketing", icon: Send },
{ title: "Wholesale Portal", href: "/admin/wholesale", description: "Standalone B2B portal with custom pricing, credit limits, and net-30.", group: "tools", addonKey: "wholesale_portal", upgradeText: "Enable to unlock B2B buyer portal", icon: Store },
{ title: "Import Center", href: "/admin/import", description: "AI-powered import for products, orders, contacts, and stops.", group: "tools", addonKey: "ai_tools", upgradeText: "Enable AI import with smart column mapping", icon: Upload },
{ title: "AI Intelligence", href: "/admin/settings/ai", description: "Campaign writer, pricing advisor, and report explainer.", group: "tools", addonKey: "ai_tools", upgradeText: "Enable AI tools for marketing and pricing", icon: BrainCircuit },
{ title: "Time Tracking", href: "/admin/time-tracking", description: "Worker clock-in/out, hours tracking, and overtime management.", group: "tools", addonKey: "time_tracking", upgradeText: "Enable for field worker time tracking", icon: Clock },
{ title: "Water Log", href: "/admin/water-log", description: "Irrigation tracking and water usage reporting.", group: "tools", addonKey: "water_log", upgradeText: "Enable for agricultural water tracking", icon: Droplets },
{ title: "Route Trace", href: "/admin/route-trace", description: "Lot tracking, QR stickers, hauling board, and supply chain traceability.", group: "tools", addonKey: "route_trace", upgradeText: "Enable for field-to-delivery traceability", icon: Route },
];
type Tab = "operations" | "fulfillment" | "management" | "tools";
type GroupFilter = "all" | "operations" | "fulfillment" | "management" | "tools";
const TABS: { id: Tab; label: string; icon: ReactNode }[] = [
{
id: "operations",
label: "Operations",
icon: (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
),
},
{
id: "fulfillment",
label: "Fulfillment",
icon: (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4 7.55 4.24"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
),
},
{
id: "management",
label: "Management",
icon: (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
),
},
{
id: "tools",
label: "Tools",
icon: (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
),
},
const GROUP_FILTERS: { id: GroupFilter; label: string }[] = [
{ id: "all", label: "All" },
{ id: "operations", label: "Operations" },
{ id: "fulfillment", label: "Fulfillment" },
{ id: "management", label: "Management" },
{ id: "tools", label: "Tools" },
];
type Props = {
@@ -96,6 +63,15 @@ type Props = {
stats: DashboardStats;
};
type AttentionItem = {
id: string;
title: string;
detail: string;
href: string;
tone: "primary" | "accent" | "warning" | "danger";
icon: LucideIcon;
};
export default function DashboardClient({
brandId,
brandName,
@@ -106,11 +82,8 @@ export default function DashboardClient({
limits,
stats,
}: Props) {
const [activeTab, setActiveTab] = useState<Tab>("operations");
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
// Stats are pre-fetched server-side — no loading state needed.
// All values are guaranteed to be present before this component renders.
const [groupFilter, setGroupFilter] = useState<GroupFilter>("all");
const usagePct = {
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
@@ -118,8 +91,6 @@ export default function DashboardClient({
products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0,
};
const tabSections = sections.filter((s) => s.group === activeTab);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
};
@@ -133,27 +104,77 @@ export default function DashboardClient({
return styles[status] || { bg: "var(--admin-bg)", text: "var(--admin-text-secondary)" };
};
// "What needs attention" feed — composes a flat list of items that need action
const attentionItems: AttentionItem[] = [];
if (stats.todayOrders === 0) {
attentionItems.push({
id: "no-orders-today",
title: "No orders today yet",
detail: "Send a campaign or check that your storefront is reachable.",
href: "/admin/communications/compose",
tone: "accent",
icon: Send,
});
}
if (stats.pendingStops > 0) {
attentionItems.push({
id: "pending-stops",
title: `${stats.pendingStops} stop${stats.pendingStops === 1 ? "" : "s"} pending`,
detail: "Review upcoming routes and confirm pickup locations.",
href: "/admin/stops",
tone: "primary",
icon: MapPin,
});
}
if (usagePct.products > 85) {
attentionItems.push({
id: "products-near-limit",
title: `${Math.round(usagePct.products)}% of product limit used`,
detail: "Consider archiving inactive products or upgrading your plan.",
href: "/admin/products",
tone: "warning",
icon: Package,
});
} else if (usagePct.users > 85) {
attentionItems.push({
id: "users-near-limit",
title: `${Math.round(usagePct.users)}% of user limit used`,
detail: "Add more seats or review who has access.",
href: "/admin/users",
tone: "warning",
icon: AlertCircle,
});
}
if (planTier === "starter" && brandId) {
attentionItems.push({
id: "upgrade-prompt",
title: "Unlock unlimited stops & products",
detail: "Upgrade from Starter to Farm for $149/mo.",
href: "/admin/settings/billing",
tone: "accent",
icon: Sparkles,
});
}
const quickActions = [
{ label: "New Order", href: "/admin/orders?new=true", icon: "plus" },
{ label: "Add Stop", href: "/admin/stops/new", icon: "map" },
{ label: "Add Product", href: "/admin/products/new", icon: "package" },
{ label: "Send Blast", href: "/admin/communications/compose", icon: "mail" },
{ label: "New Order", href: "/admin/orders?new=true", icon: Plus },
{ label: "Add Stop", href: "/admin/stops/new", icon: MapPin },
{ label: "Add Product", href: "/admin/products/new", icon: Package },
{ label: "Send Blast", href: "/admin/communications/compose", icon: Send },
];
const visibleSections = sections
.filter((s) => !(s.title === "Water Log" && !isWaterLogVisible))
.filter((s) => !(s.title === "Route Trace" && !enabledAddons["route_trace"]))
.filter((s) => groupFilter === "all" || s.group === groupFilter);
return (
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
{/* Page Header */}
<div className="px-4 sm:px-6 md:px-8 py-5 sm:py-6">
<PageHeader
icon={
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
}
title="Admin Dashboard"
icon={<Inbox className="h-5 w-5" />}
title="Dashboard"
subtitle={brandName}
actions={
<div className="flex items-center gap-3">
@@ -174,154 +195,157 @@ export default function DashboardClient({
{/* Main Content */}
<div className="px-4 sm:px-6 md:px-8 pb-8 space-y-5">
{/* ── Stats Cards Row ────────────────────────────────────── */}
{/* ── KPI Strip (top metrics) ──────────────────────────── */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 animate-fade-up" style={{ animationDelay: "0ms" }}>
{/* Today's Orders */}
<div className="admin-stat-card">
<div className="admin-stat-card-inner">
<div className="admin-stat-icon" style={{ backgroundColor: "var(--admin-accent-light)" }}>
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
</svg>
</div>
<div className="admin-stat-body">
<span className="admin-stat-label">Today&apos;s Orders</span>
<span className="admin-stat-value">{stats.todayOrders}</span>
</div>
</div>
</div>
{/* Today's Revenue */}
<div className="admin-stat-card">
<div className="admin-stat-card-inner">
<div className="admin-stat-icon" style={{ backgroundColor: "var(--admin-accent-soft)" }}>
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="admin-stat-body">
<span className="admin-stat-label">Today&apos;s Revenue</span>
<span className="admin-stat-value ha-num">{formatCurrency(stats.todayRevenue)}</span>
</div>
</div>
</div>
{/* Pending Stops */}
<div className="admin-stat-card">
<div className="admin-stat-card-inner">
<div className="admin-stat-icon" style={{ backgroundColor: "var(--admin-bg-subtle)" }}>
<svg className="w-4 h-4" style={{ color: "var(--admin-text-secondary)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<div className="admin-stat-body">
<span className="admin-stat-label">Pending Stops</span>
<span className="admin-stat-value">{stats.pendingStops}</span>
</div>
</div>
</div>
{/* Active Products */}
<div className="admin-stat-card">
<div className="admin-stat-card-inner">
<div className="admin-stat-icon" style={{ backgroundColor: "var(--admin-primary-soft)" }}>
<svg className="w-4 h-4" style={{ color: "var(--admin-primary)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
</div>
<div className="admin-stat-body">
<span className="admin-stat-label">Active Products</span>
<span className="admin-stat-value">{usage.products}</span>
</div>
</div>
</div>
<KPIStat
label="Today's Orders"
value={stats.todayOrders}
icon={<ShoppingCart className="w-4 h-4" />}
tone="primary"
/>
<KPIStat
label="Today's Revenue"
value={formatCurrency(stats.todayRevenue)}
icon={<DollarSign className="w-4 h-4" />}
tone="accent"
/>
<KPIStat
label="Pending Stops"
value={stats.pendingStops}
icon={<MapPin className="w-4 h-4" />}
tone="default"
/>
<KPIStat
label="Active Products"
value={usage.products}
icon={<Package className="w-4 h-4" />}
tone="primary"
/>
</div>
{/* ── Quick Actions + Usage Row ─────────────────────────── */}
{/* ── Command center: attention feed + quick actions ───── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 animate-fade-up" style={{ animationDelay: "60ms" }}>
{/* Quick Actions */}
<div className="admin-card-section">
<div className="admin-section-header">
<span className="admin-section-title">Quick Actions</span>
</div>
<div className="admin-quick-actions">
{quickActions.map((action) => (
<Link
key={action.href}
href={action.href}
className="admin-quick-action"
>
<div className="admin-quick-action-icon" style={{ backgroundColor: "var(--admin-accent-light)" }}>
{action.icon === "plus" && (
<svg className="w-3.5 h-3.5" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
)}
{action.icon === "map" && (
<svg className="w-3.5 h-3.5" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
)}
{action.icon === "package" && (
<svg className="w-3.5 h-3.5" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
)}
{action.icon === "mail" && (
<svg className="w-3.5 h-3.5" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
)}
</div>
<span className="admin-quick-action-label">{action.label}</span>
<svg className="w-3.5 h-3.5 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</Link>
))}
</div>
</div>
{/* Usage Stats */}
{/* Attention feed (left, 2 cols) */}
<div className="lg:col-span-2 admin-card-section">
<div className="admin-section-header">
<div className="flex items-center gap-2">
<AdminBadge variant={planTier === "enterprise" ? "warning" : planTier === "farm" ? "success" : "default"}>
{planTier.charAt(0).toUpperCase() + planTier.slice(1)}
</AdminBadge>
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>{brandId ? brandName : "All Brands"}</span>
</div>
<a href="/admin/settings/billing" className="text-xs font-medium hover:underline sm:hidden" style={{ color: "var(--admin-accent)" }}>
Manage
</a>
<span className="admin-section-title">What needs attention</span>
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
{attentionItems.length} item{attentionItems.length === 1 ? "" : "s"}
</span>
</div>
<div className="admin-usage-grid">
{[
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users, icon: "users" },
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops, icon: "map" },
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products, icon: "package" },
].map(({ label, value, pct }) => (
<div key={label} className="admin-usage-item">
<div className="admin-usage-header">
<span className="admin-usage-label">{label}</span>
<span className="admin-usage-value ha-num">{value}</span>
</div>
<div className="admin-usage-bar">
<div
className="admin-usage-fill"
style={{
width: `${Math.min(pct, 100)}%`,
backgroundColor: pct > 90 ? "var(--admin-danger)" : pct > 75 ? "var(--admin-warning)" : "var(--admin-accent)",
}}
/>
</div>
{pct > 85 && (
<span className="admin-usage-warning">Near limit</span>
)}
{attentionItems.length === 0 ? (
<EmptyState
icon={<Sparkles className="w-8 h-8" />}
title="All clear"
description="Nothing needs your attention right now. Enjoy the calm."
/>
) : (
<ul className="divide-y" style={{ borderColor: "var(--admin-border-light)" }}>
{attentionItems.map((item) => {
const Icon = item.icon;
const toneColor =
item.tone === "accent" ? "var(--admin-accent)" :
item.tone === "warning" ? "var(--admin-warning)" :
item.tone === "danger" ? "var(--admin-danger)" :
"var(--admin-primary)";
const toneSoft =
item.tone === "accent" ? "var(--admin-accent-soft)" :
item.tone === "warning" ? "var(--admin-warning-soft)" :
item.tone === "danger" ? "var(--admin-danger-soft)" :
"var(--admin-primary-soft)";
return (
<li key={item.id}>
<Link
href={item.href}
className="group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-black/[0.02]"
>
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: toneSoft, color: toneColor }}
aria-hidden="true"
>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>
{item.title}
</p>
<p className="text-xs mt-0.5" style={{ color: "var(--admin-text-muted)" }}>
{item.detail}
</p>
</div>
<svg className="w-4 h-4 mt-2 flex-shrink-0 opacity-30 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</li>
);
})}
</ul>
)}
</div>
{/* Quick actions (right, 1 col) */}
<div className="admin-card-section">
<div className="admin-section-header">
<span className="admin-section-title">Quick actions</span>
</div>
<div className="admin-quick-actions">
{quickActions.map((action) => {
const Icon = action.icon;
return (
<Link
key={action.href}
href={action.href}
className="admin-quick-action"
>
<div className="admin-quick-action-icon" style={{ backgroundColor: "var(--admin-primary-soft)" }}>
<Icon className="w-3.5 h-3.5" style={{ color: "var(--admin-primary)" }} />
</div>
<span className="admin-quick-action-label">{action.label}</span>
<svg className="w-3.5 h-3.5 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</Link>
);
})}
</div>
{/* Plan usage inside quick actions card */}
<div className="px-4 py-3 border-t" style={{ borderColor: "var(--admin-border-light)" }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<AdminBadge tone={planTier === "enterprise" ? "info" : planTier === "farm" ? "success" : "neutral"}>
{planTier.charAt(0).toUpperCase() + planTier.slice(1)}
</AdminBadge>
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
{brandId ? brandName : "All Brands"}
</span>
</div>
))}
</div>
<div className="space-y-2">
{[
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users },
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops },
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products },
].map(({ label, value, pct }) => (
<div key={label} className="admin-usage-item">
<div className="admin-usage-header">
<span className="admin-usage-label">{label}</span>
<span className="admin-usage-value ha-num">{value}</span>
</div>
<div className="admin-usage-bar">
<div
className="admin-usage-fill"
style={{
width: `${Math.min(pct, 100)}%`,
backgroundColor: pct > 90 ? "var(--admin-danger)" : pct > 75 ? "var(--admin-warning)" : "var(--admin-primary)",
}}
/>
</div>
</div>
))}
</div>
</div>
</div>
</div>
@@ -329,8 +353,8 @@ export default function DashboardClient({
{/* ── Recent Orders ─────────────────────────────────────── */}
<div className="admin-card-section animate-fade-up" style={{ animationDelay: "120ms" }}>
<div className="admin-section-header">
<span className="admin-section-title">Recent Orders</span>
<Link href="/admin/orders" className="text-xs font-medium hover:underline" style={{ color: "var(--admin-accent)" }}>
<span className="admin-section-title">Recent orders</span>
<Link href="/admin/orders" className="text-xs font-medium hover:underline" style={{ color: "var(--admin-primary)" }}>
View all
</Link>
</div>
@@ -346,9 +370,7 @@ export default function DashboardClient({
>
<div className="admin-order-info">
<div className="admin-order-icon">
<svg className="w-4 h-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
</svg>
<ShoppingCart className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} />
</div>
<div>
<span className="admin-order-name">{order.customer_name}</span>
@@ -366,108 +388,86 @@ export default function DashboardClient({
})}
</div>
) : (
<div className="admin-orders-empty">
<svg className="w-10 h-10 mb-2" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p className="text-sm" style={{ color: "var(--admin-text-muted)" }}>No recent orders</p>
<Link href="/admin/orders?new=true" className="text-xs font-medium hover:underline mt-1" style={{ color: "var(--admin-accent)" }}>
Create your first order
</Link>
</div>
<EmptyState
icon={<Inbox className="w-8 h-8" />}
title="No recent orders"
description="When customers place orders, they'll show up here."
action={{ label: "Create your first order", href: "/admin/orders?new=true" }}
/>
)}
</div>
{/* ── Section Navigation Tabs ───────────────────────────── */}
{/* ── Section filter + grid ────────────────────────────── */}
<div className="animate-fade-up" style={{ animationDelay: "180ms" }}>
<AdminFilterTabs
activeTab={activeTab}
onTabChange={(value) => setActiveTab(value as Tab)}
tabs={TABS.map((tab) => ({ value: tab.id, label: tab.label, icon: tab.icon }))}
size="md"
showCounts={false}
/>
</div>
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--admin-text-primary)" }}>
All sections
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--admin-text-muted)" }}>
Jump to any part of the admin. Press <kbd className="px-1.5 py-0.5 rounded text-[10px] font-mono" style={{ backgroundColor: "var(--admin-bg-subtle)", border: "1px solid var(--admin-border)" }}>K</kbd> to search.
</p>
</div>
<AdminFilterTabs
activeTab={groupFilter}
onTabChange={(value) => setGroupFilter(value as GroupFilter)}
tabs={GROUP_FILTERS.map((f) => ({ value: f.id, label: f.label }))}
size="sm"
showCounts={false}
/>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
{visibleSections.map((section) => {
const Icon = section.icon;
const isAddon = Boolean(section.addonKey);
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true;
const isProminent = section.prominent;
{/* ── Section Cards Grid ─────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3 animate-fade-up" style={{ animationDelay: "240ms" }}>
{tabSections.map((section) => {
if (section.title === "Water Log" && !isWaterLogVisible) return null;
if (section.title === "Route Trace" && !enabledAddons["route_trace"]) return null;
const isAddon = Boolean(section.addonKey);
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true;
const isProminent = section.prominent;
return (
<Link
key={section.title}
href={section.href}
className={[
"admin-section-card",
isAddon && !isEnabled ? "admin-section-card--locked" : "",
isProminent ? "admin-section-card--prominent" : "",
].filter(Boolean).join(" ")}
>
<div className="admin-section-card-top">
<div className={[
"admin-section-card-icon",
isAddon && !isEnabled ? "admin-section-card-icon--locked" : "",
isProminent ? "admin-section-card-icon--prominent" : "",
].filter(Boolean).join(" ")}>
{section.title === "Orders" && (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
</svg>
)}
{section.title === "Products" && (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
)}
{section.title === "Tours & Stops" && (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
)}
{section.title === "Driver Pickup" && (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
</svg>
)}
{!["Orders", "Products", "Tours & Stops", "Driver Pickup"].includes(section.title) && (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
)}
return (
<Link
key={section.title}
href={section.href}
className={[
"admin-section-card",
isAddon && !isEnabled ? "admin-section-card--locked" : "",
isProminent ? "admin-section-card--prominent" : "",
].filter(Boolean).join(" ")}
>
<div className="admin-section-card-top">
<div className={[
"admin-section-card-icon",
isAddon && !isEnabled ? "admin-section-card-icon--locked" : "",
isProminent ? "admin-section-card-icon--prominent" : "",
].filter(Boolean).join(" ")}>
<Icon className="w-4 h-4" />
</div>
<div className="flex items-center gap-1.5">
{isAddon && !isEnabled && <AdminBadge tone="neutral">Add-on</AdminBadge>}
{isAddon && isEnabled && <AdminBadge tone="success">Active</AdminBadge>}
{isProminent && <AdminBadge tone="info">Core</AdminBadge>}
</div>
</div>
<div className="flex items-center gap-1.5">
{isAddon && !isEnabled && <AdminBadge variant="warning">Add-on</AdminBadge>}
{isAddon && isEnabled && <AdminBadge variant="success">Active</AdminBadge>}
{isProminent && <AdminBadge variant="success">Core</AdminBadge>}
<div className="admin-section-card-body">
<h3 className="admin-section-card-title">{section.title}</h3>
<p className={["admin-section-card-desc", isAddon && !isEnabled ? "" : ""].filter(Boolean).join(" ")}>
{section.description}
</p>
</div>
</div>
<div className="admin-section-card-body">
<h3 className="admin-section-card-title">{section.title}</h3>
<p className={["admin-section-card-desc", isAddon && !isEnabled ? "text-stone-400" : ""].filter(Boolean).join(" ")}>
{section.description}
</p>
</div>
{isAddon && !isEnabled && section.upgradeText && (
<p className="admin-section-card-hint">{section.upgradeText}</p>
)}
{isAddon && !isEnabled && section.upgradeText && (
<p className="admin-section-card-hint">{section.upgradeText}</p>
)}
<div className="admin-section-card-arrow">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</div>
</Link>
);
})}
<div className="admin-section-card-arrow">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</div>
</Link>
);
})}
</div>
</div>
</div>
+2 -2
View File
@@ -185,8 +185,8 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
{error && (
<div
role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-red-700"
style={{ background: "rgba(220, 38, 38, 0.06)", border: "1px solid rgba(220, 38, 38, 0.15)" }}
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{ background: "var(--admin-danger-soft)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)" }}
>
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" />
+107
View File
@@ -0,0 +1,107 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { Inbox } from "lucide-react";
type EmptyStateAction = {
/** Visible label for the call-to-action. */
label: string;
/** If provided, renders a Next.js Link. Mutually exclusive with onClick. */
href?: string;
/** If provided, renders a button. */
onClick?: () => void;
};
type EmptyStateProps = {
/** Optional icon element (typically a lucide-react icon). */
icon?: ReactNode;
/** Required heading explaining the empty state. */
title: string;
/** Optional supporting copy shown below the title. */
description?: string;
/** Optional single primary action. */
action?: EmptyStateAction;
/** Extra class names appended to the root. */
className?: string;
};
/**
* EmptyState — generic empty state for admin pages.
*
* Centers an icon + title + description and (optionally) a single primary CTA
* styled with `.ha-btn-primary`. Uses the new admin design tokens for
* colors and Fraunces for the title.
*/
export default function EmptyState({
icon,
title,
description,
action,
className = "",
}: EmptyStateProps) {
return (
<div
className={`flex flex-col items-center justify-center text-center ${className}`.trim()}
style={{ padding: "3rem 1.5rem" }}
>
<div
className="mb-4"
style={{ color: "var(--admin-text-muted)" }}
aria-hidden="true"
>
{icon ?? <DefaultEmptyIcon />}
</div>
<h3
className="ha-display"
style={{
fontSize: "1.125rem",
color: "var(--admin-text-primary)",
}}
>
{title}
</h3>
{description && (
<p
className="mt-2 max-w-sm text-sm leading-relaxed"
style={{ color: "var(--admin-text-muted)" }}
>
{description}
</p>
)}
{action && (
<div className="mt-6">
<EmptyStateActionButton action={action} />
</div>
)}
</div>
);
}
function DefaultEmptyIcon() {
return <Inbox className="h-9 w-9" strokeWidth={1.25} />;
}
function EmptyStateActionButton({ action }: { action: EmptyStateAction }) {
// Pick Link vs button based on whether an href is provided. Both render
// with the same .ha-btn-primary class so the CTA is visually consistent.
if (action.href) {
return (
<Link href={action.href} className="ha-btn-primary">
{action.label}
</Link>
);
}
return (
<button
type="button"
onClick={action.onClick}
className="ha-btn-primary"
>
{action.label}
</button>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import type { ReactNode } from "react";
import { ArrowDown, ArrowRight, ArrowUp } from "lucide-react";
type KPITone = "default" | "primary" | "accent" | "warning" | "danger";
type KPIStatProps = {
/** Eyebrow label above the value (e.g. "Today's Orders") */
label: string;
/** The primary value to display. Accepts a node so callers can pass formatted strings, JSX, etc. */
value: ReactNode;
/** Optional icon element (typically a lucide-react icon) shown in the leading tile. */
icon?: ReactNode;
/** Controls the icon tile's background + icon color. Defaults to "default". */
tone?: KPITone;
/** Optional trend indicator rendered next to the value. */
trend?: { value: string; direction: "up" | "down" | "flat" };
/** Extra class names appended to the card. */
className?: string;
};
// Tone -> icon-tile background / icon color. Pulled from --admin-* tokens
// introduced in the Phase 1 palette unification. No hardcoded hex values.
const toneStyles: Record<KPITone, { bg: string; fg: string }> = {
default: { bg: "var(--admin-bg-subtle)", fg: "var(--admin-text-secondary)" },
primary: { bg: "var(--admin-primary-soft)", fg: "var(--admin-primary)" },
accent: { bg: "var(--admin-accent-soft)", fg: "var(--admin-accent)" },
warning: { bg: "var(--admin-warning-soft)", fg: "var(--admin-warning)" },
danger: { bg: "var(--admin-danger-soft)", fg: "var(--admin-danger)" },
};
// Trend direction -> icon + color. "up" reads as positive (primary),
// "down" as negative (danger), "flat" as neutral.
const trendStyles: Record<"up" | "down" | "flat", { Icon: typeof ArrowUp; color: string }> = {
up: { Icon: ArrowUp, color: "var(--admin-primary)" },
down: { Icon: ArrowDown, color: "var(--admin-danger)" },
flat: { Icon: ArrowRight, color: "var(--admin-text-muted)" },
};
/**
* KPIStat — compact metric card for admin surfaces.
*
* Extracted from the inline pattern previously duplicated four times in
* `DashboardClient.tsx`. Reuses the `.admin-stat-card*` classes from
* `admin-design-system.css` for the shell, then applies tone-aware
* background/foreground via inline `var(--admin-*)` styles.
*/
export default function KPIStat({
label,
value,
icon,
tone = "default",
trend,
className = "",
}: KPIStatProps) {
const { bg, fg } = toneStyles[tone];
return (
<div className={`admin-stat-card ${className}`.trim()}>
<div className="admin-stat-card-inner">
{icon && (
<div
className="admin-stat-icon"
style={{ backgroundColor: bg, color: fg }}
>
{icon}
</div>
)}
<div className="admin-stat-body">
<span className="admin-stat-label">{label}</span>
<div className="flex items-baseline gap-2">
<span className="admin-stat-value">{value}</span>
{trend && <TrendIndicator trend={trend} />}
</div>
</div>
</div>
</div>
);
}
function TrendIndicator({
trend,
}: {
trend: { value: string; direction: "up" | "down" | "flat" };
}) {
const { Icon, color } = trendStyles[trend.direction];
return (
<span
className="inline-flex items-center gap-0.5 text-[0.6875rem] font-semibold"
style={{ color }}
>
<Icon className="h-3 w-3" strokeWidth={2.25} aria-hidden="true" />
{trend.value}
</span>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
type LoadingStateProps = {
/** Optional eyebrow text rendered above the skeleton (e.g. "Loading orders..."). */
label?: string;
/** Number of skeleton rows to show. Defaults to 3. */
rows?: number;
/** Extra class names appended to the wrapper. */
className?: string;
};
/**
* LoadingState — generic skeleton for admin list/card surfaces.
*
* Renders an optional centered "Loading…" eyebrow above `rows` skeleton
* blocks, each composed of a label-sized bar and a value-sized bar. The
* shimmer comes from the shared `.ha-skeleton` class in
* `admin-design-system.css` so motion / reduced-motion stay consistent
* with the rest of the admin shell.
*/
export default function LoadingState({
label,
rows = 3,
className = "",
}: LoadingStateProps) {
const safeRows = Math.max(1, Math.floor(rows));
return (
<div
className={`flex flex-col items-stretch ${className}`.trim()}
style={{ padding: "1.5rem 1rem" }}
role="status"
aria-live="polite"
aria-busy="true"
>
{label && (
<span
className="ha-eyebrow mx-auto mb-4"
aria-label={label}
>
{label}
</span>
)}
<div className="space-y-3">
{Array.from({ length: safeRows }).map((_, i) => (
// Index is fine here — the list shape is stable per render.
// eslint-disable-next-line react/no-array-index-key
<SkeletonRow key={i} />
))}
</div>
</div>
);
}
function SkeletonRow() {
return (
<div
className="flex items-center gap-3 rounded-md border p-3"
style={{
backgroundColor: "var(--admin-card-bg)",
borderColor: "var(--admin-border)",
}}
>
{/* Icon-tile placeholder */}
<span
className="ha-skeleton block flex-shrink-0"
style={{ width: "2.25rem", height: "2.25rem", borderRadius: "0.5rem" }}
/>
{/* Label + value placeholders */}
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<span
className="ha-skeleton block"
style={{ width: "40%", height: "0.5rem", borderRadius: "0.25rem" }}
/>
<span
className="ha-skeleton block"
style={{ width: "65%", height: "0.75rem", borderRadius: "0.25rem" }}
/>
</div>
</div>
);
}
+17 -16
View File
@@ -132,7 +132,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
return (
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
<div className="rounded-xl bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] p-4 text-[var(--admin-danger)] text-sm">
{error}
</div>
)}
@@ -182,7 +182,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
<AdminInput label="Brand" required>
{lockBrand ? (
// brand_admin / store_employee — brand is fixed by their admin_users record
<div className="w-full rounded-lg border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm text-[var(--admin-text-primary)]">
<div className="w-full rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)]">
{brandOptions.find((b) => b.id === brandId)?.name ?? brandId}
</div>
) : (
@@ -197,7 +197,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
/>
)}
{!lockBrand && brands.length === 0 && (
<p className="mt-1 text-xs text-stone-500">
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Loading available brands if this persists, check the admin Brands settings.
</p>
)}
@@ -237,8 +237,8 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300">Product Image</label>
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<label className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div
onDragOver={(e) => e.preventDefault()}
@@ -250,28 +250,28 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
onClick={() => fileInputRef.current?.click()}
className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${uploadError ? "border-red-400" : "border-zinc-600 hover:border-slate-400 hover:bg-zinc-900"}
${uploadError ? "border-[var(--admin-danger)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" />
<span className="text-sm text-zinc-500">Uploading...</span>
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
<span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
</>
) : imagePreview ? (
<>
<span className="relative inline-block h-32 w-auto">
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</span>
<span className="text-xs text-zinc-500">Click or drop to replace</span>
<span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
</>
) : (
<>
<svg className="h-8 w-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm text-zinc-500">Drag & drop or click to upload</span>
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
</>
)}
<input
@@ -286,31 +286,32 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
/>
</div>
{uploadError && <p className="mt-1 text-xs text-red-400">{uploadError}</p>}
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
{imagePreview && (
<button
type="button"
onClick={() => { setImagePreview(null); setPendingImageUrl(""); }}
className="mt-2 text-xs text-red-500 hover:underline"
className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
>
Remove image
</button>
)}
</div>
<div className="flex gap-3">
{/* Save button bar — new design tokens */}
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
<button
type="submit"
disabled={loading}
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white disabled:opacity-50"
className="ha-btn-primary"
>
{loading ? "Creating..." : "Create Product"}
</button>
<Link
href="/admin/products"
className="rounded-xl border border-zinc-600 px-6 py-3 font-medium text-zinc-300"
className="ha-btn-ghost"
>
Cancel
</Link>
+117 -44
View File
@@ -198,8 +198,22 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<div
className="rounded-xl border p-4 text-sm flex items-start gap-3"
style={{
borderColor: "var(--admin-danger-soft)",
backgroundColor: "var(--admin-danger-soft)",
color: "var(--admin-danger)",
}}
>
<svg
className="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
style={{ color: "var(--admin-danger)" }}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
@@ -208,11 +222,17 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Order items */}
<div>
<p className="mb-3 text-sm font-semibold text-stone-700">
<p className="ha-field-label mb-3">
Order Items ({visibleItems.length})
</p>
{visibleItems.length === 0 ? (
<p className="rounded-xl border border-dashed border-stone-300 p-4 text-center text-sm text-stone-400">
<p
className="rounded-xl border border-dashed p-4 text-center text-sm"
style={{
borderColor: "var(--admin-border-strong)",
color: "var(--admin-text-muted)",
}}
>
No items
</p>
) : (
@@ -220,13 +240,23 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{visibleItems.map((item) => (
<div
key={item.id}
className="rounded-xl border border-stone-200 bg-white p-4"
className="rounded-xl border p-4"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div className="flex items-start justify-between gap-3">
<p className="font-medium text-stone-900">{item.productName}</p>
<p
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{item.productName}
</p>
<button
onClick={() => removeItem(item.id)}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium"
style={{ color: "var(--admin-danger)" }}
>
Remove
</button>
@@ -234,7 +264,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-500 mb-1">Qty</label>
<label className="ha-field-label mb-1">Qty</label>
<input
type="number"
min="1"
@@ -242,13 +272,18 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
onChange={(e) =>
updateItem(item.id, "quantity", Number(e.target.value))
}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
className="ha-field-input ha-field-input-mono"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-500 mb-1">Price</label>
<label className="ha-field-label mb-1">Price</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
<span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
$
</span>
<input
type="number"
step="0.01"
@@ -257,13 +292,16 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
onChange={(e) =>
updateItem(item.id, "price", Number(e.target.value))
}
className="w-full rounded-xl border border-stone-200 bg-stone-50 pl-8 pr-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
className="ha-field-input ha-field-input-mono pl-8 pr-3"
/>
</div>
</div>
</div>
<p className="mt-2 text-right text-sm font-semibold text-stone-900">
<p
className="mt-2 text-right text-sm font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(Number(item.price) * item.quantity)}
</p>
</div>
@@ -280,48 +318,71 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
step="0.01"
value={subtotal}
readOnly
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm text-stone-500"
className="ha-field-input ha-field-input-mono"
style={{ color: "var(--admin-text-muted)" }}
/>
</AdminInput>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-stone-500 mb-1.5">Discount Amount</label>
<label className="ha-field-label mb-1.5">Discount Amount</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
<span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
$
</span>
<input
type="number"
step="0.01"
min="0"
value={discount_amount}
onChange={(e) => setDiscount_amount(Number(e.target.value))}
className={`w-full rounded-xl border px-8 pr-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
className="ha-field-input ha-field-input-mono pl-8 pr-3"
style={
fieldErrors.discount_amount
? "border-red-400 bg-red-50"
: "border-stone-200 bg-white focus:border-[var(--admin-accent)]"
}`}
? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
: undefined
}
/>
</div>
{fieldErrors.discount_amount && (
<p className="mt-1 text-xs text-red-500">{fieldErrors.discount_amount}</p>
<p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
{fieldErrors.discount_amount}
</p>
)}
</div>
<div>
<label className="block text-xs font-semibold text-stone-500 mb-1.5">Discount Reason</label>
<label className="ha-field-label mb-1.5">Discount Reason</label>
<input
type="text"
value={discount_reason}
onChange={(e) => setDiscount_reason(e.target.value)}
placeholder="Optional"
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
className="ha-field-input"
/>
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div
className="rounded-xl border p-4"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-bg-subtle)",
}}
>
<div className="flex justify-between">
<span className="text-sm font-medium text-stone-600">Total</span>
<span className="text-xl font-bold text-stone-900">
<span
className="text-sm font-medium"
style={{ color: "var(--admin-text-secondary)" }}
>
Total
</span>
<span
className="text-xl font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(subtotal + Number(order.tax_amount ?? 0) - discount_amount)}
</span>
</div>
@@ -331,8 +392,9 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Customer fields */}
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold text-stone-500 mb-1.5">
Name <span className="text-red-500">*</span>
<label className="ha-field-label mb-1.5">
<span>Name</span>
<span className="ha-field-label-required">*</span>
</label>
<input
type="text"
@@ -345,14 +407,17 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
return next;
});
}}
className={`w-full rounded-xl border px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
className="ha-field-input"
style={
fieldErrors.customer_name
? "border-red-400 bg-red-50"
: "border-stone-200 bg-white focus:border-[var(--admin-accent)]"
}`}
? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
: undefined
}
/>
{fieldErrors.customer_name && (
<p className="mt-1 text-xs text-red-500">{fieldErrors.customer_name}</p>
<p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
{fieldErrors.customer_name}
</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -376,17 +441,15 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Status & pickup */}
<div className="space-y-4">
<div>
<label className="mb-2 block text-xs font-semibold text-stone-500">Status</label>
<div className="flex gap-2">
<label className="ha-field-label mb-2">Status</label>
<div className="ha-segment">
{["pending", "confirmed", "cancelled"].map((s) => (
<button
key={s}
type="button"
onClick={() => setStatus(s)}
className={`flex-1 rounded-xl px-4 py-2.5 text-sm font-semibold capitalize transition-colors ${
status === s
? "bg-[var(--admin-accent)] text-white"
: "bg-white text-stone-600 border border-stone-200 hover:bg-stone-50"
className={`ha-segment-btn capitalize ${
status === s ? "ha-segment-btn--active" : ""
}`}
>
{s}
@@ -396,15 +459,24 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
</div>
<div>
<label className="mb-2 block text-xs font-semibold text-stone-500">Pickup</label>
<label className="ha-field-label mb-2">Pickup</label>
<button
type="button"
onClick={() => setPickup_complete((v) => !v)}
className={`w-full rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors ${
className="ha-segment-btn w-full justify-center"
style={
pickup_complete
? "bg-green-100 text-green-700 border border-green-200"
: "bg-amber-100 text-amber-700 border border-amber-200"
}`}
? {
backgroundColor: "var(--admin-primary-soft)",
color: "var(--admin-primary)",
border: "1px solid var(--admin-primary)",
}
: {
backgroundColor: "var(--admin-warning-soft)",
color: "var(--admin-warning)",
border: "1px solid var(--admin-warning)",
}
}
>
{pickup_complete ? "✓ Picked Up" : "○ Not Picked Up"}
</button>
@@ -428,6 +500,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
variant="primary"
fullWidth
size="lg"
className="ha-btn-primary"
>
{saving ? "Saving..." : "Save Changes"}
</AdminButton>
+49 -28
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { formatDate } from "@/lib/format-date";
import AdminBadge from "./design-system/AdminBadge";
import { toggleOrderPickupComplete } from "@/actions/orders";
type Order = {
@@ -15,6 +16,16 @@ type Order = {
created_at: string;
};
type OrderStatusTone = "warning" | "info" | "success" | "danger" | "neutral";
const statusToTone: Record<string, OrderStatusTone> = {
pending: "warning",
confirmed: "info",
paid: "success",
cancelled: "danger",
completed: "neutral",
};
export default function OrderTableBody({ orders }: { orders: Order[] }) {
const [pickupToggles, setPickupToggles] = useState<Record<string, boolean>>(
() => Object.fromEntries(orders.map((o) => [o.id, o.pickup_complete]))
@@ -33,68 +44,78 @@ export default function OrderTableBody({ orders }: { orders: Order[] }) {
}
}
const statusColors: Record<string, string> = {
pending: "bg-yellow-100 text-yellow-700",
confirmed: "bg-blue-900/40 text-blue-700",
paid: "bg-green-900/40 text-green-400",
cancelled: "bg-red-900/40 text-red-400",
completed: "bg-zinc-950 text-zinc-400",
};
return (
<tbody className="divide-y divide-slate-200">
<tbody style={{ borderColor: "var(--admin-border)" }}>
{error && (
<tr>
<td colSpan={6} className="px-3 py-2 text-sm text-red-400">
<td
colSpan={6}
className="px-3 py-2 text-sm"
style={{ color: "var(--admin-danger)" }}
>
{error}
</td>
</tr>
)}
{orders.map((order) => (
<tr key={order.id} className="hover:bg-zinc-800">
<tr
key={order.id}
className="transition-colors"
style={{ borderTop: "1px solid var(--admin-border-light)" }}
>
<td className="px-3 py-2">
<span className="font-mono text-sm text-zinc-500">
<span
className="font-mono text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{order.id.slice(0, 8)}
</span>
</td>
<td className="px-3 py-2">
<div className="font-medium text-zinc-100">
<div
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_name}
</div>
<div className="text-sm text-zinc-500">
<div
className="text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{order.customer_email}
</div>
</td>
<td className="px-3 py-2">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
statusColors[order.status] ?? "bg-zinc-950 text-zinc-400"
}`}
>
<AdminBadge tone={statusToTone[order.status] ?? "neutral"}>
{order.status ?? "pending"}
</span>
</AdminBadge>
</td>
<td className="px-3 py-2 font-semibold text-zinc-100">
<td
className="px-3 py-2 font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
${Number(order.subtotal).toFixed(2)}
</td>
<td className="px-3 py-2">
<button
type="button"
onClick={() => togglePickup(order.id, pickupToggles[order.id])}
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
pickupToggles[order.id]
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-500"
}`}
className="transition-opacity hover:opacity-80"
>
{pickupToggles[order.id] ? "Picked up" : "Pending"}
<AdminBadge tone={pickupToggles[order.id] ? "success" : "neutral"} dot>
{pickupToggles[order.id] ? "Picked up" : "Pending"}
</AdminBadge>
</button>
</td>
<td className="px-3 py-2 text-sm text-zinc-500">
<td
className="px-3 py-2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{formatDate(new Date(order.created_at))}
</td>
</tr>
+36 -26
View File
@@ -1,6 +1,7 @@
"use client";
import NextImage from "next/image";
import Link from "next/link";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { uploadProductImage, deleteProductImage } from "@/actions/products/upload-image";
@@ -147,13 +148,13 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
<div className="rounded-xl bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] p-4 text-sm text-[var(--admin-danger)]">
{error}
</div>
)}
{saved && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
<div className="rounded-xl bg-[var(--admin-primary-soft)] border border-[var(--admin-primary)] p-4 text-sm text-[var(--admin-primary)]">
Product updated successfully.
</div>
)}
@@ -205,13 +206,13 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
</AdminInput>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
<label className="mb-2 block text-sm font-medium text-[var(--admin-text-primary)]">Status</label>
<button
onClick={() => setActive((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
active
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
? "bg-[var(--admin-primary-soft)] text-[var(--admin-primary)] ring-1 ring-[var(--admin-primary)]"
: "bg-[var(--admin-bg)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
{active ? "Active" : "Inactive"}
@@ -219,19 +220,19 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Taxable</label>
<label className="mb-2 block text-sm font-medium text-[var(--admin-text-primary)]">Taxable</label>
<button
onClick={() => setIs_taxable((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center gap-3 ${
is_taxable
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200"
: "bg-amber-900/30 text-amber-700 ring-1 ring-amber-200"
? "bg-[var(--admin-primary-soft)] text-[var(--admin-primary)] ring-1 ring-[var(--admin-primary)]"
: "bg-[var(--admin-accent-soft)] text-[var(--admin-accent)] ring-1 ring-[var(--admin-accent)]"
}`}
>
<span className="text-lg">{is_taxable ? "✓" : "✗"}</span>
<span>{is_taxable ? "Taxable — tax applied at checkout for nexus shipments" : "Non-taxable — always exempt from sales tax"}</span>
</button>
<p className="mt-1.5 text-xs text-zinc-500">Tax is calculated at checkout for shipping orders in your brand&apos;s nexus states. Non-taxable items (e.g. cooler boxes, apparel) are always exempt.</p>
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">Tax is calculated at checkout for shipping orders in your brand&apos;s nexus states. Non-taxable items (e.g. cooler boxes, apparel) are always exempt.</p>
</div>
<AdminInput
@@ -249,8 +250,8 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
</AdminInput>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Product Image</label>
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<label className="mb-1 block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div
onDragOver={(e) => e.preventDefault()}
@@ -262,28 +263,28 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
onClick={() => fileInputRef.current?.click()}
className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${dragOver ? "border-green-500 bg-green-900/30" : "border-zinc-600 hover:border-slate-400 hover:bg-zinc-900"}
${dragOver ? "border-[var(--admin-primary)] bg-[var(--admin-primary-soft)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" />
<span className="text-sm text-zinc-500">Uploading...</span>
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
<span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
</>
) : imagePreview ? (
<>
<span className="relative inline-block h-32 w-auto">
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</span>
<span className="text-xs text-zinc-500">Click or drop to replace</span>
<span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
</>
) : (
<>
<svg className="h-8 w-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm text-zinc-500">Drag & drop or click to upload</span>
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
</>
)}
<input
@@ -298,26 +299,35 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
/>
</div>
{uploadError && <p className="mt-1 text-xs text-red-400">{uploadError}</p>}
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
{imagePreview && (
<button
type="button"
onClick={handleRemoveImage}
className="mt-2 text-xs text-red-500 hover:underline"
className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
>
Remove image
</button>
)}
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
{/* Save button bar — new design tokens */}
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
<button
onClick={handleSave}
disabled={saving}
className="ha-btn-primary"
>
{saving ? "Saving..." : "Save Changes"}
</button>
<Link
href="/admin/products"
className="ha-btn-ghost"
>
Cancel
</Link>
</div>
</div>
);
}
+9 -7
View File
@@ -247,7 +247,7 @@ export default function ProductFormModal({
if (e.target === e.currentTarget) onClose();
}}
style={{
backgroundColor: "rgba(28, 25, 23, 0.55)",
backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 55%, transparent)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
}}
@@ -256,7 +256,8 @@ export default function ProductFormModal({
role="dialog"
aria-modal="true"
aria-label={mode === "add" ? "Add product" : "Edit product"}
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl shadow-[0_30px_80px_-20px_rgba(28,25,23,0.45)] flex flex-col overflow-hidden border border-stone-200/60"
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl flex flex-col overflow-hidden border border-[var(--admin-border)]"
style={{ boxShadow: "0 30px 80px -20px color-mix(in srgb, var(--admin-text-primary) 45%, transparent)" }}
>
{/* grain overlay */}
<div className="atelier-grain" aria-hidden />
@@ -357,7 +358,7 @@ export default function ProductFormModal({
{uploading ? (
<div className="flex flex-col items-center gap-3">
<div className="h-6 w-6 rounded-full border-2 border-stone-300 border-t-[#224E2F] animate-spin" />
<div className="h-6 w-6 rounded-full border-2 border-stone-300 border-t-[var(--admin-primary)] animate-spin" />
<p className="atelier-drop-title">Pouring into the cellar</p>
</div>
) : imagePreview ? (
@@ -390,7 +391,7 @@ export default function ProductFormModal({
<>
{/* ornamental drop zone content */}
<svg
className="h-10 w-10 text-[#224E2F]/70"
className="h-10 w-10 text-[var(--admin-primary)]/70"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -425,7 +426,7 @@ export default function ProductFormModal({
</div>
{uploadError && (
<p className="mt-3 atelier-hint" style={{ color: "#B91C1C" }}>
<p className="mt-3 atelier-hint" style={{ color: "var(--admin-danger)" }}>
{uploadError}
</p>
)}
@@ -668,7 +669,7 @@ export default function ProductFormModal({
</form>
{/* FOOTER */}
<div className="relative shrink-0 px-6 sm:px-10 pt-4 pb-6 sm:pb-7 border-t border-stone-200/60 bg-[#FAF7F0]/80 backdrop-blur-sm">
<div className="relative shrink-0 px-6 sm:px-10 pt-4 pb-6 sm:pb-7 border-t border-[var(--admin-border)] bg-[var(--admin-bg)]/80 backdrop-blur-sm">
<div className="flex items-center justify-between gap-4">
<button
type="button"
@@ -685,9 +686,10 @@ export default function ProductFormModal({
className={[
"h-1.5 w-1.5 rounded-full",
name.trim() && price
? "bg-emerald-600 shadow-[0_0_0_3px_rgba(22,163,74,0.15)]"
? "bg-[var(--admin-primary)]"
: "bg-stone-300",
].join(" ")}
style={name.trim() && price ? { boxShadow: "0 0 0 3px var(--admin-primary-soft)" } : undefined}
/>
{name.trim() && price ? "Ready" : "Required fields pending"}
</div>
+32 -28
View File
@@ -68,35 +68,35 @@ export default function ProductTableBody({
return (
<>
{/* Filter bar — sits outside <table> in the parent */}
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center">
<div className="border-b border-[var(--admin-border-light)] px-5 py-3 flex gap-3 flex-wrap items-center">
<input
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="flex-1 min-w-40 rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-slate-900"
className="flex-1 min-w-40 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-primary)]"
/>
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1">
<div className="flex gap-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-slate-900 text-white"
: "text-zinc-400 hover:bg-zinc-950"
? "bg-[var(--admin-primary)] text-white"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-xs text-slate-400">{filtered.length}</span>
<span className="text-xs text-[var(--admin-text-muted)]">{filtered.length}</span>
</div>
{/* Delete error banner */}
{deleteError && (
<div className="mx-5 mt-3 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
<div className="mx-5 mt-3 rounded-lg bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError}
<button
onClick={() => setDeleteError(null)}
@@ -107,10 +107,10 @@ export default function ProductTableBody({
</div>
)}
<tbody className="divide-y divide-slate-200">
<tbody className="divide-y divide-[var(--admin-border-light)]">
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-sm text-zinc-500">
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
{search || statusFilter !== "all"
? "No products match your search."
: "No products found."}
@@ -120,43 +120,46 @@ export default function ProductTableBody({
filtered.map((product) => (
<tr
key={product.id}
className="hover:bg-zinc-800 transition-colors relative"
className="hover:bg-[var(--admin-primary-soft)] transition-colors relative"
>
<td className="px-3 py-2">
<Link
href={`/admin/products/${product.id}`}
className="block font-medium text-zinc-100 hover:text-zinc-400"
className="block font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-primary)]"
>
{product.name}
</Link>
<div className="text-zinc-500 line-clamp-1 text-sm">
{product.description || <span className="italic text-slate-400">No description</span>}
<div className="text-[var(--admin-text-muted)] line-clamp-1 text-sm">
{product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
</div>
</td>
<td className="px-3 py-2 text-zinc-300">
<td className="px-3 py-2 text-[var(--admin-text-secondary)]">
{Array.isArray(product.brands)
? product.brands[0]?.name
: product.brands?.name}
</td>
<td className="px-3 py-2 text-zinc-300">{product.type}</td>
<td className="px-3 py-2 text-[var(--admin-text-secondary)]">{product.type}</td>
<td className="px-3 py-2 font-semibold text-zinc-100">
<td
className="px-3 py-2 font-semibold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)}
</td>
<td className="px-3 py-2">
<AdminBadge variant={product.active ? "success" : "default"} dot>
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"}
</AdminBadge>
</td>
<td className="px-3 py-2">
{product.is_taxable === false ? (
<AdminBadge variant="warning">Non-taxable</AdminBadge>
<AdminBadge tone="warning">Non-taxable</AdminBadge>
) : (
<AdminBadge variant="success">Taxable</AdminBadge>
<AdminBadge tone="success">Taxable</AdminBadge>
)}
</td>
@@ -165,7 +168,7 @@ export default function ProductTableBody({
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/products/${product.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-950"
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
>
Edit
</Link>
@@ -174,7 +177,7 @@ export default function ProductTableBody({
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-zinc-500 hover:bg-zinc-950"
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
@@ -187,10 +190,10 @@ export default function ProductTableBody({
className="fixed inset-0 z-10"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-zinc-900 shadow-lg ring-1 ring-zinc-700 overflow-hidden">
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-[var(--admin-card-bg)] shadow-lg ring-1 ring-[var(--admin-border)] overflow-hidden">
<button
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-400 hover:bg-red-900/30"
className="w-full text-left px-4 py-2.5 text-sm text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)]"
>
Delete
</button>
@@ -201,21 +204,22 @@ export default function ProductTableBody({
{confirmDelete === product.id && (
<>
<div
className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm"
className="fixed inset-0 z-30 backdrop-blur-sm"
style={{ backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 60%, transparent)" }}
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-zinc-900 shadow-xl ring-1 ring-zinc-700 p-6">
<p className="text-sm font-semibold text-zinc-100">
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-[var(--admin-card-bg)] shadow-xl ring-1 ring-[var(--admin-border)] p-6">
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-2 text-xs text-zinc-500">
<p className="mt-2 text-xs text-[var(--admin-text-muted)]">
This will remove the product. If it is attached to any
orders, it will be hidden instead of deleted.
</p>
<div className="mt-5 flex gap-3">
<button
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-zinc-600 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
>
Cancel
</button>
+97 -101
View File
@@ -9,7 +9,8 @@ import { updateProduct } from "@/actions/products/update-product";
import { deleteProduct } from "@/actions/products";
import { uploadProductImage } from "@/actions/products/upload-image";
import ProductFormModal, { type ProductFormValues } from "@/components/admin/ProductFormModal";
import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, useToast, Skeleton } from "@/components/admin/design-system";
import { PageHeader, AdminButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, AdminBadge, EmptyState, useToast, Skeleton } from "@/components/admin/design-system";
import { Package as PackageIconLucide, Inbox as InboxIcon } from "lucide-react";
type Product = {
id: string;
@@ -29,44 +30,13 @@ type ViewMode = "table" | "cards";
// Icons
const Icons = {
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14"/>
</svg>
),
package: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m7.5 4.27 9 5.15"/>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
<path d="m3.3 7 8.7 5 8.7-5"/>
<path d="M12 22V12"/>
</svg>
),
upload: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
),
};
// Page header icon
const PackageIconHeader = () => (
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m7.5 4.27 9 5.15"/>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
<path d="m3.3 7 8.7 5 8.7-5"/>
<path d="M12 22V12"/>
</svg>
);
export default function ProductsClient({
products,
brandId,
@@ -253,34 +223,50 @@ export default function ProductsClient({
return (
<div className="p-4 sm:p-6">
{/* Page Header */}
{/* Page Header — eyebrow + title + subtitle + primary CTA */}
<div className="ha-eyebrow mb-2">Operations</div>
<PageHeader
icon={<PackageIconHeader />}
icon={<PackageIconLucide className="h-5 w-5" strokeWidth={1.75} />}
title="Products"
subtitle={`${filtered.length} product${filtered.length !== 1 ? "s" : ""}`}
subtitle="Manage your product catalog, pricing, and availability."
actions={
<AdminButton
onClick={openAddModal}
icon={Icons.plus("h-4 w-4")}
>
Add Product
Add product
</AdminButton>
}
/>
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : products.length}</p>
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Total</p>
<p
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : products.length}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1">{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : activeCount}</p>
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Active</p>
<p
className="text-xl sm:text-2xl font-bold text-[var(--admin-primary)] mt-1"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : activeCount}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Inactive</p>
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1">{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : inactiveCount}</p>
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Inactive</p>
<p
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-muted)] mt-1"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : inactiveCount}
</p>
</div>
</div>
@@ -320,6 +306,7 @@ export default function ProductsClient({
onDeleteConfirm={(id) => handleDelete(id)}
onDeleteCancel={() => setDeleteConfirm(null)}
deletingId={deletingId}
onAdd={openAddModal}
/>
) : (
<CardView
@@ -330,6 +317,7 @@ export default function ProductsClient({
onDeleteConfirm={(id) => handleDelete(id)}
onDeleteCancel={() => setDeleteConfirm(null)}
deletingId={deletingId}
onAdd={openAddModal}
/>
)}
@@ -375,6 +363,7 @@ function TableView({
onDeleteConfirm,
onDeleteCancel,
deletingId,
onAdd,
}: {
products: Product[];
onEdit: (p: Product) => void;
@@ -383,6 +372,7 @@ function TableView({
onDeleteConfirm: (id: string) => void;
onDeleteCancel: () => void;
deletingId: string | null;
onAdd: () => void;
}) {
// Track the position of the open row's three-dots button so the popup can be
// rendered via portal at body level (escapes any overflow:hidden ancestors
@@ -434,31 +424,32 @@ function TableView({
const openProduct = deleteConfirm ? products.find((p) => p.id === deleteConfirm) : null;
return (
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-white">
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)]">
<table className="w-full text-sm">
<thead className="bg-stone-50">
<thead className="bg-[var(--admin-bg)]">
<tr>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Product</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Type</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Price</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Product</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Type</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Price</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Status</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{products.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-16 text-center">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
{Icons.package("h-8 w-8 text-stone-400")}
</div>
<p className="text-sm font-medium text-stone-600">No products found</p>
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
<td colSpan={5} className="px-4 py-8">
<EmptyState
icon={<InboxIcon className="h-8 w-8" strokeWidth={1.25} />}
title="No products found"
description="Try adjusting your filters, or add your first product to get started."
action={{ label: "Add your first product", onClick: onAdd }}
/>
</td>
</tr>
) : (
products.map((product) => (
<tr key={product.id} className="hover:bg-stone-50 transition-colors">
<tr key={product.id} className="hover:bg-[var(--admin-primary-soft)] transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{product.image_url ? (
@@ -472,40 +463,41 @@ function TableView({
/>
</div>
) : (
<div className="h-10 w-10 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-[var(--admin-border)]">
<span className="text-stone-400"></span>
<div className="h-10 w-10 rounded-lg bg-[var(--admin-bg-subtle)] flex items-center justify-center shrink-0 border border-[var(--admin-border)]">
<span className="text-[var(--admin-text-muted)]"></span>
</div>
)}
<div>
<button
onClick={() => onEdit(product)}
className="font-semibold text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] transition-colors text-left"
className="font-semibold text-[var(--admin-text-primary)] hover:text-[var(--admin-primary)] transition-colors text-left"
>
{product.name}
</button>
<div className="text-xs text-stone-500 line-clamp-1">
{product.description || <span className="italic text-stone-400">No description</span>}
<div className="text-xs text-[var(--admin-text-muted)] line-clamp-1">
{product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className="rounded-md bg-stone-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-stone-600">
<span className="rounded-md bg-[var(--admin-bg-subtle)] px-2 py-0.5 text-[10px] font-semibold uppercase text-[var(--admin-text-secondary)]">
{product.type}
</span>
</td>
<td className="px-4 py-3 font-mono text-sm font-semibold text-[var(--admin-text-primary)]">
<td
className="px-4 py-3 text-sm font-semibold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
product.active ? "bg-[var(--admin-accent)]/10 text-[var(--admin-accent)]" : "bg-stone-100 text-stone-500"
}`}>
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"}
</span>
</AdminBadge>
</td>
<td className="px-4 py-3 text-right">
@@ -520,7 +512,7 @@ function TableView({
<button
ref={(el) => { buttonRefs.current[product.id] = el; }}
onClick={() => onDelete(product.id)}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)] transition-colors"
aria-label="Product actions"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -548,19 +540,19 @@ function TableView({
<div
role="dialog"
aria-label="Confirm delete"
className="fixed z-[70] w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4"
className="fixed z-[70] w-72 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] shadow-xl p-4"
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
>
<p className="text-sm font-semibold text-stone-900">
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
Delete &quot;{openProduct.name}&quot;?
</p>
<p className="mt-1 text-xs text-stone-500">
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
This will remove the product. If attached to orders, it will be hidden.
</p>
<div className="mt-3 flex gap-2">
<button
onClick={onDeleteCancel}
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg)]"
>
Cancel
</button>
@@ -592,6 +584,7 @@ function CardView({
onDeleteConfirm,
onDeleteCancel,
deletingId,
onAdd,
}: {
products: Product[];
onEdit: (p: Product) => void;
@@ -600,25 +593,27 @@ function CardView({
onDeleteConfirm: (id: string) => void;
onDeleteCancel: () => void;
deletingId: string | null;
onAdd: () => void;
}) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{products.length === 0 ? (
<div className="col-span-full text-center py-16 rounded-xl border border-[var(--admin-border)] bg-white">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
{Icons.package("h-8 w-8 text-stone-400")}
</div>
<p className="text-sm font-medium text-stone-600">No products found</p>
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
<div className="col-span-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)]">
<EmptyState
icon={<InboxIcon className="h-8 w-8" strokeWidth={1.25} />}
title="No products found"
description="Try adjusting your filters, or add your first product to get started."
action={{ label: "Add your first product", onClick: onAdd }}
/>
</div>
) : (
products.map((product) => (
<div
key={product.id}
className="group relative rounded-xl border border-[var(--admin-border)] bg-white hover:shadow-md hover:border-[var(--admin-accent)]/30 transition-all overflow-hidden"
className="group relative rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] hover:shadow-md hover:border-[var(--admin-primary)]/40 transition-all overflow-hidden"
>
{/* Image */}
<div className="relative h-40 bg-stone-100">
<div className="relative h-40 bg-[var(--admin-bg-subtle)]">
{product.image_url ? (
<Image
src={product.image_url}
@@ -630,16 +625,14 @@ function CardView({
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-4xl text-stone-300"></span>
<span className="text-4xl text-[var(--admin-text-muted)]"></span>
</div>
)}
{/* Status badge */}
<div className="absolute top-3 right-3">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold shadow-sm ${
product.active ? "bg-[var(--admin-accent)] text-white" : "bg-stone-500 text-white"
}`}>
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"}
</span>
</AdminBadge>
</div>
</div>
@@ -649,27 +642,30 @@ function CardView({
onClick={() => onEdit(product)}
className="block w-full text-left"
>
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors line-clamp-1">
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-primary)] transition-colors line-clamp-1">
{product.name}
</h3>
<p className="text-xs text-stone-500 mt-1 line-clamp-2 h-8">
<p className="text-xs text-[var(--admin-text-muted)] mt-1 line-clamp-2 h-8">
{product.description || <span className="italic">No description</span>}
</p>
</button>
<div className="flex items-center justify-between mt-4 pt-3 border-t border-stone-100">
<div className="flex items-center justify-between mt-4 pt-3 border-t border-[var(--admin-border-light)]">
<div>
<span className="text-xs text-stone-500">Price</span>
<p className="text-lg font-bold text-[var(--admin-text-primary)] font-mono">
<span className="text-xs text-[var(--admin-text-muted)]">Price</span>
<p
className="text-lg font-bold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)}
</p>
</div>
<div className="flex items-center gap-1">
<span className="rounded-md bg-stone-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-stone-600">
<span className="rounded-md bg-[var(--admin-bg-subtle)] px-2 py-0.5 text-[10px] font-semibold uppercase text-[var(--admin-text-secondary)]">
{product.type}
</span>
{product.is_taxable && (
<span className="rounded-md bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700 border border-amber-200">
<span className="rounded-md bg-[var(--admin-accent-soft)] px-2 py-0.5 text-[10px] font-semibold text-[var(--admin-accent)] border border-[var(--admin-accent)]">
Tax
</span>
)}
@@ -687,7 +683,7 @@ function CardView({
</AdminButton>
<button
onClick={() => onDelete(product.id)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-[var(--admin-danger-soft)] hover:text-[var(--admin-danger)] hover:border-[var(--admin-danger)] transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
@@ -698,25 +694,25 @@ function CardView({
{/* Delete confirm */}
{deleteConfirm === product.id && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-xl p-4 max-w-[280px] mx-4">
<p className="text-sm font-semibold text-stone-900">
<div className="absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm" style={{ backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 50%, transparent)" }}>
<div className="bg-[var(--admin-card-bg)] rounded-xl shadow-xl p-4 max-w-[280px] mx-4">
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-1 text-xs text-stone-500">
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
This will remove the product. If attached to orders, it will be hidden.
</p>
<div className="flex gap-2 mt-3">
<button
onClick={onDeleteCancel}
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg)]"
>
Cancel
</button>
<button
onClick={() => onDeleteConfirm(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-[var(--admin-danger-hover)]"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
+49
View File
@@ -0,0 +1,49 @@
import React from "react";
/**
* SideNavGroup — a presentational wrapper that renders an optional group
* label (10px uppercase, tracking-widest) and a hairline divider, then
* passes the nav item children through unchanged.
*
* Used by AdminSidebar to chunk the long list of admin links into the
* 7 IA groups defined in `docs/superpowers/specs/2026-06-17-admin-redesign.md`
* (Workspace · Operations · Communications · Growth · Tracking · Insights · Settings).
*
* `collapsed` is a future-proofing prop for a per-group collapse toggle;
* it visually hides the children but keeps the label + divider in place.
*/
type SideNavGroupProps = {
label?: string;
children: React.ReactNode;
collapsed?: boolean;
};
export default function SideNavGroup({
label,
children,
collapsed = false,
}: SideNavGroupProps) {
return (
<section
className="px-3 pb-3"
aria-label={label ? `${label} section` : undefined}
>
{label && (
<div
className="flex items-center gap-2 px-3 pt-3 pb-2 mb-1 border-b"
style={{ borderColor: "rgba(208, 203, 180, 0.12)" }}
>
<span
className="text-[10px] font-semibold uppercase tracking-widest select-none"
style={{ color: "rgba(184, 180, 168, 0.5)" }}
>
{label}
</span>
</div>
)}
<ul className="space-y-1" role="list" hidden={collapsed}>
{children}
</ul>
</section>
);
}
+15 -9
View File
@@ -93,19 +93,25 @@ export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props)
>
{loading ? (
<div className="space-y-3">
<div className="h-4 w-1/3 animate-pulse rounded bg-stone-200" />
<div className="h-4 w-2/3 animate-pulse rounded bg-stone-200" />
<div className="h-4 w-1/2 animate-pulse rounded bg-stone-200" />
<div className="h-4 w-1/3 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
<div className="h-4 w-2/3 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
<div className="h-4 w-1/2 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
</div>
) : loadError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<div
className="rounded-xl border px-4 py-3 text-sm text-[var(--admin-danger)]"
style={{
background: "var(--admin-danger-soft)",
borderColor: "color-mix(in srgb, var(--admin-danger) 25%, transparent)",
}}
>
{loadError}
</div>
) : stop ? (
<div className="space-y-5">
{/* Tabs */}
<div
className="flex items-center gap-1 rounded-xl border border-[var(--admin-border)] bg-stone-50 p-1"
className="flex items-center gap-1 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-1"
role="tablist"
>
<TabButton active={tab === "details"} onClick={() => setTab("details")}>
@@ -223,8 +229,8 @@ function DetailsPanel({
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${
stop.active
? "bg-emerald-100 text-emerald-700"
: "bg-stone-200 text-stone-500"
? "bg-[var(--admin-success-soft)] text-[var(--admin-success)]"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
}`}
>
{stop.active ? "Active" : "Inactive"}
@@ -265,14 +271,14 @@ function DetailsPanel({
<button
type="button"
onClick={() => onDuplicate(stop.id)}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
>
Duplicate Stop
</button>
) : (
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
>
Duplicate Stop
</a>
+42 -28
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import { getStopPendingCustomers, type StopCustomer } from "@/actions/stops/get-stop-customers";
import { sendStopBlast } from "@/actions/communications/stop-blast";
import { AdminButton } from "@/components/admin/design-system";
const quickMessages = [
{ label: "Truck running late", value: "Heads up — the truck is running about 15 minutes behind schedule. Thanks for your patience!" },
@@ -84,10 +85,10 @@ export default function StopMessagingForm({
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-zinc-100">
<h2 className="ha-display text-2xl text-[var(--admin-text-primary)]">
Send Message
</h2>
<p className="mt-1 text-zinc-400">
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Notify all customers with pending pickups at this stop.
</p>
</div>
@@ -96,18 +97,21 @@ export default function StopMessagingForm({
<button
onClick={loadCustomers}
disabled={loading}
className="w-full rounded-xl border-2 border-dashed border-zinc-600 px-6 py-4 text-lg font-medium text-zinc-500 disabled:opacity-50"
className="w-full rounded-xl border-2 border-dashed border-[var(--admin-border)] px-6 py-4 text-lg font-medium text-[var(--admin-text-secondary)] hover:border-[var(--admin-primary)] hover:text-[var(--admin-primary)] transition-colors disabled:opacity-50"
>
{loading ? "Loading customers..." : "Load Pending Customers"}
</button>
) : (
<>
{customers.length === 0 ? (
<div className="rounded-xl bg-slate-50 p-6 text-center text-zinc-500">
<div className="rounded-xl bg-[var(--admin-bg-subtle)] p-6 text-center text-[var(--admin-text-muted)]">
No pending orders for this stop yet.
</div>
) : (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
<div
className="rounded-xl p-4 text-sm text-[var(--admin-success)]"
style={{ background: "var(--admin-success-soft)" }}
>
{customers.length} pending order{customers.length !== 1 ? "s" : ""} found
{recipients.length !== customers.length && (
<span> {recipients.length} with contact info</span>
@@ -117,21 +121,21 @@ export default function StopMessagingForm({
{/* Channel */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Send via
<label className="ha-field-label mb-2">
<span>Send via</span>
</label>
<div className="flex gap-3">
<div className="flex gap-2">
{(["sms", "email", "both"] as const).map((ch) => (
<button
key={ch}
onClick={() => setChannel(ch)}
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
channel === ch
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
? "bg-[var(--admin-primary)] text-white"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
}`}
>
{ch === "sms" ? "📱 SMS" : ch === "email" ? "✉️ Email" : "📱+✉️ Both"}
{ch === "sms" ? "SMS" : ch === "email" ? "Email" : "Both"}
</button>
))}
</div>
@@ -139,8 +143,8 @@ export default function StopMessagingForm({
{/* Quick messages */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Quick messages
<label className="ha-field-label mb-2">
<span>Quick messages</span>
</label>
<div className="flex flex-wrap gap-2">
{quickMessages.map((qm) => (
@@ -149,8 +153,8 @@ export default function StopMessagingForm({
onClick={() => applyQuickMessage(qm.value)}
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
message === qm.value
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
? "bg-[var(--admin-primary)] text-white"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
}`}
>
{qm.label}
@@ -161,8 +165,8 @@ export default function StopMessagingForm({
{/* Message preview */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Message
<label className="ha-field-label mb-2">
<span>Message</span>
</label>
<textarea
value={message}
@@ -171,37 +175,44 @@ export default function StopMessagingForm({
setCustomMessage(e.target.value);
}}
rows={4}
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-slate-900"
className="ha-field-textarea"
placeholder="Type your message..."
/>
<p className="mt-1 text-xs text-slate-400">
<p className="mt-1 text-xs text-[var(--admin-text-muted)] tabular-nums">
{message.length} characters
</p>
</div>
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
<div
role="alert"
className="rounded-xl p-4 text-sm text-[var(--admin-danger)]"
style={{ background: "var(--admin-danger-soft)" }}
>
{error}
</div>
)}
{sent > 0 && (
<div className="rounded-xl bg-green-900/30 p-4 text-green-400 text-sm">
<div
className="rounded-xl p-4 text-sm text-[var(--admin-success)]"
style={{ background: "var(--admin-success-soft)" }}
>
Message sent to {sent} customer{sent !== 1 ? "s" : ""}!
</div>
)}
{/* Recipients */}
{recipients.length > 0 && message && (
<div className="rounded-xl border border-zinc-800 p-4">
<p className="mb-3 text-sm font-medium text-zinc-300">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4">
<p className="mb-3 text-sm font-medium text-[var(--admin-text-secondary)]">
Recipients ({recipients.length}):
</p>
<div className="space-y-2">
{recipients.map((c) => (
<div key={c.id} className="flex justify-between text-sm">
<span className="text-zinc-300">{c.customer_name}</span>
<span className="text-slate-400">
<span className="text-[var(--admin-text-primary)]">{c.customer_name}</span>
<span className="text-[var(--admin-text-muted)] tabular-nums">
{c.customer_phone ?? c.customer_email ?? "no contact"}
</span>
</div>
@@ -210,15 +221,18 @@ export default function StopMessagingForm({
</div>
)}
<button
<AdminButton
variant="primary"
size="lg"
onClick={handleSend}
disabled={!message || recipients.length === 0 || sending}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
isLoading={sending}
className="w-full"
>
{sending
? "Sending..."
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
</button>
</AdminButton>
</>
)}
</div>
@@ -203,8 +203,8 @@ export default function StopProductAssignment({
role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{
background: "rgba(220, 38, 38, 0.06)",
border: "1px solid rgba(220, 38, 38, 0.15)",
background: "var(--admin-danger-soft)",
border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)",
}}
>
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
+5 -5
View File
@@ -403,7 +403,7 @@ export default function StopsCalendarClient({ stops }: Props) {
Active
</span>
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "#f59e0b" }} />
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-warning)" }} />
Draft
</span>
<span className="ha-calendar-legend-item">
@@ -513,7 +513,7 @@ function EventPopover({
className="inline-block w-1.5 h-1.5 rounded-full"
style={{
background:
status === "active" ? "var(--admin-accent)" : status === "draft" ? "#f59e0b" : "var(--admin-text-muted)",
status === "active" ? "var(--admin-accent)" : status === "draft" ? "var(--admin-warning)" : "var(--admin-text-muted)",
}}
/>
{statusLabel(status)} · {brandName(stop)}
@@ -682,19 +682,19 @@ function DayRouteDrawer({
status === "active"
? "var(--admin-accent-light)"
: status === "draft"
? "rgba(245, 158, 11, 0.1)"
? "var(--admin-warning-soft)"
: "var(--admin-bg-subtle)",
color:
status === "active"
? "var(--admin-accent-text)"
: status === "draft"
? "#92400e"
? "var(--admin-warning)"
: "var(--admin-text-secondary)",
borderColor:
status === "active"
? "var(--admin-accent)"
: status === "draft"
? "rgba(245, 158, 11, 0.3)"
? "var(--admin-warning)"
: "var(--admin-border-light)",
}}
>
+6 -6
View File
@@ -68,13 +68,13 @@ export default function StopsLocationsTabs({
group relative flex items-center gap-2.5 rounded-xl px-3.5 py-2
text-sm font-semibold transition-all duration-200
${isActive
? "bg-white text-emerald-700 border border-emerald-200 shadow-sm"
: "text-stone-600 border border-transparent hover:text-emerald-700 hover:bg-emerald-50/40"
? "bg-white text-[var(--admin-primary)] border border-[var(--admin-primary)]/30 shadow-sm"
: "text-[var(--admin-text-secondary)] border border-transparent hover:text-[var(--admin-primary)] hover:bg-[var(--admin-primary-soft)]/40"
}
`}
>
<span
className={`flex-shrink-0 transition-colors ${isActive ? "text-emerald-600" : "text-stone-400 group-hover:text-emerald-500"}`}
className={`flex-shrink-0 transition-colors ${isActive ? "text-[var(--admin-primary)]" : "text-[var(--admin-text-muted)] group-hover:text-[var(--admin-primary)]"}`}
aria-hidden
>
{t.icon}
@@ -85,8 +85,8 @@ export default function StopsLocationsTabs({
inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5
text-[11px] font-bold tabular-nums
${isActive
? "bg-emerald-600 text-white"
: "bg-stone-200/70 text-stone-600 group-hover:bg-emerald-100 group-hover:text-emerald-700"
? "bg-[var(--admin-primary)] text-white"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] group-hover:bg-[var(--admin-primary-soft)] group-hover:text-[var(--admin-primary)]"
}
`}
>
@@ -94,7 +94,7 @@ export default function StopsLocationsTabs({
</span>
{t.sublabel && (
<span
className={`hidden sm:inline text-[11px] font-medium tabular-nums ${isActive ? "text-stone-500" : "text-stone-400"}`}
className={`hidden sm:inline text-[11px] font-medium tabular-nums ${isActive ? "text-[var(--admin-text-muted)]" : "text-[var(--admin-text-muted)]/80"}`}
>
· {t.sublabel}
</span>
@@ -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;
}, []);
+141 -22
View File
@@ -1,33 +1,139 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
/**
* Visual variants for `AdminBadge`. Kept for backward compatibility —
* existing callers (status pills, add-on flags, etc.) continue to pass
* one of these values and are routed onto the new tone palette below.
*/
type BadgeVariant = "default" | "success" | "warning" | "danger" | "info";
/**
* New tone palette. Matches the spec in
* `docs/superpowers/specs/2026-06-17-admin-redesign.md` §3. The previous
* `--admin-warning-light` / `--admin-danger-light` references have been
* replaced with the new `--admin-warning-soft` / `--admin-danger-soft`
* tokens. No hardcoded hex values remain in this file.
*/
type BadgeTone =
| "neutral"
| "primary"
| "accent"
| "warning"
| "danger"
| "success"
| "info";
type AdminBadgeProps = {
children: React.ReactNode;
children: ReactNode;
/** Legacy variant name. Falls back to "default". Mapped onto `tone` internally. */
variant?: BadgeVariant;
/** New tone name. Takes precedence over `variant` when both are provided. */
tone?: BadgeTone;
/** Optional leading dot indicator. */
dot?: boolean;
/** Extra classes — typically size overrides like `"text-xs px-2 py-0.5"`. */
className?: string;
};
const variantClasses: Record<BadgeVariant, { bg: string; text: string; dot: string }> = {
default: { bg: "bg-[var(--admin-border-light)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-text-muted)]" },
success: { bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", dot: "bg-[var(--admin-accent)]" },
warning: { bg: "bg-[var(--admin-warning-light)]", text: "text-[var(--admin-warning)]", dot: "bg-[var(--admin-warning)]" },
danger: { bg: "bg-[var(--admin-danger-light)]", text: "text-[var(--admin-danger)]", dot: "bg-[var(--admin-danger)]" },
info: { bg: "bg-[var(--admin-border)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-info)]" },
type ToneStyle = {
bg: string;
text: string;
/** Optional border color; falls back to the background when unset. */
border?: string;
dot: string;
};
// New tone palette. Every value references a `--admin-*` design token
// declared in `admin-design-system.css`.
const toneStyles: Record<BadgeTone, ToneStyle> = {
neutral: {
bg: "var(--admin-bg-subtle)",
text: "var(--admin-text-secondary)",
border: "var(--admin-border)",
dot: "var(--admin-text-muted)",
},
primary: {
bg: "var(--admin-primary-soft)",
text: "var(--admin-primary)",
border: "var(--admin-primary)",
dot: "var(--admin-primary)",
},
accent: {
bg: "var(--admin-accent-soft)",
text: "var(--admin-accent)",
border: "var(--admin-accent)",
dot: "var(--admin-accent)",
},
success: {
// "success" shares the primary botanical green per the unified palette.
bg: "var(--admin-primary-soft)",
text: "var(--admin-primary)",
border: "var(--admin-primary)",
dot: "var(--admin-primary)",
},
warning: {
bg: "var(--admin-warning-soft)",
text: "var(--admin-warning)",
border: "var(--admin-warning)",
dot: "var(--admin-warning)",
},
danger: {
bg: "var(--admin-danger-soft)",
text: "var(--admin-danger)",
border: "var(--admin-danger)",
dot: "var(--admin-danger)",
},
info: {
bg: "var(--admin-bg-subtle)",
text: "var(--admin-text-secondary)",
border: "var(--admin-border)",
dot: "var(--admin-text-secondary)",
},
};
// Map the legacy `variant` prop onto the new `tone` palette. The mapping
// preserves the visual intent of pre-refactor call sites.
const variantToTone: Record<BadgeVariant, BadgeTone> = {
default: "neutral",
success: "success",
warning: "warning",
danger: "danger",
info: "info",
};
export default function AdminBadge({
children,
variant = "default",
tone,
dot = false,
className = ""
className = "",
}: AdminBadgeProps) {
const { bg, text, dot: dotColor } = variantClasses[variant];
// `tone` wins when provided; otherwise translate the legacy variant.
const resolvedTone: BadgeTone = tone ?? variantToTone[variant];
const { bg, text, border, dot: dotColor } = toneStyles[resolvedTone];
// Border color tracks tone when the tone is one of the chromatic ones;
// otherwise it falls back to the neutral hairline so the pill keeps a
// subtle outline against the cream canvas.
const style: CSSProperties = {
backgroundColor: bg,
color: text,
borderColor: border ?? "var(--admin-border)",
};
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${bg} ${text} ${className}`}>
{dot && <span className={`h-1.5 w-1.5 rounded-full ${dotColor}`} />}
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-bold ${className}`}
style={style}
>
{dot && (
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: dotColor }}
/>
)}
{children}
</span>
);
@@ -39,19 +145,21 @@ type AdminStatusBadgeProps = {
className?: string;
};
const statusConfig: Record<string, { variant: BadgeVariant; dot: boolean; label: string }> = {
active: { variant: "success", dot: true, label: "Active" },
inactive: { variant: "default", dot: true, label: "Inactive" },
pending: { variant: "warning", dot: true, label: "Pending" },
draft: { variant: "default", dot: true, label: "Draft" },
completed: { variant: "success", dot: true, label: "Completed" },
cancelled: { variant: "danger", dot: true, label: "Cancelled" },
// Reuse the new `tone` prop directly so the status pill text style always
// matches the active AdminBadge palette.
const statusConfig: Record<string, { tone: BadgeTone; dot: boolean; label: string }> = {
active: { tone: "success", dot: true, label: "Active" },
inactive: { tone: "neutral", dot: true, label: "Inactive" },
pending: { tone: "warning", dot: true, label: "Pending" },
draft: { tone: "neutral", dot: true, label: "Draft" },
completed: { tone: "success", dot: true, label: "Completed" },
cancelled: { tone: "danger", dot: true, label: "Cancelled" },
};
export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgeProps) {
const config = statusConfig[status] || statusConfig.inactive;
return (
<AdminBadge variant={config.variant} dot={config.dot} className={className}>
<AdminBadge tone={config.tone} dot={config.dot} className={className}>
{config.label}
</AdminBadge>
);
@@ -60,14 +168,25 @@ export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgePro
// Count badge (circular)
type AdminCountBadgeProps = {
count: number;
tone?: BadgeTone;
/** @deprecated Use `tone` instead. */
variant?: BadgeVariant;
className?: string;
};
export function AdminCountBadge({ count, variant = "default", className = "" }: AdminCountBadgeProps) {
const { bg, text } = variantClasses[variant];
export function AdminCountBadge({
count,
tone,
variant = "default",
className = "",
}: AdminCountBadgeProps) {
const resolvedTone: BadgeTone = tone ?? variantToTone[variant];
const { bg, text } = toneStyles[resolvedTone];
return (
<span className={`inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5 text-xs font-bold ${bg} ${text} ${className}`}>
<span
className={`inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5 text-xs font-bold ${className}`}
style={{ backgroundColor: bg, color: text }}
>
{count}
</span>
);
@@ -12,6 +12,12 @@ export { default as AdminActionMenu, AdminActionButton } from "./AdminActionMenu
export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination";
export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge";
// Phase 2 pattern primitives (admin-level, not in design-system/ folder)
// Re-exported here so callers can `import { KPIStat, EmptyState, LoadingState } from "@/components/admin/design-system"`.
export { default as KPIStat } from "../KPIStat";
export { default as EmptyState } from "../EmptyState";
export { default as LoadingState } from "../LoadingState";
// Design system components
export { default as AdminButton, AdminIconButton } from "./AdminButton";
export { default as PageHeader } from "./PageHeader";