12 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
Tyler 18fb44ed38 style(admin): unify color tokens (deep botanical + amber)
- --admin-bg: warm cream (#FAF7F0)
- --admin-text-primary: ink black (#1A1814)
- --admin-text-muted: warm grey (#8A867E) — was too dim
- --admin-border: soft beige (#E8E4D7) — was too dirty
- --admin-primary: deep botanical (#1F4D2A)
- --admin-accent: warm amber (#B8761E) — the 'one thing to do'
- --admin-warning: amber (was #aba278 — looked like dirt)
- --admin-danger: warmer rust (#A8321C)
- sidebar: deeper, more refined (#2A2520)
- drop purple/blue/citrus stat icons in DashboardClient; use semantic tokens
- use --admin-warning instead of hardcoded #f59e0b in usage bar
2026-06-17 00:08:46 -06:00
Tyler bd2dadd9ee chore(design): admin redesign spec + task checklist
- design spec: aesthetic, color tokens, IA, phased plan
- checklist: 7 phases, every task has notes column, revert cheatsheet
- working branch: design/ui-revamp-2026-06
2026-06-17 00:07:04 -06:00
37 changed files with 3743 additions and 1472 deletions
@@ -0,0 +1,121 @@
# Admin Redesign — Task Checklist
> Working branch: `design/ui-revamp-2026-06`
> Spec: `docs/superpowers/specs/2026-06-17-admin-redesign.md`
> Started: 2026-06-17
> Deadline: tomorrow
## How to use this file
- `[ ]` unchecked → not done · `[x]` checked → done
- Tick a box by changing `[ ]` to `[x]`
- Add free-form notes in the **Notes** column
- One commit per task (or per cluster of related tasks)
- Revert a task with `git reset --hard <commit-before-task>`
- Full revert: `git checkout main && git branch -D design/ui-revamp-2026-06`
## Revert cheatsheet
```bash
git status # what's dirty
git stash # save uncommitted, discard
git diff # see what's staged
git log --oneline -10 # see recent commits
git reset --hard <sha> # nuke back to that commit
git checkout main # abandon branch, keep work
git branch -D design/ui-revamp-2026-06 # delete the branch
```
---
## Phase 0 — Setup
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [x] | Create feature branch | — | `design/ui-revamp-2026-06` off `main` | — |
| [x] | Write design spec | `docs/superpowers/specs/2026-06-17-admin-redesign.md` | aesthetic, color, IA, phases | — |
| [x] | Write this checklist | `docs/superpowers/plans/2026-06-17-admin-redesign-checklist.md` | — | — |
| [ ] | Commit baseline | both files above | `chore: design spec + checklist` | (will commit after writing this) |
## Phase 1 — Foundation (color + design tokens)
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [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 |
|---|---|---|---|---|
| [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 |
|---|---|---|---|---|
| [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)
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [ ] | Redesign `DashboardClient` as single feed (no 4 tabs) | `src/components/admin/DashboardClient.tsx` | top: KPI strip · middle: "Needs attention" feed · bottom: section grid | `feat(admin): unified dashboard` |
| [ ] | Add a "What needs attention" feed (orders pending, stops today, low stock, unanswered contacts) | same file | data from existing actions | (same commit) |
## Phase 5 — Apply patterns to top pages
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [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` |
## Phase 6 — Frontend polish (public side)
| Done | Task | Files | Notes | Commit |
|---|---|---|---|---|
| [ ] | Tuxedo home polish | `src/app/tuxedo/page.tsx` + `tuxedo/` components | rhythm, spacing, mobile | `style(storefront): tuxedo polish` |
| [ ] | Indian River Direct home polish | `src/app/indian-river-direct/page.tsx` + components | rhythm, spacing, mobile | `style(storefront): IRD polish` |
| [ ] | Pricing page polish | `src/app/pricing/page.tsx` | tier card spacing | `style(public): pricing polish` |
## Phase 7 — Verify (gate before merge)
| Done | Task | Command | Notes | Commit |
|---|---|---|---|---|
| [ ] | Type-check clean | `npx tsc --noEmit` | 0 errors | (no commit, gate) |
| [ ] | Build clean | `npm run build` | green | (no commit, gate) |
| [ ] | Dev server boots | `npm run dev` | `/admin` renders, sidebar works, Cmd+K opens | (no commit, gate) |
| [ ] | Manual smoke: dashboard | `localhost:4000/admin` | layout intact, no console errors | (no commit, gate) |
| [ ] | Manual smoke: command palette | `Cmd+K` | opens, type, jump | (no commit, gate) |
| [ ] | Manual smoke: orders list | `/admin/orders` | loads, page header consistent | (no commit, gate) |
---
## Live notes (free-form)
Add anything you want to remember as we go.
- The existing `.ha-*` (Harvest Almanac) class system is good. Build on it; don't replace it.
- The sidebar is in `src/components/admin/AdminSidebar.tsx` (~520 lines). Don't rewrite from scratch — refactor the data structure to groups and let the JSX mostly stand.
- `lucide-react` is already a dependency. New icons should use lucide, not inline SVGs.
- The `Atelier des Récoltes` CSS classes (`.atelier-*` in `globals.css`) are public-side only. Don't mix them into admin.
- Per MEMORY.md: spawn sub-agents sequentially if TPM rate limit hits. If parallel works, fine; if not, fall back.
## Decisions log
Record any calls you make mid-flight that change the spec.
- (none yet)
@@ -0,0 +1,135 @@
# Admin Redesign — 2026-06-17
> Branch: `design/ui-revamp-2026-06`
> Type: Hot take. Tomorrow deadline. Approved yolo.
> Goal: polish the public side, reimagine the admin, fix the colors, make things findable.
---
## 1. Context
- **Public side (`/`, `/tuxedo`, `/indian-river-direct`, pricing, blog, contact)** — "good, just needs polishing." The "Atelier des Récoltes" / "Field Almanac" editorial language is in place (cream + forest + gold + Fraunces + Manrope + Fragment Mono). Don't break it.
- **Admin (`/admin/*`)** — the surface that needs the most work.
- 15+ admin sections are split across 4 dashboard tabs (Operations / Fulfillment / Management / Tools). **The user can't find things.**
- Color palette mixes forest + citrus + sage + gold + warmred + surface greys. **The colors fight each other and feel muddy.**
- Sidebar has 6 flat items. "Settings" sub-pages are reachable only by knowing they exist. **Discoverability is bad.**
- The "Harvest Almanac" design system (`.ha-*` classes) exists but is underused. Most admin pages fall back to defaults.
## 2. Aesthetic direction (one paragraph, committed)
> **Editorial operations.** The admin should feel like the back office of an almanac press, not a generic SaaS dashboard. Warm cream canvas, deep botanical green for primary action, amber gold for the *one* thing you should do next, ink-black for text, a single soft beige for borders. The same typeface family as the public side (Fraunces / Manrope / Fragment Mono). Generous use of numerals in Fragment Mono so prices and counts read like a ledger. **One primary, one accent, four neutrals. That's it.** No purple, no teal, no citrus orange as a primary.
Commit to this and stop adding new colors.
## 3. Color system (final, do not deviate)
| Token | Hex | Use |
|---|---|---|
| `--admin-bg` | `#FAF7F0` | Page background (atelier cream) |
| `--admin-surface` | `#FFFFFF` | Cards, modals, sheets |
| `--admin-surface-sunken` | `#F4F1E8` | Inset wells, code blocks |
| `--admin-ink` | `#1A1814` | Primary text |
| `--admin-ink-muted` | `#73706B` | Secondary text |
| `--admin-ink-faint` | `#A8A29E` | Tertiary / placeholders |
| `--admin-line` | `#E8E4D7` | Borders, dividers |
| `--admin-line-strong` | `#D4CFBE` | Emphasized borders |
| `--admin-primary` | `#1F4D2A` | Primary action (deep botanical) |
| `--admin-primary-hover` | `#163C20` | Primary hover |
| `--admin-primary-soft` | `#E8F0E5` | Primary at 8% (selected rows, chips) |
| `--admin-accent` | `#B8761E` | Amber — "the one thing to do" |
| `--admin-accent-soft` | `#F7EBD5` | Amber at 12% |
| `--admin-success` | `#1F4D2A` | (same as primary; semantic) |
| `--admin-warning` | `#B8761E` | (same as accent; semantic) |
| `--admin-danger` | `#A8321C` | Errors / destructive |
| `--admin-danger-soft` | `#F7E3DE` | Danger at 12% |
**Drop**: `--admin-warning: #aba278` (looks like dirt), the old `citrus` orange in the active state, the blue/purple stat icons. Replace with semantic use of the new palette.
## 4. IA decision
Sidebar groups, top to bottom, with section dividers:
| Group | Items |
|---|---|
| **Workspace** | Dashboard · Command Center (platform_admin only) |
| **Operations** | Orders · Stops & Routes · Products · Driver Pickup · Shipping |
| **Communications** | Harvest Reach (campaigns, templates, contacts, segments, logs) |
| **Growth** | Wholesale · Import Center · AI Intelligence |
| **Tracking** | Time Tracking · Water Log · Route Trace |
| **Insights** | Reports · Tax Dashboard |
| **Settings** | General · Brand · Billing · Users & Permissions · Integrations · Payments · Shipping · Add-ons |
The old "Workers & PINs" sidebar item is a tab on Time Tracking now. Settings is one click → secondary nav inside settings (tabs at the top of the page).
**Cmd+K command palette** for global search across:
- Pages (jumps to any admin route)
- Recent orders / products / stops / customers (live search)
- Quick actions ("Create order", "Add product", "Send blast")
## 5. Component patterns (consistency pass)
| Pattern | Owner | Status |
|---|---|---|
| `PageHeader` (eyebrow + title + subtitle + actions) | `src/components/admin/design-system/PageHeader.tsx` | exists, audit usage |
| `EmptyState` (icon + title + body + CTA) | same dir, currently `EmptyState.tsx` exists for shared, admin needs its own | add `.ha-empty` |
| `LoadingState` (skeleton + label) | `.ha-skeleton` exists in CSS, no component | wrap |
| `ConfirmDialog` (destructive action) | `AdminDeleteConfirm.tsx` exists | audit usage |
| `StatusPill` (success/warn/danger/info/neutral) | `AdminBadge.tsx` exists, variants are off | tighten variants |
| `KPIStat` (label + value + trend) | inline in `DashboardClient` | extract to component |
| `CommandPalette` (Cmd+K) | **new** | new component |
| `SideNavGroup` (label + items) | **new** | replaces flat NAV_ITEMS |
| `DataTable` (sortable, filterable, paginated) | `shared/DataTable.tsx` exists | tighten + use everywhere |
## 6. Phased execution (yolo, one day)
**Phase 1 — Foundation (commit per file)**
- Update `src/styles/admin-design-system.css` color tokens
- New `SideNavGroup` component
- Replace `AdminSidebar` flat list with grouped nav
- New `CommandPalette` component (mounted in admin layout)
- Refactor `DashboardClient` to unified command center (drop 4 tabs, single column feed)
**Phase 2 — Pattern extraction (commit per file)**
- Extract `KPIStat` from dashboard
- Refine `EmptyState` admin variant
- `LoadingState` wrapper
- `StatusPill` tighten variants
**Phase 3 — Apply to highest-traffic pages**
- `/admin/orders` + detail
- `/admin/products` + new + edit
- `/admin/stops` calendar
- `/admin/communications` composer
- `/admin/settings/*` tabs
**Phase 4 — Frontend polish**
- Public page spot-check: hero spacing, section rhythm
- Verify TypeScript + build green
## 7. Risk & revert
- Working on branch `design/ui-revamp-2026-06` (already created off `main`).
- **Every milestone is its own commit.** `git reset --hard <sha>` reverts to that point.
- **One-click full revert:** `git checkout main && git branch -D design/ui-revamp-2026-06` — but only after a failed `git push`.
- Type-check (`npx tsc --noEmit`) + `npm run build` are the gate at the end of each phase. Build green = milestone accepted.
- If something breaks the admin layout at runtime, `git revert HEAD` unwinds the most recent commit without losing history.
- **Do not delete or rename files in this branch.** Only add, only modify. Easier to revert.
## 8. Out of scope (yolo tomorrow, not now)
- Wholesale portal pages (`/wholesale/*`)
- Water-log standalone pages
- Public marketing pages (pricing, blog, changelog) — polish later
- Wholesale auth migration (separate task per MEMORY.md)
- Migrating remaining legacy `supabase.from()` calls (separate task per MEMORY.md)
## 9. What "done" looks like
- [ ] Color tokens unified, no muddy mix
- [ ] Sidebar has 6 visible groups with section labels
- [ ] Cmd+K opens a command palette that can navigate to any admin page
- [ ] Dashboard is a single feed, no 4-tab structure
- [ ] Top 6 admin pages use the new PageHeader / StatusPill / EmptyState consistently
- [ ] `npx tsc --noEmit` clean
- [ ] `npm run build` clean
- [ ] Dev server boots, `/admin` renders, sidebar works, Cmd+K opens
+16
View File
@@ -10,6 +10,8 @@ import { ToastProvider } from "@/components/admin/Toast";
import { ToastContainer } from "@/components/admin/ToastContainer"; import { ToastContainer } from "@/components/admin/ToastContainer";
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition"; import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer"; 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, // Admin layout calls getAdminUser() which reads cookies(). Without this,
// Next.js tries to prerender the entire /admin/* tree statically and the // 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); 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 ( return (
<ToastProviderWrapper> <ToastProviderWrapper>
<AdminSidebar <AdminSidebar
@@ -106,7 +119,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
brandIds={adminUser.brand_ids} brandIds={adminUser.brand_ids}
activeBrandId={activeBrandId} activeBrandId={activeBrandId}
brands={brands} 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 {/* The main content area swaps on every navigation. Wrapping
it in <SmoothViewTransition> opts the swap into a soft it in <SmoothViewTransition> opts the swap into a soft
crossfade via the View Transitions API — sidebar and 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 OrderPaymentSection from "@/components/admin/OrderPaymentSection";
import OrderPickupAction from "@/components/admin/OrderPickupAction"; import OrderPickupAction from "@/components/admin/OrderPickupAction";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; 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 { formatDate } from "@/lib/format-date";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
@@ -34,16 +36,31 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
if (!order) { if (!order) {
return ( 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"> <div className="mx-auto max-w-4xl">
<Link <Link
href="/admin/orders" 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 Back to Orders
</Link> </Link>
<div className="mt-8 rounded-2xl border border-red-200 bg-red-50 p-8 text-center"> <div
<p className="text-lg font-semibold text-red-700">Order not found</p> 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>
</div> </div>
</main> </main>
@@ -67,46 +84,66 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
const total = subtotal + taxAmount - discount_amount; const total = subtotal + taxAmount - discount_amount;
return ( 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"> <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 */} {/* Header */}
<div className="flex items-center justify-between"> <div>
<div className="flex items-center gap-4"> <p className="ha-eyebrow mb-2">
<Link Order #{order.id.slice(0, 8).toUpperCase()} · {formatDate(order.created_at)}
href="/admin/orders" </p>
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" <PageHeader
> title={order.customer_name}
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> subtitle={`${formatCurrency(total)} · ${order.pickup_complete ? "Picked Up" : "Awaiting Pickup"}${order.stops ? ` · ${order.stops.city}, ${order.stops.state}` : ""}`}
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> actions={
</svg> <div className="flex items-center gap-2">
</Link> <AdminBadge tone={order.pickup_complete ? "success" : "warning"} dot>
<div> {order.pickup_complete ? "Picked Up" : "Pending"}
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Order Details</h1> </AdminBadge>
<p className="text-sm text-stone-500">{formatDate(order.created_at)}</p> {order.payment_processor && (
</div> <AdminBadge tone="info">{order.payment_processor}</AdminBadge>
</div> )}
<div className="flex items-center gap-3"> </div>
<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> </div>
{/* Customer info */} {/* 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 className="flex items-start justify-between gap-4">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400">Customer</p> <p
<h2 className="mt-1 text-2xl font-bold text-stone-950">{order.customer_name}</h2> 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> </div>
<OrderPickupAction <OrderPickupAction
orderId={order.id} orderId={order.id}
@@ -118,14 +155,34 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
<div className="mt-5 grid grid-cols-2 gap-4"> <div className="mt-5 grid grid-cols-2 gap-4">
{order.customer_phone && ( {order.customer_phone && (
<div> <div>
<p className="text-xs font-semibold text-stone-400">Phone</p> <p
<p className="mt-0.5 text-sm font-medium text-stone-700">{order.customer_phone}</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> </div>
)} )}
{order.customer_email && ( {order.customer_email && (
<div> <div>
<p className="text-xs font-semibold text-stone-400">Email</p> <p
<p className="mt-0.5 text-sm font-medium text-stone-700">{order.customer_email}</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>
)} )}
</div> </div>
@@ -133,64 +190,133 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
{/* Stop info */} {/* Stop info */}
{order.stops && ( {order.stops && (
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm"> <div
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400">Pickup Location</p> className="rounded-2xl border p-6 shadow-sm"
<p className="mt-1 text-lg font-bold text-stone-950"> 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} {order.stops.city}, {order.stops.state}
</p> </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> </div>
)} )}
{/* Order items */} {/* Order items */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm"> <div
<h3 className="text-lg font-bold text-stone-950">Order Items</h3> 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 ? ( {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) => ( {order.order_items.map((item: OrderItem) => (
<div <div
key={item.id} key={item.id}
className="flex items-center justify-between py-3" className="flex items-center justify-between py-3"
> >
<div> <div>
<p className="font-medium text-stone-900"> <p
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{item.products?.name ?? "Unknown Product"} {item.products?.name ?? "Unknown Product"}
</p> </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> </div>
<p className="font-semibold text-stone-900"> <p
className="font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(Number(item.price) * item.quantity)} {formatCurrency(Number(item.price) * item.quantity)}
</p> </p>
</div> </div>
))} ))}
</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 */} {/* 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"> <div className="flex justify-between text-sm">
<span className="text-stone-500">Subtotal</span> <span style={{ color: "var(--admin-text-muted)" }}>Subtotal</span>
<span className="text-stone-900">{formatCurrency(subtotal)}</span> <span style={{ color: "var(--admin-text-primary)" }}>
{formatCurrency(subtotal)}
</span>
</div> </div>
{taxAmount > 0 && ( {taxAmount > 0 && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-stone-500">Tax</span> <span style={{ color: "var(--admin-text-muted)" }}>Tax</span>
<span className="text-stone-900">{formatCurrency(taxAmount)}</span> <span style={{ color: "var(--admin-text-primary)" }}>
{formatCurrency(taxAmount)}
</span>
</div> </div>
)} )}
{discount_amount > 0 && ( {discount_amount > 0 && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-stone-500">Discount</span> <span style={{ color: "var(--admin-text-muted)" }}>Discount</span>
<span className="text-red-600">-{formatCurrency(discount_amount)}</span> <span style={{ color: "var(--admin-danger)" }}>
-{formatCurrency(discount_amount)}
</span>
{order.discount_reason && ( {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>
)} )}
<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>Total</span>
<span>{formatCurrency(total)}</span> <span>{formatCurrency(total)}</span>
</div> </div>
@@ -198,9 +324,25 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
</div> </div>
{/* Payment & Refunds */} {/* Payment & Refunds */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm"> <div
<h3 className="text-lg font-bold text-stone-950">Payment & Refunds</h3> className="rounded-2xl border p-6 shadow-sm"
<p className="mt-1 text-sm text-stone-500">Record payment details and manage refunds</p> 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"> <div className="mt-5">
<OrderPaymentSection <OrderPaymentSection
@@ -218,9 +360,25 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
</div> </div>
{/* Edit form */} {/* Edit form */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm"> <div
<h3 className="text-lg font-bold text-stone-950">Edit Order</h3> className="rounded-2xl border p-6 shadow-sm"
<p className="mt-1 text-sm text-stone-500">Update customer details, pricing, and status</p> 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"> <div className="mt-5">
<OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} /> <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 */} {/* Internal notes */}
{order.internal_notes && ( {order.internal_notes && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-6"> <div
<p className="text-xs font-semibold uppercase tracking-wider text-amber-600">Internal Notes</p> className="rounded-2xl border p-6"
<p className="mt-1 text-sm text-stone-700">{order.internal_notes}</p> 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>
)} )}
</div> </div>
+10 -3
View File
@@ -65,11 +65,12 @@ export default async function AdminOrdersPage() {
} }
return ( 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"> <div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader <PageHeader
title="Orders" title="Orders"
subtitle="Manage customer orders and pickup status" subtitle="View, filter, and manage customer orders."
icon={ 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"> <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"/> <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 */} {/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6"> <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 <AdminOrdersPanel
initialOrders={brandOrders} initialOrders={brandOrders}
initialStops={brandStops} initialStops={brandStops}
+41 -31
View File
@@ -1,6 +1,8 @@
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import ProductEditForm from "@/components/admin/ProductEditForm"; import ProductEditForm from "@/components/admin/ProductEditForm";
import { getAdminUser } from "@/lib/admin-permissions"; 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 AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
@@ -51,15 +53,15 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
if (error || !product) { if (error || !product) {
return ( 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="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-red-600">Product not found</h1> <h1 className="text-3xl font-bold text-[var(--admin-danger)]">Product not found</h1>
<pre className="mt-4 rounded-xl bg-white p-4 text-sm text-stone-600"> <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"} {error?.message ?? "Product not found"}
</pre> </pre>
<Link <Link
href="/admin/products" 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 Back to Products
</Link> </Link>
@@ -69,58 +71,66 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
} }
return ( 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="mx-auto max-w-4xl">
<Link <div className="ha-eyebrow mb-2">Operations</div>
href="/admin/products" <PageHeader
className="text-sm text-stone-500 hover:text-stone-700" icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
> title="Edit product"
Back to Products subtitle="Update product details, pricing, and availability."
</Link> 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 className="flex items-start justify-between">
<div> <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} {product.brands?.name}
</p> </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} {product.name}
</h1> </h1>
<p className="mt-2 text-lg text-stone-600"> <p className="mt-2 text-lg text-[var(--admin-text-secondary)]">
{product.description} {product.description}
</p> </p>
</div> </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>
<div className="mt-6 grid grid-cols-2 gap-6"> <div className="mt-6 grid grid-cols-2 gap-6">
<div> <div>
<p className="text-sm font-medium text-stone-500">Price</p> <p className="text-sm font-medium text-[var(--admin-text-muted)]">Price</p>
<p className="mt-1 text-2xl font-bold text-stone-950"> <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)} ${Number(product.price).toFixed(2)}
</p> </p>
</div> </div>
<div> <div>
<p className="text-sm font-medium text-stone-500">Type</p> <p className="text-sm font-medium text-[var(--admin-text-muted)]">Type</p>
<p className="mt-1 text-lg font-semibold text-stone-950"> <p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
{product.type} {product.type}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50"> <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-stone-950">Edit Product</h2> <h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Product</h2>
<p className="mt-1 text-stone-500"> <p className="mt-1 text-[var(--admin-text-muted)]">
Update product details, pricing, and availability. Update product details, pricing, and availability.
</p> </p>
+13 -14
View File
@@ -1,5 +1,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import NewProductForm from "@/components/admin/NewProductForm"; 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 { getBrands } from "@/actions/admin/users";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
@@ -19,28 +21,25 @@ export default async function NewProductPage() {
} }
return ( 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="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 <Link
href="/admin/products" 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 Back to Products
</Link> </Link>
</div> </div>
<div className="rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50"> <div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
<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>
<NewProductForm <NewProductForm
defaultBrandId={adminUser.brand_id ?? ""} defaultBrandId={adminUser.brand_id ?? ""}
brands={brands} brands={brands}
+4 -14
View File
@@ -7,16 +7,6 @@ import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import ProductsClient from "@/components/admin/ProductsClient"; import ProductsClient from "@/components/admin/ProductsClient";
import { getBrands } from "@/actions/admin/users"; 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). // Shape ProductsClient expects (legacy columns mapped from the new schema).
type ProductRow = { type ProductRow = {
id: string; id: string;
@@ -41,8 +31,8 @@ export default async function AdminProductsPage() {
return ( return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8"> <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"> <div className="mx-auto max-w-6xl">
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Access Denied</h1> <h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-danger)]">Access Denied</h1>
<p className="mt-2 text-sm text-stone-500">You do not have permission to manage products.</p> <p className="mt-2 text-sm text-[var(--admin-text-muted)]">You do not have permission to manage products.</p>
</div> </div>
</main> </main>
); );
@@ -133,8 +123,8 @@ export default async function AdminProductsPage() {
return ( return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8"> <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"> <div className="mx-auto max-w-6xl">
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Error loading products</h1> <h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-danger)]">Error loading products</h1>
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-stone-600"> <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} {queryError}
</pre> </pre>
</div> </div>
+169 -131
View File
@@ -4,9 +4,17 @@ import StopProductAssignment from "@/components/admin/StopProductAssignment";
import MessageCustomersSection from "@/components/admin/MessageCustomersSection"; import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; 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 = { type StopDetailPageProps = {
params: Promise<{ params: Promise<{
id: string; id: string;
@@ -68,18 +76,30 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
if (!stopRow) { if (!stopRow) {
return ( return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}> <main className="min-h-screen bg-[var(--admin-bg)]">
<div className="mx-auto max-w-4xl"> <div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<h1 className="text-3xl font-bold text-red-600">Stop not found</h1> <div className="mx-auto max-w-4xl">
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600"> <p className="ha-eyebrow mb-2">Operations</p>
{id} <PageHeader
</pre> title="Stop not found"
<Link subtitle="This stop may have been removed or you don't have access."
href="/admin/stops" icon={<StopIcon />}
className="mt-4 inline-block text-stone-500 hover:text-stone-700" />
> <div className="rounded-2xl border border-[var(--admin-border)] bg-white p-5">
Back to Stops <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">
</Link> {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> </div>
</main> </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 ( return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}> <main className="min-h-screen bg-[var(--admin-bg)]">
<div className="mx-auto max-w-4xl"> <div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<Link <p className="ha-eyebrow mb-2">Operations</p>
href="/admin/stops" <PageHeader
className="text-sm text-stone-500 hover:text-stone-700" title={`${stop.city}, ${stop.state}`}
> subtitle={detailSubtitle}
Back to Stops icon={<StopIcon />}
</Link> />
</div>
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg"> <div className="px-4 sm:px-6 md:px-8 pb-8 sm:pb-10">
<div className="flex items-start justify-between"> <div className="mx-auto max-w-4xl">
<div> <Link
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500"> href="/admin/stops"
{stop.brands?.name} className="ha-btn-ghost mb-4 inline-flex"
</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"
> >
Duplicate Stop <svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
</a> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</div> </svg>
Back to Stops
</Link>
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg"> <div className="rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
<h2 className="text-2xl font-bold text-stone-950"> <div className="flex items-start justify-between">
Assigned Products <div>
</h2> <p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
<p className="mt-1 text-stone-600"> {stop.brands?.name}
Manage which products are available at this stop. </p>
</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"> <span
<StopProductAssignment className={`shrink-0 rounded-full px-4 py-2 text-sm font-medium ${
stopId={stop.id} stop.active
allProducts={allProducts} ? "bg-[var(--admin-success-soft)] text-[var(--admin-success)] border border-[var(--admin-success)]/30"
assignedProducts={assignedProducts} : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
callerUid={adminUser.user_id} }`}
/> >
{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>
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg"> <div className="mt-4 flex justify-end">
<h2 className="text-2xl font-bold text-stone-950">Edit Stop</h2> <a
<p className="mt-1 text-stone-600"> href={`/admin/stops/new?duplicate=${stop.id}`}
Update stop details, location, and availability. className="ha-btn-ghost"
</p> >
Duplicate Stop
<div className="mt-6"> </a>
<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>
</div>
{/* Message Customers */} <div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg"> <h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">
<h2 className="text-2xl font-bold text-stone-950">Message Customers</h2> Assigned Products
<p className="mt-1 text-stone-600"> </h2>
Send updates to customers with pending pickups at this stop. <p className="mt-1 text-[var(--admin-text-secondary)]">
</p> Manage which products are available at this stop.
</p>
<div className="mt-6"> <div className="mt-6">
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} /> <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> </div>
</div> </div>
+33 -18
View File
@@ -3,9 +3,17 @@ import NewStopForm from "@/components/admin/NewStopForm";
import StopProductAssignment from "@/components/admin/StopProductAssignment"; import StopProductAssignment from "@/components/admin/StopProductAssignment";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; 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 = { type Stop = {
city: string; city: string;
state: string; state: string;
@@ -62,30 +70,37 @@ export default async function NewStopPage({
[brandId] [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 ( return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}> <main className="min-h-screen bg-[var(--admin-bg)]">
<div className="mx-auto max-w-4xl"> <div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<div className="mb-8"> <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 <Link
href="/admin/stops" 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> </Link>
</div>
<div className="rounded-2xl bg-white p-8 border border-stone-200 shadow-lg"> <div className="rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
<h1 className="text-3xl font-bold text-stone-950"> <NewStopForm duplicateFrom={duplicateFrom} />
{duplicateFrom ? "Duplicate Stop" : "Create Stop"} </div>
</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> </div>
</div> </div>
</main> </main>
+27 -15
View File
@@ -3,6 +3,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import StopTableClient from "@/components/admin/StopTableClient"; import StopTableClient from "@/components/admin/StopTableClient";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
const StopIcon = () => ( const StopIcon = () => (
@@ -75,20 +76,23 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
<main className="min-h-screen bg-[var(--admin-bg)]"> <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="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<nav className="flex items-center gap-2 text-xs sm:text-sm mb-6"> <p className="ha-eyebrow mb-2">Operations</p>
<a href="/admin" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Admin</a> <PageHeader
<span className="text-[var(--admin-text-muted)]">/</span> title="Stops & Routes"
<span className="text-[var(--admin-text-primary)] font-medium">Stops & Routes</span> subtitle="Schedule and manage pickup locations and dates."
</nav> icon={<StopIcon />}
<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"> <div className="rounded-2xl border border-[var(--admin-danger)]/30 bg-[var(--admin-danger-soft)] p-5">
{error} <h2 className="text-lg font-semibold text-[var(--admin-danger)]">Error loading stops</h2>
</pre> <pre className="mt-3 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)] overflow-auto">
<div className="mt-4 p-4 rounded-xl bg-stone-100 text-sm"> {error}
<p className="font-semibold">Debug info:</p> </pre>
<p>adminUser.brand_id: {adminUser.brand_id ?? "null"}</p> <div className="mt-4 p-4 rounded-xl bg-[var(--admin-bg-subtle)] text-sm text-[var(--admin-text-secondary)]">
<p>adminUser.role: {adminUser.role}</p> <p className="font-semibold text-[var(--admin-text-primary)]">Debug info:</p>
<p>adminUser.email: {adminUser.email}</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> </div>
</div> </div>
@@ -115,7 +119,15 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
return ( return (
<div className="min-h-screen bg-[var(--admin-bg)]"> <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} /> <StopTableClient stops={stopsForClient} />
</div> </div>
</div> </div>
+1 -1
View File
@@ -145,7 +145,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
<div <div
role="alert" role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]" 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}> <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" /> <circle cx="12" cy="12" r="10" />
+423 -141
View File
@@ -3,11 +3,31 @@
import { useState, useMemo, useCallback, useEffect } from "react"; import { useState, useMemo, useCallback, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import {
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
MapPin,
Package,
X,
} from "lucide-react";
import { markPickupComplete } from "@/actions/pickup"; import { markPickupComplete } from "@/actions/pickup";
import { createAdminOrder, type AdminCreateOrderItem } from "@/actions/orders/create-admin-order"; import { createAdminOrder, type AdminCreateOrderItem } from "@/actions/orders/create-admin-order";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import AdminBadge from "./design-system/AdminBadge"; 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"; import { Skeleton } from "./design-system";
type OrderItem = { type OrderItem = {
@@ -66,55 +86,13 @@ function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount); return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
} }
// Icons // Icon wrappers (lucide-react) — kept compact for inline use.
const Icons = { const MapPinIcon = () => <MapPin className="h-4 w-4" strokeWidth={2} />;
mapPin: (className: string) => ( const CheckIcon = () => <Check className="h-4 w-4" strokeWidth={2} />;
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> const XIcon = () => <X className="h-3 w-3" strokeWidth={2} />;
<path d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/> const ChevronDownIcon = () => <ChevronDown className="h-4 w-4" strokeWidth={2} />;
<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"/> const ChevronLeftIcon = () => <ChevronLeft className="h-4 w-4" strokeWidth={2} />;
</svg> const ChevronRightIcon = () => <ChevronRight className="h-4 w-4" strokeWidth={2} />;
),
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>
),
};
export default function AdminOrdersPanel({ export default function AdminOrdersPanel({
initialOrders, initialOrders,
@@ -355,12 +333,23 @@ export default function AdminOrdersPanel({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)]"> <div
{Icons.package("h-5 w-5 text-white")} 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>
<div> <div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Orders</h2> <h2
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]"> 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" : ""} {filteredOrders.length} order{filteredOrders.length !== 1 ? "s" : ""}
</p> </p>
</div> </div>
@@ -368,7 +357,7 @@ export default function AdminOrdersPanel({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{brandId && ( {brandId && (
<AdminBadge variant="info">Brand scoped</AdminBadge> <AdminBadge tone="info">Brand scoped</AdminBadge>
)} )}
<AdminButton <AdminButton
onClick={openNewOrderModal} onClick={openNewOrderModal}
@@ -382,18 +371,21 @@ export default function AdminOrdersPanel({
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6"> <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"> <KPIStat
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p> label="Total"
<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> value={isLoading ? <Skeleton variant="text" className="w-16 h-8" /> : orders.length}
</div> tone="default"
<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> <KPIStat
<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> label="Pending"
</div> value={isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pendingCount}
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4"> tone="warning"
<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> <KPIStat
</div> label="Picked Up"
value={isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pickedUpCount}
tone="primary"
/>
</div> </div>
{/* Filters */} {/* Filters */}
@@ -423,40 +415,92 @@ export default function AdminOrdersPanel({
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowStopDropdown(!showStopDropdown)} 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 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> <span>Stops</span>
{selectedStops.length > 0 && ( {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> </button>
{showStopDropdown && ( {showStopDropdown && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setShowStopDropdown(false)} /> <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
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3"> className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border shadow-lg"
<span className="text-sm font-semibold text-stone-700">Filter by Stop</span> style={{
<button onClick={clearStops} className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] font-medium">Clear all</button> 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>
<div className="max-h-64 overflow-y-auto p-2"> <div className="max-h-64 overflow-y-auto p-2">
{stops.map((stop) => ( {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 <input
type="checkbox" type="checkbox"
checked={selectedStops.includes(stop.id)} checked={selectedStops.includes(stop.id)}
onChange={() => toggleStop(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"> <div className="flex-1">
<span className="text-sm font-medium text-stone-700">{stop.city}, {stop.state}</span> <span
<span className="ml-2 text-xs text-stone-400">{formatDate(stop.date)}</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> </div>
</label> </label>
))} ))}
@@ -474,10 +518,23 @@ export default function AdminOrdersPanel({
const stop = stops.find((s) => s.id === stopId); const stop = stops.find((s) => s.id === stopId);
if (!stop) return null; if (!stop) return null;
return ( 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} {stop.city}, {stop.state}
<button onClick={() => toggleStop(stopId)} className="ml-1 hover:text-[var(--admin-accent-hover)]"> <button
{Icons.x("h-3 w-3")} onClick={() => toggleStop(stopId)}
className="ml-1"
style={{ color: "var(--admin-accent-hover)" }}
aria-label={`Remove ${stop.city}, ${stop.state}`}
>
<XIcon />
</button> </button>
</span> </span>
); );
@@ -487,14 +544,24 @@ export default function AdminOrdersPanel({
{/* Bulk actions bar */} {/* Bulk actions bar */}
{selectedOrders.size > 0 && ( {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"> <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 {selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} selected
</span> </span>
<button <button
onClick={() => setSelectedOrders(new Set())} 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 Clear
</button> </button>
@@ -504,7 +571,7 @@ export default function AdminOrdersPanel({
size="sm" size="sm"
onClick={handleBulkMarkPickup} onClick={handleBulkMarkPickup}
isLoading={bulkMarkingUp} isLoading={bulkMarkingUp}
icon={Icons.check("h-4 w-4")} icon={<CheckIcon />}
> >
Mark All as Picked Up Mark All as Picked Up
</AdminButton> </AdminButton>
@@ -513,7 +580,13 @@ export default function AdminOrdersPanel({
{/* Orders Table */} {/* Orders Table */}
{isLoading ? ( {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"> <div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4"> <div key={i} className="flex items-center gap-4">
@@ -526,80 +599,184 @@ export default function AdminOrdersPanel({
</div> </div>
</div> </div>
) : paginatedOrders.length === 0 ? ( ) : paginatedOrders.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-[var(--admin-border)] bg-white"> <div
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4"> className="rounded-xl border"
{Icons.package("h-8 w-8 text-stone-400")} style={{
</div> borderColor: "var(--admin-border)",
<p className="text-sm font-medium text-stone-600">No orders found</p> backgroundColor: "var(--admin-card-bg)",
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p> }}
>
<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>
) : ( ) : (
<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]"> <table className="w-full text-sm min-w-[700px]">
<thead className="bg-stone-50"> <thead style={{ backgroundColor: "var(--admin-bg)" }}>
<tr className="border-b border-[var(--admin-border)]"> <tr style={{ borderBottom: "1px solid var(--admin-border)" }}>
<th className="w-10 px-4 py-3"> <th className="w-10 px-4 py-3">
<input <input
type="checkbox" type="checkbox"
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0} checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
onChange={toggleSelectAll} 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>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Order</th> <th
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Customer</th> className="text-left px-4 py-3 font-semibold text-xs"
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden md:table-cell">Stop</th> style={{ color: "var(--admin-text-muted)" }}
<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> Order
<th className="text-right px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Total</th> </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)" }}
>
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> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--admin-border)]"> <tbody style={{ borderColor: "var(--admin-border)" }}>
{paginatedOrders.map((order) => ( {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"> <td className="px-4 py-3">
<input <input
type="checkbox" type="checkbox"
checked={selectedOrders.has(order.id)} checked={selectedOrders.has(order.id)}
onChange={() => toggleOrderSelection(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>
<td className="px-4 py-3"> <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)} {shortId(order.id)}
</Link> </Link>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="font-medium text-[var(--admin-text-primary)]">{order.customer_name}</div> <div
{order.customer_phone && <div className="font-mono text-xs text-stone-400">{order.customer_phone}</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>
<td className="px-4 py-3 hidden md:table-cell"> <td className="px-4 py-3 hidden md:table-cell">
{order.stops ? ( {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>
<td className="px-4 py-3 hidden lg:table-cell"> <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>
<td className="px-4 py-3 text-center"> <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>
<td className="px-4 py-3 text-right"> <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>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{order.pickup_complete ? ( {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" && ( {order.payment_processor === "square" && (
<AdminBadge variant="info">Square</AdminBadge> <AdminBadge tone="info">Square</AdminBadge>
)} )}
{!order.pickup_complete && ( {!order.pickup_complete && (
<AdminButton <AdminButton
@@ -623,8 +800,14 @@ export default function AdminOrdersPanel({
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]"> <div
<p className="text-xs text-[var(--admin-text-muted)]"> 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} Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, filteredOrders.length)} of {filteredOrders.length}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -636,9 +819,14 @@ export default function AdminOrdersPanel({
disabled={page === 0} disabled={page === 0}
className="!rounded-lg" className="!rounded-lg"
> >
{Icons.chevronLeft("h-4 w-4")} <ChevronLeftIcon />
</AdminIconButton> </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 <AdminIconButton
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -647,7 +835,7 @@ export default function AdminOrdersPanel({
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}
className="!rounded-lg" className="!rounded-lg"
> >
{Icons.chevronRight("h-4 w-4")} <ChevronRightIcon />
</AdminIconButton> </AdminIconButton>
</div> </div>
</div> </div>
@@ -656,19 +844,54 @@ export default function AdminOrdersPanel({
{/* New Order Modal (admin manual entry) - sibling to the main panel content */} {/* New Order Modal (admin manual entry) - sibling to the main panel content */}
{showNewOrderModal && ( {showNewOrderModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-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 border-[var(--admin-border)] bg-white shadow-2xl"> <div
<div className="flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-4"> 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> <div>
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">New Order (Admin)</h3> <h3
<p className="text-xs text-[var(--admin-text-muted)]">Manual entry for testing / phone orders</p> 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> </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>
<div className="p-6 space-y-5 max-h-[70vh] overflow-auto"> <div className="p-6 space-y-5 max-h-[70vh] overflow-auto">
{newOrderError && ( {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 */} {/* Customer */}
@@ -715,8 +938,18 @@ export default function AdminOrdersPanel({
{/* Items */} {/* Items */}
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="block text-xs font-semibold text-[var(--admin-text-secondary)]">Items</label> <label
<span className="text-xs text-[var(--admin-text-muted)]">Total: ${newOrderTotal.toFixed(2)}</span> 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> </div>
{products.length > 0 ? ( {products.length > 0 ? (
@@ -733,20 +966,41 @@ export default function AdminOrdersPanel({
/> />
</div> </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 && ( {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 && ( {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) => { {newOrderItems.map((item, idx) => {
const prod = products.find((p) => p.id === item.product_id); const prod = products.find((p) => p.id === item.product_id);
return ( return (
<div key={idx} className="grid grid-cols-12 gap-2 items-center text-sm"> <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"> <div className="col-span-2">
<input <input
type="number" type="number"
@@ -754,6 +1008,11 @@ export default function AdminOrdersPanel({
value={item.quantity} value={item.quantity}
onChange={(e) => updateNewOrderItem(idx, { quantity: Math.max(1, parseInt(e.target.value) || 1) })} onChange={(e) => updateNewOrderItem(idx, { quantity: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full rounded border px-2 py-1 text-sm" 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>
<div className="col-span-2"> <div className="col-span-2">
@@ -763,6 +1022,11 @@ export default function AdminOrdersPanel({
value={item.price} value={item.price}
onChange={(e) => updateNewOrderItem(idx, { price: parseFloat(e.target.value) || 0 })} onChange={(e) => updateNewOrderItem(idx, { price: parseFloat(e.target.value) || 0 })}
className="w-full rounded border px-2 py-1 text-sm" 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>
<div className="col-span-2"> <div className="col-span-2">
@@ -770,13 +1034,25 @@ export default function AdminOrdersPanel({
value={item.fulfillment} value={item.fulfillment}
onChange={(e) => updateNewOrderItem(idx, { fulfillment: e.target.value as "pickup" | "ship" })} onChange={(e) => updateNewOrderItem(idx, { fulfillment: e.target.value as "pickup" | "ship" })}
className="w-full rounded border px-2 py-1 text-sm" 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="pickup">pickup</option>
<option value="ship">ship</option> <option value="ship">ship</option>
</select> </select>
</div> </div>
<div className="col-span-1 text-right"> <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>
</div> </div>
); );
@@ -786,7 +1062,13 @@ export default function AdminOrdersPanel({
</div> </div>
</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}> <AdminButton variant="secondary" onClick={closeNewOrderModal} disabled={newOrderSubmitting}>
Cancel Cancel
</AdminButton> </AdminButton>
+323 -322
View File
@@ -1,226 +1,146 @@
"use client"; "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 Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { 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 { signOutAction } from "@/actions/auth-actions";
import BrandSelector from "@/components/admin/BrandSelector"; import BrandSelector from "@/components/admin/BrandSelector";
import SideNavGroup from "@/components/admin/SideNavGroup";
// Elegant warm sidebar design // Sidebar tokens used (from src/styles/admin-design-system.css):
// Colors: parchment 100 bg, soft linen text, powder petal accent // --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 = { type IconComponent = ComponentType<{ className?: string; size?: number | string }>;
href?: string;
type NavItemDef = {
href: string;
label: string; label: string;
icon?: string; icon: IconComponent;
divider?: boolean; /** 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[] = [ type NavGroup = {
// Main label: string;
{ href: "/admin", label: "Dashboard", icon: "grid" }, items: NavItemDef[];
{ 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" }, * Grouped nav IA per `docs/superpowers/specs/2026-06-17-admin-redesign.md` §4.
// Settings section * Order is intentional (top-to-bottom = frequency-of-use + mental flow).
{ divider: true, label: "Settings" }, *
{ href: "/admin/settings", label: "General", icon: "settings" }, * - Workspace : Dashboard is always shown; Command Center is platform_admin-only.
{ href: "/admin/time-tracking", label: "Workers & PINs", icon: "users" }, * - Operations : Day-to-day order/stop/product flow.
{ href: "/admin/time-tracking?tab=tasks", label: "Tasks", icon: "clipboard" }, * - Communications: Harvest Reach only — the other sub-items live inside that page.
{ href: "/admin/users", label: "Users & Permissions", icon: "shield" }, * - Growth : Wholesale portal, data imports, AI assistant.
{ href: "/admin/settings/integrations", label: "Integrations", icon: "plug" }, * - Tracking : Time tracking is always on; Water Log / Route Trace are
{ href: "/admin/settings/billing", label: "Billing", icon: "billing" }, * 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 = { type SidebarProps = {
userRole?: string | null; userRole?: string | null;
brandIds?: string[]; brandIds?: string[];
activeBrandId?: string | null; activeBrandId?: string | null;
brands?: { id: string; name: string; slug: string; logo_url: 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({ export default function AdminSidebar({
@@ -228,6 +148,7 @@ export default function AdminSidebar({
brandIds, brandIds,
activeBrandId, activeBrandId,
brands, brands,
enabledAddons,
}: SidebarProps) { }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
@@ -237,15 +158,52 @@ export default function AdminSidebar({
const mobileMenuRef = useRef<HTMLDivElement>(null); const mobileMenuRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null); const closeButtonRef = useRef<HTMLButtonElement>(null);
const roleLabel = userRole === "platform_admin" ? "Platform Admin" const roleLabel =
: userRole === "brand_admin" ? "Brand Admin" userRole === "platform_admin"
: userRole === "store_employee" ? "Store Employee" ? "Platform Admin"
: null; : userRole === "brand_admin"
? "Brand Admin"
: userRole === "store_employee"
? "Store Employee"
: null;
const isActive = useCallback((href: string) => { /**
if (href === "/admin") return pathname === "/admin"; * Build the filtered list of visible nav items for the current viewer.
return pathname.startsWith(href); * Honors role-gating (Command Center) and add-on gating (Water Log, Route Trace).
}, [pathname]); * 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 // Close mobile menu with animation
const closeMobileMenu = useCallback(() => { const closeMobileMenu = useCallback(() => {
@@ -283,31 +241,106 @@ export default function AdminSidebar({
}; };
}, [mobileOpen]); }, [mobileOpen]);
// Keyboard navigation for nav items // Keyboard navigation for nav items (arrow up/down loops, enter/space activates)
const handleNavKeyDown = useCallback((e: KeyboardEvent<HTMLAnchorElement>, href: string, index: number) => { const handleNavKeyDown = useCallback(
const navLinks = NAV_ITEMS.filter(item => !item.divider); (e: KeyboardEvent<HTMLAnchorElement>, href: string, index: number) => {
const total = visibleItems.length;
if (total === 0) return;
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
const nextIndex = (index + 1) % navLinks.length; const nextIndex = (index + 1) % total;
const nextLink = document.querySelector(`[data-nav-index="${nextIndex}"]`) as HTMLAnchorElement; const nextLink = document.querySelector(
nextLink?.focus(); `[data-nav-index="${nextIndex}"]`,
} else if (e.key === "ArrowUp") { ) as HTMLAnchorElement | null;
e.preventDefault(); nextLink?.focus();
const prevIndex = index === 0 ? navLinks.length - 1 : index - 1; } else if (e.key === "ArrowUp") {
const prevLink = document.querySelector(`[data-nav-index="${prevIndex}"]`) as HTMLAnchorElement; e.preventDefault();
prevLink?.focus(); const prevIndex = index === 0 ? total - 1 : index - 1;
} else if (e.key === "Enter" || e.key === " ") { const prevLink = document.querySelector(
e.preventDefault(); `[data-nav-index="${prevIndex}"]`,
router.push(href); ) as HTMLAnchorElement | null;
if (mobileOpen) closeMobileMenu(); prevLink?.focus();
} } else if (e.key === "Enter" || e.key === " ") {
}, [router, mobileOpen, closeMobileMenu]); e.preventDefault();
router.push(href);
if (mobileOpen) closeMobileMenu();
}
},
[router, mobileOpen, closeMobileMenu, visibleItems.length],
);
async function handleLogout() { async function handleLogout() {
await signOutAction(); 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 ( return (
<> <>
{/* Mobile hamburger button - accessible */} {/* Mobile hamburger button - accessible */}
@@ -319,7 +352,7 @@ export default function AdminSidebar({
aria-controls="admin-sidebar" aria-controls="admin-sidebar"
type="button" type="button"
> >
<HamburgerIcon /> <Menu className="w-5 h-5" aria-hidden="true" />
</button> </button>
{/* Mobile overlay backdrop with blur */} {/* 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 <aside
ref={sidebarRef} ref={sidebarRef}
id="admin-sidebar" id="admin-sidebar"
className={[ 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", "border-r flex flex-col",
"transition-transform duration-300 ease-out lg:translate-x-0", "transition-transform duration-300 ease-out lg:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full", mobileOpen ? "translate-x-0" : "-translate-x-full",
isClosing ? "opacity-90" : "opacity-100" isClosing ? "opacity-90" : "opacity-100",
].join(" ")} ].join(" ")}
style={{ style={{
backgroundColor: "var(--admin-sidebar-bg)", backgroundColor: "var(--admin-sidebar-bg)",
borderColor: "rgba(208, 203, 180, 0.2)" borderColor: "rgba(208, 203, 180, 0.2)",
}} }}
role="navigation" role="navigation"
aria-label="Admin navigation" aria-label="Admin navigation"
@@ -364,11 +397,9 @@ export default function AdminSidebar({
> >
<div <div
className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm transition-transform hover:scale-105" 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"> <ArrowLeft className="h-5 w-5 text-white" 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>
</div> </div>
<span className="text-sm font-semibold tracking-tight">Admin</span> <span className="text-sm font-semibold tracking-tight">Admin</span>
</Link> </Link>
@@ -381,101 +412,66 @@ export default function AdminSidebar({
aria-label="Close navigation menu" aria-label="Close navigation menu"
type="button" type="button"
> >
<CloseIcon /> <X className="w-5 h-5" aria-hidden="true" />
</button> </button>
</div> </div>
</div> </div>
{/* Back to site link */} {/* 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 <Link
href="/" href="/"
className="text-xs transition-colors flex items-center gap-1.5 hover:text-white" className="text-xs transition-colors flex items-center gap-1.5 hover:text-white"
style={{ color: "var(--admin-sidebar-text)" }} 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"> <ArrowLeft className="w-3.5 h-3.5" aria-hidden="true" />
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
Back to Site Back to Site
</Link> </Link>
</div> </div>
{/* Nav links with keyboard navigation */} {/* Nav groups with keyboard navigation */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin"> <nav
<ul className="space-y-1" role="list"> ref={mobileMenuRef}
{NAV_ITEMS.map((item, index) => { className="flex-1 overflow-y-auto overflow-x-hidden pt-3 pb-2 scrollbar-thin"
// Divider with optional label >
if (item.divider) { {visibleGroups.map((group) => (
return ( <SideNavGroup key={group.label} label={group.label}>
<li key={`divider-${index}`} role="separator" aria-hidden="true"> {group.items.map((item) => {
<div className="flex items-center gap-2 px-3 py-3 mt-2"> const idx = visibleItems.findIndex((v) => v.href === item.href);
<span className="text-[10px] font-semibold uppercase tracking-widest" style={{ color: "rgba(195, 195, 193, 0.5)" }}> return renderNavLink(item, idx);
{item.label} })}
</span> </SideNavGroup>
</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> </nav>
{/* Bottom: brand picker + role + sign out */} {/* Bottom: command-palette tip + brand picker + role + sign out */}
<div <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)" }} 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 */} {/* Brand selector — only show when admin has access to brands */}
{brands && brands.length > 0 && ( {brands && brands.length > 0 && (
<BrandSelector <BrandSelector
@@ -491,13 +487,18 @@ export default function AdminSidebar({
className="px-3 py-2.5 rounded-xl border" className="px-3 py-2.5 rounded-xl border"
style={{ style={{
backgroundColor: "rgba(208, 203, 180, 0.1)", 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} {roleLabel}
</p> </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> </div>
)} )}
<button <button
@@ -507,7 +508,7 @@ export default function AdminSidebar({
aria-label="Sign out of admin" aria-label="Sign out of admin"
type="button" type="button"
> >
<LogoutIcon /> <LogOut className="w-4 h-4" aria-hidden="true" />
Sign out Sign out
</button> </button>
</div> </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>
);
}
+318 -318
View File
@@ -3,7 +3,8 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import dynamic from "next/dynamic"; 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"; import type { DashboardStats } from "@/actions/dashboard";
// Lazy-load the upgrade modal — only needed when starter tier + brandId present // Lazy-load the upgrade modal — only needed when starter tier + brandId present
@@ -20,69 +21,35 @@ type Section = {
addonKey?: string; addonKey?: string;
upgradeText?: string; upgradeText?: string;
prominent?: boolean; prominent?: boolean;
icon: LucideIcon;
}; };
const sections: Section[] = [ const sections: Section[] = [
{ title: "Orders", href: "/admin/orders", description: "View orders, pickup status, fulfillment, and customer details.", group: "operations", prominent: true }, { 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 }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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 }[] = [ const GROUP_FILTERS: { id: GroupFilter; label: string }[] = [
{ { id: "all", label: "All" },
id: "operations", { id: "operations", label: "Operations" },
label: "Operations", { id: "fulfillment", label: "Fulfillment" },
icon: ( { id: "management", label: "Management" },
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> { id: "tools", label: "Tools" },
<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>
),
},
]; ];
type Props = { type Props = {
@@ -96,6 +63,15 @@ type Props = {
stats: DashboardStats; stats: DashboardStats;
}; };
type AttentionItem = {
id: string;
title: string;
detail: string;
href: string;
tone: "primary" | "accent" | "warning" | "danger";
icon: LucideIcon;
};
export default function DashboardClient({ export default function DashboardClient({
brandId, brandId,
brandName, brandName,
@@ -106,11 +82,8 @@ export default function DashboardClient({
limits, limits,
stats, stats,
}: Props) { }: Props) {
const [activeTab, setActiveTab] = useState<Tab>("operations");
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false); const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
const [groupFilter, setGroupFilter] = useState<GroupFilter>("all");
// Stats are pre-fetched server-side — no loading state needed.
// All values are guaranteed to be present before this component renders.
const usagePct = { const usagePct = {
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0, users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
@@ -118,42 +91,90 @@ export default function DashboardClient({
products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0, products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0,
}; };
const tabSections = sections.filter((s) => s.group === activeTab);
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount); return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
}; };
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
const styles: Record<string, { bg: string; text: string }> = { const styles: Record<string, { bg: string; text: string }> = {
pending: { bg: "var(--admin-warning-light)", text: "var(--admin-warning)" }, pending: { bg: "var(--admin-warning-soft)", text: "var(--admin-warning)" },
processing: { bg: "var(--admin-accent-light)", text: "var(--admin-accent-text)" }, processing: { bg: "var(--admin-primary-soft)", text: "var(--admin-accent-text)" },
shipped: { bg: "#dbeafe", text: "#1e40af" }, shipped: { bg: "var(--admin-primary-soft)", text: "var(--admin-primary)" },
}; };
return styles[status] || { bg: "var(--admin-bg)", text: "var(--admin-text-secondary)" }; 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 = [ const quickActions = [
{ label: "New Order", href: "/admin/orders?new=true", icon: "plus" }, { label: "New Order", href: "/admin/orders?new=true", icon: Plus },
{ label: "Add Stop", href: "/admin/stops/new", icon: "map" }, { label: "Add Stop", href: "/admin/stops/new", icon: MapPin },
{ label: "Add Product", href: "/admin/products/new", icon: "package" }, { label: "Add Product", href: "/admin/products/new", icon: Package },
{ label: "Send Blast", href: "/admin/communications/compose", icon: "mail" }, { 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 ( return (
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}> <div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
{/* Page Header */} {/* Page Header */}
<div className="px-4 sm:px-6 md:px-8 py-5 sm:py-6"> <div className="px-4 sm:px-6 md:px-8 py-5 sm:py-6">
<PageHeader <PageHeader
icon={ icon={<Inbox className="h-5 w-5" />}
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> title="Dashboard"
<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"
subtitle={brandName} subtitle={brandName}
actions={ actions={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -174,154 +195,157 @@ export default function DashboardClient({
{/* Main Content */} {/* Main Content */}
<div className="px-4 sm:px-6 md:px-8 pb-8 space-y-5"> <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" }}> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 animate-fade-up" style={{ animationDelay: "0ms" }}>
{/* Today's Orders */} <KPIStat
<div className="admin-stat-card"> label="Today's Orders"
<div className="admin-stat-card-inner"> value={stats.todayOrders}
<div className="admin-stat-icon" style={{ backgroundColor: "var(--admin-accent-light)" }}> icon={<ShoppingCart className="w-4 h-4" />}
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> tone="primary"
<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> <KPIStat
</div> label="Today's Revenue"
<div className="admin-stat-body"> value={formatCurrency(stats.todayRevenue)}
<span className="admin-stat-label">Today&apos;s Orders</span> icon={<DollarSign className="w-4 h-4" />}
<span className="admin-stat-value">{stats.todayOrders}</span> tone="accent"
</div> />
</div> <KPIStat
</div> label="Pending Stops"
value={stats.pendingStops}
{/* Today's Revenue */} icon={<MapPin className="w-4 h-4" />}
<div className="admin-stat-card"> tone="default"
<div className="admin-stat-card-inner"> />
<div className="admin-stat-icon" style={{ backgroundColor: "#fef3c7" }}> <KPIStat
<svg className="w-4 h-4 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> label="Active Products"
<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" /> value={usage.products}
</svg> icon={<Package className="w-4 h-4" />}
</div> tone="primary"
<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: "#dbeafe" }}>
<svg className="w-4 h-4 text-blue-600" 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: "#f3e8ff" }}>
<svg className="w-4 h-4 text-purple-600" 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>
</div> </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" }}> <div className="grid grid-cols-1 lg:grid-cols-3 gap-3 animate-fade-up" style={{ animationDelay: "60ms" }}>
{/* Quick Actions */} {/* Attention feed (left, 2 cols) */}
<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 */}
<div className="lg:col-span-2 admin-card-section"> <div className="lg:col-span-2 admin-card-section">
<div className="admin-section-header"> <div className="admin-section-header">
<div className="flex items-center gap-2"> <span className="admin-section-title">What needs attention</span>
<AdminBadge variant={planTier === "enterprise" ? "warning" : planTier === "farm" ? "success" : "default"}> <span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
{planTier.charAt(0).toUpperCase() + planTier.slice(1)} {attentionItems.length} item{attentionItems.length === 1 ? "" : "s"}
</AdminBadge> </span>
<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>
</div> </div>
<div className="admin-usage-grid"> {attentionItems.length === 0 ? (
{[ <EmptyState
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users, icon: "users" }, icon={<Sparkles className="w-8 h-8" />}
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops, icon: "map" }, title="All clear"
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products, icon: "package" }, description="Nothing needs your attention right now. Enjoy the calm."
].map(({ label, value, pct }) => ( />
<div key={label} className="admin-usage-item"> ) : (
<div className="admin-usage-header"> <ul className="divide-y" style={{ borderColor: "var(--admin-border-light)" }}>
<span className="admin-usage-label">{label}</span> {attentionItems.map((item) => {
<span className="admin-usage-value ha-num">{value}</span> const Icon = item.icon;
</div> const toneColor =
<div className="admin-usage-bar"> item.tone === "accent" ? "var(--admin-accent)" :
<div item.tone === "warning" ? "var(--admin-warning)" :
className="admin-usage-fill" item.tone === "danger" ? "var(--admin-danger)" :
style={{ "var(--admin-primary)";
width: `${Math.min(pct, 100)}%`, const toneSoft =
backgroundColor: pct > 90 ? "var(--admin-danger)" : pct > 75 ? "#f59e0b" : "var(--admin-accent)", item.tone === "accent" ? "var(--admin-accent-soft)" :
}} item.tone === "warning" ? "var(--admin-warning-soft)" :
/> item.tone === "danger" ? "var(--admin-danger-soft)" :
</div> "var(--admin-primary-soft)";
{pct > 85 && ( return (
<span className="admin-usage-warning">Near limit</span> <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>
<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> </div>
</div> </div>
@@ -329,8 +353,8 @@ export default function DashboardClient({
{/* ── Recent Orders ─────────────────────────────────────── */} {/* ── Recent Orders ─────────────────────────────────────── */}
<div className="admin-card-section animate-fade-up" style={{ animationDelay: "120ms" }}> <div className="admin-card-section animate-fade-up" style={{ animationDelay: "120ms" }}>
<div className="admin-section-header"> <div className="admin-section-header">
<span className="admin-section-title">Recent Orders</span> <span className="admin-section-title">Recent orders</span>
<Link href="/admin/orders" className="text-xs font-medium hover:underline" style={{ color: "var(--admin-accent)" }}> <Link href="/admin/orders" className="text-xs font-medium hover:underline" style={{ color: "var(--admin-primary)" }}>
View all View all
</Link> </Link>
</div> </div>
@@ -346,9 +370,7 @@ export default function DashboardClient({
> >
<div className="admin-order-info"> <div className="admin-order-info">
<div className="admin-order-icon"> <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}> <ShoppingCart className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} />
<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>
<div> <div>
<span className="admin-order-name">{order.customer_name}</span> <span className="admin-order-name">{order.customer_name}</span>
@@ -366,108 +388,86 @@ export default function DashboardClient({
})} })}
</div> </div>
) : ( ) : (
<div className="admin-orders-empty"> <EmptyState
<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}> icon={<Inbox className="w-8 h-8" />}
<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" /> title="No recent orders"
</svg> description="When customers place orders, they'll show up here."
<p className="text-sm" style={{ color: "var(--admin-text-muted)" }}>No recent orders</p> action={{ label: "Create your first order", href: "/admin/orders?new=true" }}
<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>
)} )}
</div> </div>
{/* ── Section Navigation Tabs ───────────────────────────── */} {/* ── Section filter + grid ────────────────────────────── */}
<div className="animate-fade-up" style={{ animationDelay: "180ms" }}> <div className="animate-fade-up" style={{ animationDelay: "180ms" }}>
<AdminFilterTabs <div className="flex items-center justify-between gap-3 mb-3">
activeTab={activeTab} <div>
onTabChange={(value) => setActiveTab(value as Tab)} <h2 className="text-base font-semibold" style={{ color: "var(--admin-text-primary)" }}>
tabs={TABS.map((tab) => ({ value: tab.id, label: tab.label, icon: tab.icon }))} All sections
size="md" </h2>
showCounts={false} <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.
</div> </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 ─────────────────────────────────── */} return (
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3 animate-fade-up" style={{ animationDelay: "240ms" }}> <Link
{tabSections.map((section) => { key={section.title}
if (section.title === "Water Log" && !isWaterLogVisible) return null; href={section.href}
if (section.title === "Route Trace" && !enabledAddons["route_trace"]) return null; className={[
"admin-section-card",
const isAddon = Boolean(section.addonKey); isAddon && !isEnabled ? "admin-section-card--locked" : "",
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true; isProminent ? "admin-section-card--prominent" : "",
const isProminent = section.prominent; ].filter(Boolean).join(" ")}
>
return ( <div className="admin-section-card-top">
<Link <div className={[
key={section.title} "admin-section-card-icon",
href={section.href} isAddon && !isEnabled ? "admin-section-card-icon--locked" : "",
className={[ isProminent ? "admin-section-card-icon--prominent" : "",
"admin-section-card", ].filter(Boolean).join(" ")}>
isAddon && !isEnabled ? "admin-section-card--locked" : "", <Icon className="w-4 h-4" />
isProminent ? "admin-section-card--prominent" : "", </div>
].filter(Boolean).join(" ")} <div className="flex items-center gap-1.5">
> {isAddon && !isEnabled && <AdminBadge tone="neutral">Add-on</AdminBadge>}
<div className="admin-section-card-top"> {isAddon && isEnabled && <AdminBadge tone="success">Active</AdminBadge>}
<div className={[ {isProminent && <AdminBadge tone="info">Core</AdminBadge>}
"admin-section-card-icon", </div>
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>
)}
</div> </div>
<div className="flex items-center gap-1.5">
{isAddon && !isEnabled && <AdminBadge variant="warning">Add-on</AdminBadge>} <div className="admin-section-card-body">
{isAddon && isEnabled && <AdminBadge variant="success">Active</AdminBadge>} <h3 className="admin-section-card-title">{section.title}</h3>
{isProminent && <AdminBadge variant="success">Core</AdminBadge>} <p className={["admin-section-card-desc", isAddon && !isEnabled ? "" : ""].filter(Boolean).join(" ")}>
{section.description}
</p>
</div> </div>
</div>
<div className="admin-section-card-body"> {isAddon && !isEnabled && section.upgradeText && (
<h3 className="admin-section-card-title">{section.title}</h3> <p className="admin-section-card-hint">{section.upgradeText}</p>
<p className={["admin-section-card-desc", isAddon && !isEnabled ? "text-stone-400" : ""].filter(Boolean).join(" ")}> )}
{section.description}
</p>
</div>
{isAddon && !isEnabled && section.upgradeText && ( <div className="admin-section-card-arrow">
<p className="admin-section-card-hint">{section.upgradeText}</p> <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 className="admin-section-card-arrow"> </div>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> </Link>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /> );
</svg> })}
</div> </div>
</Link>
);
})}
</div> </div>
</div> </div>
+2 -2
View File
@@ -185,8 +185,8 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
{error && ( {error && (
<div <div
role="alert" role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-red-700" 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}> <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" /> <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 ( return (
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6"> <form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
{error && ( {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} {error}
</div> </div>
)} )}
@@ -182,7 +182,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
<AdminInput label="Brand" required> <AdminInput label="Brand" required>
{lockBrand ? ( {lockBrand ? (
// brand_admin / store_employee — brand is fixed by their admin_users record // 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} {brandOptions.find((b) => b.id === brandId)?.name ?? brandId}
</div> </div>
) : ( ) : (
@@ -197,7 +197,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
/> />
)} )}
{!lockBrand && brands.length === 0 && ( {!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. Loading available brands if this persists, check the admin Brands settings.
</p> </p>
)} )}
@@ -237,8 +237,8 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
</AdminInput> </AdminInput>
<div> <div>
<label className="block text-sm font-medium text-zinc-300">Product Image</label> <label className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</label>
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p> <p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div <div
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
@@ -250,28 +250,28 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className={` className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors 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 ? "opacity-50 pointer-events-none" : ""}
`} `}
> >
{uploading ? ( {uploading ? (
<> <>
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" /> <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-zinc-500">Uploading...</span> <span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
</> </>
) : imagePreview ? ( ) : imagePreview ? (
<> <>
<span className="relative inline-block h-32 w-auto"> <span className="relative inline-block h-32 w-auto">
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" /> <NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</span> </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" /> <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> </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 <input
@@ -286,31 +286,32 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
/> />
</div> </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 && ( {imagePreview && (
<button <button
type="button" type="button"
onClick={() => { setImagePreview(null); setPendingImageUrl(""); }} 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 Remove image
</button> </button>
)} )}
</div> </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 <button
type="submit" type="submit"
disabled={loading} 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"} {loading ? "Creating..." : "Create Product"}
</button> </button>
<Link <Link
href="/admin/products" href="/admin/products"
className="rounded-xl border border-zinc-600 px-6 py-3 font-medium text-zinc-300" className="ha-btn-ghost"
> >
Cancel Cancel
</Link> </Link>
+117 -44
View File
@@ -198,8 +198,22 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{error && ( {error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3"> <div
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> 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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
{error} {error}
@@ -208,11 +222,17 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Order items */} {/* Order items */}
<div> <div>
<p className="mb-3 text-sm font-semibold text-stone-700"> <p className="ha-field-label mb-3">
Order Items ({visibleItems.length}) Order Items ({visibleItems.length})
</p> </p>
{visibleItems.length === 0 ? ( {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 No items
</p> </p>
) : ( ) : (
@@ -220,13 +240,23 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{visibleItems.map((item) => ( {visibleItems.map((item) => (
<div <div
key={item.id} 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"> <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 <button
onClick={() => removeItem(item.id)} 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 Remove
</button> </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 className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <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 <input
type="number" type="number"
min="1" min="1"
@@ -242,13 +272,18 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
onChange={(e) => onChange={(e) =>
updateItem(item.id, "quantity", Number(e.target.value)) 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>
<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"> <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 <input
type="number" type="number"
step="0.01" step="0.01"
@@ -257,13 +292,16 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
onChange={(e) => onChange={(e) =>
updateItem(item.id, "price", Number(e.target.value)) 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> </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)} {formatCurrency(Number(item.price) * item.quantity)}
</p> </p>
</div> </div>
@@ -280,48 +318,71 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
step="0.01" step="0.01"
value={subtotal} value={subtotal}
readOnly 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> </AdminInput>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <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"> <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 <input
type="number" type="number"
step="0.01" step="0.01"
min="0" min="0"
value={discount_amount} value={discount_amount}
onChange={(e) => setDiscount_amount(Number(e.target.value))} 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 fieldErrors.discount_amount
? "border-red-400 bg-red-50" ? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
: "border-stone-200 bg-white focus:border-[var(--admin-accent)]" : undefined
}`} }
/> />
</div> </div>
{fieldErrors.discount_amount && ( {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>
<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 <input
type="text" type="text"
value={discount_reason} value={discount_reason}
onChange={(e) => setDiscount_reason(e.target.value)} onChange={(e) => setDiscount_reason(e.target.value)}
placeholder="Optional" 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> </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"> <div className="flex justify-between">
<span className="text-sm font-medium text-stone-600">Total</span> <span
<span className="text-xl font-bold text-stone-900"> 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)} {formatCurrency(subtotal + Number(order.tax_amount ?? 0) - discount_amount)}
</span> </span>
</div> </div>
@@ -331,8 +392,9 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Customer fields */} {/* Customer fields */}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-xs font-semibold text-stone-500 mb-1.5"> <label className="ha-field-label mb-1.5">
Name <span className="text-red-500">*</span> <span>Name</span>
<span className="ha-field-label-required">*</span>
</label> </label>
<input <input
type="text" type="text"
@@ -345,14 +407,17 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
return next; 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 fieldErrors.customer_name
? "border-red-400 bg-red-50" ? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
: "border-stone-200 bg-white focus:border-[var(--admin-accent)]" : undefined
}`} }
/> />
{fieldErrors.customer_name && ( {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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <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 */} {/* Status & pickup */}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="mb-2 block text-xs font-semibold text-stone-500">Status</label> <label className="ha-field-label mb-2">Status</label>
<div className="flex gap-2"> <div className="ha-segment">
{["pending", "confirmed", "cancelled"].map((s) => ( {["pending", "confirmed", "cancelled"].map((s) => (
<button <button
key={s} key={s}
type="button" type="button"
onClick={() => setStatus(s)} onClick={() => setStatus(s)}
className={`flex-1 rounded-xl px-4 py-2.5 text-sm font-semibold capitalize transition-colors ${ className={`ha-segment-btn capitalize ${
status === s status === s ? "ha-segment-btn--active" : ""
? "bg-[var(--admin-accent)] text-white"
: "bg-white text-stone-600 border border-stone-200 hover:bg-stone-50"
}`} }`}
> >
{s} {s}
@@ -396,15 +459,24 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
</div> </div>
<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 <button
type="button" type="button"
onClick={() => setPickup_complete((v) => !v)} 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 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"} {pickup_complete ? "✓ Picked Up" : "○ Not Picked Up"}
</button> </button>
@@ -428,6 +500,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
variant="primary" variant="primary"
fullWidth fullWidth
size="lg" size="lg"
className="ha-btn-primary"
> >
{saving ? "Saving..." : "Save Changes"} {saving ? "Saving..." : "Save Changes"}
</AdminButton> </AdminButton>
+49 -28
View File
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import AdminBadge from "./design-system/AdminBadge";
import { toggleOrderPickupComplete } from "@/actions/orders"; import { toggleOrderPickupComplete } from "@/actions/orders";
type Order = { type Order = {
@@ -15,6 +16,16 @@ type Order = {
created_at: string; 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[] }) { export default function OrderTableBody({ orders }: { orders: Order[] }) {
const [pickupToggles, setPickupToggles] = useState<Record<string, boolean>>( const [pickupToggles, setPickupToggles] = useState<Record<string, boolean>>(
() => Object.fromEntries(orders.map((o) => [o.id, o.pickup_complete])) () => 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 ( return (
<tbody className="divide-y divide-slate-200"> <tbody style={{ borderColor: "var(--admin-border)" }}>
{error && ( {error && (
<tr> <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} {error}
</td> </td>
</tr> </tr>
)} )}
{orders.map((order) => ( {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"> <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)} {order.id.slice(0, 8)}
</span> </span>
</td> </td>
<td className="px-3 py-2"> <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} {order.customer_name}
</div> </div>
<div className="text-sm text-zinc-500"> <div
className="text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{order.customer_email} {order.customer_email}
</div> </div>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span <AdminBadge tone={statusToTone[order.status] ?? "neutral"}>
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
statusColors[order.status] ?? "bg-zinc-950 text-zinc-400"
}`}
>
{order.status ?? "pending"} {order.status ?? "pending"}
</span> </AdminBadge>
</td> </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)} ${Number(order.subtotal).toFixed(2)}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<button <button
type="button"
onClick={() => togglePickup(order.id, pickupToggles[order.id])} onClick={() => togglePickup(order.id, pickupToggles[order.id])}
className={`rounded-full px-2 py-0.5 text-xs font-medium ${ className="transition-opacity hover:opacity-80"
pickupToggles[order.id]
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-500"
}`}
> >
{pickupToggles[order.id] ? "Picked up" : "Pending"} <AdminBadge tone={pickupToggles[order.id] ? "success" : "neutral"} dot>
{pickupToggles[order.id] ? "Picked up" : "Pending"}
</AdminBadge>
</button> </button>
</td> </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))} {formatDate(new Date(order.created_at))}
</td> </td>
</tr> </tr>
+36 -26
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import NextImage from "next/image"; import NextImage from "next/image";
import Link from "next/link";
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { uploadProductImage, deleteProductImage } from "@/actions/products/upload-image"; import { uploadProductImage, deleteProductImage } from "@/actions/products/upload-image";
@@ -147,13 +148,13 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{error && ( {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} {error}
</div> </div>
)} )}
{saved && ( {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. Product updated successfully.
</div> </div>
)} )}
@@ -205,13 +206,13 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
</AdminInput> </AdminInput>
<div> <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 <button
onClick={() => setActive((v) => !v)} onClick={() => setActive((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${ className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
active active
? "bg-green-900/40 text-green-400" ? "bg-[var(--admin-primary-soft)] text-[var(--admin-primary)] ring-1 ring-[var(--admin-primary)]"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200" : "bg-[var(--admin-bg)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)]"
}`} }`}
> >
{active ? "Active" : "Inactive"} {active ? "Active" : "Inactive"}
@@ -219,19 +220,19 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
</div> </div>
<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 <button
onClick={() => setIs_taxable((v) => !v)} 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 ${ className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center gap-3 ${
is_taxable is_taxable
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200" ? "bg-[var(--admin-primary-soft)] text-[var(--admin-primary)] ring-1 ring-[var(--admin-primary)]"
: "bg-amber-900/30 text-amber-700 ring-1 ring-amber-200" : "bg-[var(--admin-accent-soft)] text-[var(--admin-accent)] ring-1 ring-[var(--admin-accent)]"
}`} }`}
> >
<span className="text-lg">{is_taxable ? "✓" : "✗"}</span> <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> <span>{is_taxable ? "Taxable — tax applied at checkout for nexus shipments" : "Non-taxable — always exempt from sales tax"}</span>
</button> </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> </div>
<AdminInput <AdminInput
@@ -249,8 +250,8 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
</AdminInput> </AdminInput>
<div> <div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Product Image</label> <label className="mb-1 block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</label>
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p> <p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div <div
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
@@ -262,28 +263,28 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className={` className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors 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 ? "opacity-50 pointer-events-none" : ""}
`} `}
> >
{uploading ? ( {uploading ? (
<> <>
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" /> <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-zinc-500">Uploading...</span> <span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
</> </>
) : imagePreview ? ( ) : imagePreview ? (
<> <>
<span className="relative inline-block h-32 w-auto"> <span className="relative inline-block h-32 w-auto">
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" /> <NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</span> </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" /> <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> </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 <input
@@ -298,26 +299,35 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
/> />
</div> </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 && ( {imagePreview && (
<button <button
type="button" type="button"
onClick={handleRemoveImage} 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 Remove image
</button> </button>
)} )}
</div> </div>
<button {/* Save button bar — new design tokens */}
onClick={handleSave} <div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
disabled={saving} <button
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50" onClick={handleSave}
> disabled={saving}
{saving ? "Saving..." : "Save Changes"} className="ha-btn-primary"
</button> >
{saving ? "Saving..." : "Save Changes"}
</button>
<Link
href="/admin/products"
className="ha-btn-ghost"
>
Cancel
</Link>
</div>
</div> </div>
); );
} }
+9 -7
View File
@@ -247,7 +247,7 @@ export default function ProductFormModal({
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
style={{ style={{
backgroundColor: "rgba(28, 25, 23, 0.55)", backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 55%, transparent)",
backdropFilter: "blur(8px)", backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)", WebkitBackdropFilter: "blur(8px)",
}} }}
@@ -256,7 +256,8 @@ export default function ProductFormModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={mode === "add" ? "Add product" : "Edit product"} 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 */} {/* grain overlay */}
<div className="atelier-grain" aria-hidden /> <div className="atelier-grain" aria-hidden />
@@ -357,7 +358,7 @@ export default function ProductFormModal({
{uploading ? ( {uploading ? (
<div className="flex flex-col items-center gap-3"> <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> <p className="atelier-drop-title">Pouring into the cellar</p>
</div> </div>
) : imagePreview ? ( ) : imagePreview ? (
@@ -390,7 +391,7 @@ export default function ProductFormModal({
<> <>
{/* ornamental drop zone content */} {/* ornamental drop zone content */}
<svg <svg
className="h-10 w-10 text-[#224E2F]/70" className="h-10 w-10 text-[var(--admin-primary)]/70"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -425,7 +426,7 @@ export default function ProductFormModal({
</div> </div>
{uploadError && ( {uploadError && (
<p className="mt-3 atelier-hint" style={{ color: "#B91C1C" }}> <p className="mt-3 atelier-hint" style={{ color: "var(--admin-danger)" }}>
{uploadError} {uploadError}
</p> </p>
)} )}
@@ -668,7 +669,7 @@ export default function ProductFormModal({
</form> </form>
{/* FOOTER */} {/* 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"> <div className="flex items-center justify-between gap-4">
<button <button
type="button" type="button"
@@ -685,9 +686,10 @@ export default function ProductFormModal({
className={[ className={[
"h-1.5 w-1.5 rounded-full", "h-1.5 w-1.5 rounded-full",
name.trim() && price name.trim() && price
? "bg-emerald-600 shadow-[0_0_0_3px_rgba(22,163,74,0.15)]" ? "bg-[var(--admin-primary)]"
: "bg-stone-300", : "bg-stone-300",
].join(" ")} ].join(" ")}
style={name.trim() && price ? { boxShadow: "0 0 0 3px var(--admin-primary-soft)" } : undefined}
/> />
{name.trim() && price ? "Ready" : "Required fields pending"} {name.trim() && price ? "Ready" : "Required fields pending"}
</div> </div>
+32 -28
View File
@@ -68,35 +68,35 @@ export default function ProductTableBody({
return ( return (
<> <>
{/* Filter bar — sits outside <table> in the parent */} {/* 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 <input
type="search" type="search"
placeholder="Search products..." placeholder="Search products..."
value={search} value={search}
onChange={(e) => onSearchChange(e.target.value)} 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) => ( {(["all", "active", "inactive"] as const).map((f) => (
<button <button
key={f} key={f}
onClick={() => onStatusChange(f)} onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${ className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f statusFilter === f
? "bg-slate-900 text-white" ? "bg-[var(--admin-primary)] text-white"
: "text-zinc-400 hover:bg-zinc-950" : "text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
}`} }`}
> >
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"} {f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button> </button>
))} ))}
</div> </div>
<span className="text-xs text-slate-400">{filtered.length}</span> <span className="text-xs text-[var(--admin-text-muted)]">{filtered.length}</span>
</div> </div>
{/* Delete error banner */} {/* Delete error banner */}
{deleteError && ( {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} {deleteError}
<button <button
onClick={() => setDeleteError(null)} onClick={() => setDeleteError(null)}
@@ -107,10 +107,10 @@ export default function ProductTableBody({
</div> </div>
)} )}
<tbody className="divide-y divide-slate-200"> <tbody className="divide-y divide-[var(--admin-border-light)]">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<tr> <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" {search || statusFilter !== "all"
? "No products match your search." ? "No products match your search."
: "No products found."} : "No products found."}
@@ -120,43 +120,46 @@ export default function ProductTableBody({
filtered.map((product) => ( filtered.map((product) => (
<tr <tr
key={product.id} 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"> <td className="px-3 py-2">
<Link <Link
href={`/admin/products/${product.id}`} 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} {product.name}
</Link> </Link>
<div className="text-zinc-500 line-clamp-1 text-sm"> <div className="text-[var(--admin-text-muted)] line-clamp-1 text-sm">
{product.description || <span className="italic text-slate-400">No description</span>} {product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
</div> </div>
</td> </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) {Array.isArray(product.brands)
? product.brands[0]?.name ? product.brands[0]?.name
: product.brands?.name} : product.brands?.name}
</td> </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)} ${Number(product.price).toFixed(2)}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<AdminBadge variant={product.active ? "success" : "default"} dot> <AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"} {product.active ? "Active" : "Inactive"}
</AdminBadge> </AdminBadge>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{product.is_taxable === false ? ( {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> </td>
@@ -165,7 +168,7 @@ export default function ProductTableBody({
<div className="relative inline-flex items-center justify-end gap-2"> <div className="relative inline-flex items-center justify-end gap-2">
<Link <Link
href={`/admin/products/${product.id}`} 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 Edit
</Link> </Link>
@@ -174,7 +177,7 @@ export default function ProductTableBody({
e.preventDefault(); e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id); 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"> <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"/> <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" className="fixed inset-0 z-10"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }} 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 <button
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }} 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 Delete
</button> </button>
@@ -201,21 +204,22 @@ export default function ProductTableBody({
{confirmDelete === product.id && ( {confirmDelete === product.id && (
<> <>
<div <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); }} 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"> <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-zinc-100"> <p className="text-sm font-semibold text-[var(--admin-text-primary)]">
Delete &quot;{product.name}&quot;? Delete &quot;{product.name}&quot;?
</p> </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 This will remove the product. If it is attached to any
orders, it will be hidden instead of deleted. orders, it will be hidden instead of deleted.
</p> </p>
<div className="mt-5 flex gap-3"> <div className="mt-5 flex gap-3">
<button <button
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }} 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 Cancel
</button> </button>
+97 -101
View File
@@ -9,7 +9,8 @@ import { updateProduct } from "@/actions/products/update-product";
import { deleteProduct } from "@/actions/products"; import { deleteProduct } from "@/actions/products";
import { uploadProductImage } from "@/actions/products/upload-image"; import { uploadProductImage } from "@/actions/products/upload-image";
import ProductFormModal, { type ProductFormValues } from "@/components/admin/ProductFormModal"; 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 = { type Product = {
id: string; id: string;
@@ -29,44 +30,13 @@ type ViewMode = "table" | "cards";
// Icons // Icons
const 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) => ( plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14"/> <path d="M12 5v14M5 12h14"/>
</svg> </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({ export default function ProductsClient({
products, products,
brandId, brandId,
@@ -253,34 +223,50 @@ export default function ProductsClient({
return ( return (
<div className="p-4 sm:p-6"> <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 <PageHeader
icon={<PackageIconHeader />} icon={<PackageIconLucide className="h-5 w-5" strokeWidth={1.75} />}
title="Products" title="Products"
subtitle={`${filtered.length} product${filtered.length !== 1 ? "s" : ""}`} subtitle="Manage your product catalog, pricing, and availability."
actions={ actions={
<AdminButton <AdminButton
onClick={openAddModal} onClick={openAddModal}
icon={Icons.plus("h-4 w-4")} icon={Icons.plus("h-4 w-4")}
> >
Add Product Add product
</AdminButton> </AdminButton>
} }
/> />
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-3 gap-3 mb-6"> <div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4"> <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">Total</p> <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">{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : products.length}</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>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4"> <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">Active</p> <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-accent)] mt-1">{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : activeCount}</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>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4"> <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">Inactive</p> <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-stone-400 mt-1">{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : inactiveCount}</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>
</div> </div>
@@ -320,6 +306,7 @@ export default function ProductsClient({
onDeleteConfirm={(id) => handleDelete(id)} onDeleteConfirm={(id) => handleDelete(id)}
onDeleteCancel={() => setDeleteConfirm(null)} onDeleteCancel={() => setDeleteConfirm(null)}
deletingId={deletingId} deletingId={deletingId}
onAdd={openAddModal}
/> />
) : ( ) : (
<CardView <CardView
@@ -330,6 +317,7 @@ export default function ProductsClient({
onDeleteConfirm={(id) => handleDelete(id)} onDeleteConfirm={(id) => handleDelete(id)}
onDeleteCancel={() => setDeleteConfirm(null)} onDeleteCancel={() => setDeleteConfirm(null)}
deletingId={deletingId} deletingId={deletingId}
onAdd={openAddModal}
/> />
)} )}
@@ -375,6 +363,7 @@ function TableView({
onDeleteConfirm, onDeleteConfirm,
onDeleteCancel, onDeleteCancel,
deletingId, deletingId,
onAdd,
}: { }: {
products: Product[]; products: Product[];
onEdit: (p: Product) => void; onEdit: (p: Product) => void;
@@ -383,6 +372,7 @@ function TableView({
onDeleteConfirm: (id: string) => void; onDeleteConfirm: (id: string) => void;
onDeleteCancel: () => void; onDeleteCancel: () => void;
deletingId: string | null; deletingId: string | null;
onAdd: () => void;
}) { }) {
// Track the position of the open row's three-dots button so the popup can be // 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 // 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; const openProduct = deleteConfirm ? products.find((p) => p.id === deleteConfirm) : null;
return ( 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"> <table className="w-full text-sm">
<thead className="bg-stone-50"> <thead className="bg-[var(--admin-bg)]">
<tr> <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 uppercase tracking-wider">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 uppercase tracking-wider">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 uppercase tracking-wider">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">Status</th>
<th className="px-4 py-3" /> <th className="px-4 py-3" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--admin-border)]"> <tbody className="divide-y divide-[var(--admin-border)]">
{products.length === 0 ? ( {products.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="px-4 py-16 text-center"> <td colSpan={5} className="px-4 py-8">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4"> <EmptyState
{Icons.package("h-8 w-8 text-stone-400")} icon={<InboxIcon className="h-8 w-8" strokeWidth={1.25} />}
</div> title="No products found"
<p className="text-sm font-medium text-stone-600">No products found</p> description="Try adjusting your filters, or add your first product to get started."
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p> action={{ label: "Add your first product", onClick: onAdd }}
/>
</td> </td>
</tr> </tr>
) : ( ) : (
products.map((product) => ( 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"> <td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{product.image_url ? ( {product.image_url ? (
@@ -472,40 +463,41 @@ function TableView({
/> />
</div> </div>
) : ( ) : (
<div className="h-10 w-10 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-[var(--admin-border)]"> <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-stone-400"></span> <span className="text-[var(--admin-text-muted)]"></span>
</div> </div>
)} )}
<div> <div>
<button <button
onClick={() => onEdit(product)} 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} {product.name}
</button> </button>
<div className="text-xs text-stone-500 line-clamp-1"> <div className="text-xs text-[var(--admin-text-muted)] line-clamp-1">
{product.description || <span className="italic text-stone-400">No description</span>} {product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
</div> </div>
</div> </div>
</div> </div>
</td> </td>
<td className="px-4 py-3"> <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} {product.type}
</span> </span>
</td> </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)} ${Number(product.price).toFixed(2)}
</td> </td>
<td className="px-4 py-3"> <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 ${ <AdminBadge tone={product.active ? "success" : "neutral"} dot>
product.active ? "bg-[var(--admin-accent)]/10 text-[var(--admin-accent)]" : "bg-stone-100 text-stone-500"
}`}>
{product.active ? "Active" : "Inactive"} {product.active ? "Active" : "Inactive"}
</span> </AdminBadge>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
@@ -520,7 +512,7 @@ function TableView({
<button <button
ref={(el) => { buttonRefs.current[product.id] = el; }} ref={(el) => { buttonRefs.current[product.id] = el; }}
onClick={() => onDelete(product.id)} 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" aria-label="Product actions"
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -548,19 +540,19 @@ function TableView({
<div <div
role="dialog" role="dialog"
aria-label="Confirm delete" 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%)" }} 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;? Delete &quot;{openProduct.name}&quot;?
</p> </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. This will remove the product. If attached to orders, it will be hidden.
</p> </p>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<button <button
onClick={onDeleteCancel} 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 Cancel
</button> </button>
@@ -592,6 +584,7 @@ function CardView({
onDeleteConfirm, onDeleteConfirm,
onDeleteCancel, onDeleteCancel,
deletingId, deletingId,
onAdd,
}: { }: {
products: Product[]; products: Product[];
onEdit: (p: Product) => void; onEdit: (p: Product) => void;
@@ -600,25 +593,27 @@ function CardView({
onDeleteConfirm: (id: string) => void; onDeleteConfirm: (id: string) => void;
onDeleteCancel: () => void; onDeleteCancel: () => void;
deletingId: string | null; deletingId: string | null;
onAdd: () => void;
}) { }) {
return ( return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{products.length === 0 ? ( {products.length === 0 ? (
<div className="col-span-full text-center py-16 rounded-xl border border-[var(--admin-border)] bg-white"> <div className="col-span-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)]">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4"> <EmptyState
{Icons.package("h-8 w-8 text-stone-400")} icon={<InboxIcon className="h-8 w-8" strokeWidth={1.25} />}
</div> title="No products found"
<p className="text-sm font-medium text-stone-600">No products found</p> description="Try adjusting your filters, or add your first product to get started."
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p> action={{ label: "Add your first product", onClick: onAdd }}
/>
</div> </div>
) : ( ) : (
products.map((product) => ( products.map((product) => (
<div <div
key={product.id} 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 */} {/* Image */}
<div className="relative h-40 bg-stone-100"> <div className="relative h-40 bg-[var(--admin-bg-subtle)]">
{product.image_url ? ( {product.image_url ? (
<Image <Image
src={product.image_url} src={product.image_url}
@@ -630,16 +625,14 @@ function CardView({
/> />
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center"> <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> </div>
)} )}
{/* Status badge */} {/* Status badge */}
<div className="absolute top-3 right-3"> <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 ${ <AdminBadge tone={product.active ? "success" : "neutral"} dot>
product.active ? "bg-[var(--admin-accent)] text-white" : "bg-stone-500 text-white"
}`}>
{product.active ? "Active" : "Inactive"} {product.active ? "Active" : "Inactive"}
</span> </AdminBadge>
</div> </div>
</div> </div>
@@ -649,27 +642,30 @@ function CardView({
onClick={() => onEdit(product)} onClick={() => onEdit(product)}
className="block w-full text-left" 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} {product.name}
</h3> </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>} {product.description || <span className="italic">No description</span>}
</p> </p>
</button> </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> <div>
<span className="text-xs text-stone-500">Price</span> <span className="text-xs text-[var(--admin-text-muted)]">Price</span>
<p className="text-lg font-bold text-[var(--admin-text-primary)] font-mono"> <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)} ${Number(product.price).toFixed(2)}
</p> </p>
</div> </div>
<div className="flex items-center gap-1"> <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} {product.type}
</span> </span>
{product.is_taxable && ( {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 Tax
</span> </span>
)} )}
@@ -687,7 +683,7 @@ function CardView({
</AdminButton> </AdminButton>
<button <button
onClick={() => onDelete(product.id)} 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}> <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" /> <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 */} {/* Delete confirm */}
{deleteConfirm === product.id && ( {deleteConfirm === product.id && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"> <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-white rounded-xl shadow-xl p-4 max-w-[280px] mx-4"> <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-stone-900"> <p className="text-sm font-semibold text-[var(--admin-text-primary)]">
Delete &quot;{product.name}&quot;? Delete &quot;{product.name}&quot;?
</p> </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. This will remove the product. If attached to orders, it will be hidden.
</p> </p>
<div className="flex gap-2 mt-3"> <div className="flex gap-2 mt-3">
<button <button
onClick={onDeleteCancel} 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 Cancel
</button> </button>
<button <button
onClick={() => onDeleteConfirm(product.id)} onClick={() => onDeleteConfirm(product.id)}
disabled={deletingId === 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"} {deletingId === product.id ? "..." : "Delete"}
</button> </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 ? ( {loading ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="h-4 w-1/3 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-stone-200" /> <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-stone-200" /> <div className="h-4 w-1/2 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
</div> </div>
) : loadError ? ( ) : 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} {loadError}
</div> </div>
) : stop ? ( ) : stop ? (
<div className="space-y-5"> <div className="space-y-5">
{/* Tabs */} {/* Tabs */}
<div <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" role="tablist"
> >
<TabButton active={tab === "details"} onClick={() => setTab("details")}> <TabButton active={tab === "details"} onClick={() => setTab("details")}>
@@ -223,8 +229,8 @@ function DetailsPanel({
<span <span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${ className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${
stop.active stop.active
? "bg-emerald-100 text-emerald-700" ? "bg-[var(--admin-success-soft)] text-[var(--admin-success)]"
: "bg-stone-200 text-stone-500" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
}`} }`}
> >
{stop.active ? "Active" : "Inactive"} {stop.active ? "Active" : "Inactive"}
@@ -265,14 +271,14 @@ function DetailsPanel({
<button <button
type="button" type="button"
onClick={() => onDuplicate(stop.id)} 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 Duplicate Stop
</button> </button>
) : ( ) : (
<a <a
href={`/admin/stops/new?duplicate=${stop.id}`} 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 Duplicate Stop
</a> </a>
+42 -28
View File
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { getStopPendingCustomers, type StopCustomer } from "@/actions/stops/get-stop-customers"; import { getStopPendingCustomers, type StopCustomer } from "@/actions/stops/get-stop-customers";
import { sendStopBlast } from "@/actions/communications/stop-blast"; import { sendStopBlast } from "@/actions/communications/stop-blast";
import { AdminButton } from "@/components/admin/design-system";
const quickMessages = [ const quickMessages = [
{ label: "Truck running late", value: "Heads up — the truck is running about 15 minutes behind schedule. Thanks for your patience!" }, { 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-bold text-zinc-100"> <h2 className="ha-display text-2xl text-[var(--admin-text-primary)]">
Send Message Send Message
</h2> </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. Notify all customers with pending pickups at this stop.
</p> </p>
</div> </div>
@@ -96,18 +97,21 @@ export default function StopMessagingForm({
<button <button
onClick={loadCustomers} onClick={loadCustomers}
disabled={loading} 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"} {loading ? "Loading customers..." : "Load Pending Customers"}
</button> </button>
) : ( ) : (
<> <>
{customers.length === 0 ? ( {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. No pending orders for this stop yet.
</div> </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 {customers.length} pending order{customers.length !== 1 ? "s" : ""} found
{recipients.length !== customers.length && ( {recipients.length !== customers.length && (
<span> {recipients.length} with contact info</span> <span> {recipients.length} with contact info</span>
@@ -117,21 +121,21 @@ export default function StopMessagingForm({
{/* Channel */} {/* Channel */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-zinc-300"> <label className="ha-field-label mb-2">
Send via <span>Send via</span>
</label> </label>
<div className="flex gap-3"> <div className="flex gap-2">
{(["sms", "email", "both"] as const).map((ch) => ( {(["sms", "email", "both"] as const).map((ch) => (
<button <button
key={ch} key={ch}
onClick={() => setChannel(ch)} onClick={() => setChannel(ch)}
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${ className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
channel === ch channel === ch
? "bg-slate-900 text-white" ? "bg-[var(--admin-primary)] text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200" : "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> </button>
))} ))}
</div> </div>
@@ -139,8 +143,8 @@ export default function StopMessagingForm({
{/* Quick messages */} {/* Quick messages */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-zinc-300"> <label className="ha-field-label mb-2">
Quick messages <span>Quick messages</span>
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{quickMessages.map((qm) => ( {quickMessages.map((qm) => (
@@ -149,8 +153,8 @@ export default function StopMessagingForm({
onClick={() => applyQuickMessage(qm.value)} onClick={() => applyQuickMessage(qm.value)}
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${ className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
message === qm.value message === qm.value
? "bg-slate-900 text-white" ? "bg-[var(--admin-primary)] text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
}`} }`}
> >
{qm.label} {qm.label}
@@ -161,8 +165,8 @@ export default function StopMessagingForm({
{/* Message preview */} {/* Message preview */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-zinc-300"> <label className="ha-field-label mb-2">
Message <span>Message</span>
</label> </label>
<textarea <textarea
value={message} value={message}
@@ -171,37 +175,44 @@ export default function StopMessagingForm({
setCustomMessage(e.target.value); setCustomMessage(e.target.value);
}} }}
rows={4} 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..." 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 {message.length} characters
</p> </p>
</div> </div>
{error && ( {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} {error}
</div> </div>
)} )}
{sent > 0 && ( {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" : ""}! Message sent to {sent} customer{sent !== 1 ? "s" : ""}!
</div> </div>
)} )}
{/* Recipients */} {/* Recipients */}
{recipients.length > 0 && message && ( {recipients.length > 0 && message && (
<div className="rounded-xl border border-zinc-800 p-4"> <div className="rounded-xl border border-[var(--admin-border)] bg-white p-4">
<p className="mb-3 text-sm font-medium text-zinc-300"> <p className="mb-3 text-sm font-medium text-[var(--admin-text-secondary)]">
Recipients ({recipients.length}): Recipients ({recipients.length}):
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{recipients.map((c) => ( {recipients.map((c) => (
<div key={c.id} className="flex justify-between text-sm"> <div key={c.id} className="flex justify-between text-sm">
<span className="text-zinc-300">{c.customer_name}</span> <span className="text-[var(--admin-text-primary)]">{c.customer_name}</span>
<span className="text-slate-400"> <span className="text-[var(--admin-text-muted)] tabular-nums">
{c.customer_phone ?? c.customer_email ?? "no contact"} {c.customer_phone ?? c.customer_email ?? "no contact"}
</span> </span>
</div> </div>
@@ -210,15 +221,18 @@ export default function StopMessagingForm({
</div> </div>
)} )}
<button <AdminButton
variant="primary"
size="lg"
onClick={handleSend} onClick={handleSend}
disabled={!message || recipients.length === 0 || sending} 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
? "Sending..." ? "Sending..."
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`} : `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
</button> </AdminButton>
</> </>
)} )}
</div> </div>
@@ -203,8 +203,8 @@ export default function StopProductAssignment({
role="alert" role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]" className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{ style={{
background: "rgba(220, 38, 38, 0.06)", background: "var(--admin-danger-soft)",
border: "1px solid rgba(220, 38, 38, 0.15)", 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}> <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 Active
</span> </span>
<span className="ha-calendar-legend-item"> <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 Draft
</span> </span>
<span className="ha-calendar-legend-item"> <span className="ha-calendar-legend-item">
@@ -513,7 +513,7 @@ function EventPopover({
className="inline-block w-1.5 h-1.5 rounded-full" className="inline-block w-1.5 h-1.5 rounded-full"
style={{ style={{
background: 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)} {statusLabel(status)} · {brandName(stop)}
@@ -682,19 +682,19 @@ function DayRouteDrawer({
status === "active" status === "active"
? "var(--admin-accent-light)" ? "var(--admin-accent-light)"
: status === "draft" : status === "draft"
? "rgba(245, 158, 11, 0.1)" ? "var(--admin-warning-soft)"
: "var(--admin-bg-subtle)", : "var(--admin-bg-subtle)",
color: color:
status === "active" status === "active"
? "var(--admin-accent-text)" ? "var(--admin-accent-text)"
: status === "draft" : status === "draft"
? "#92400e" ? "var(--admin-warning)"
: "var(--admin-text-secondary)", : "var(--admin-text-secondary)",
borderColor: borderColor:
status === "active" status === "active"
? "var(--admin-accent)" ? "var(--admin-accent)"
: status === "draft" : status === "draft"
? "rgba(245, 158, 11, 0.3)" ? "var(--admin-warning)"
: "var(--admin-border-light)", : "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 group relative flex items-center gap-2.5 rounded-xl px-3.5 py-2
text-sm font-semibold transition-all duration-200 text-sm font-semibold transition-all duration-200
${isActive ${isActive
? "bg-white text-emerald-700 border border-emerald-200 shadow-sm" ? "bg-white text-[var(--admin-primary)] border border-[var(--admin-primary)]/30 shadow-sm"
: "text-stone-600 border border-transparent hover:text-emerald-700 hover:bg-emerald-50/40" : "text-[var(--admin-text-secondary)] border border-transparent hover:text-[var(--admin-primary)] hover:bg-[var(--admin-primary-soft)]/40"
} }
`} `}
> >
<span <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 aria-hidden
> >
{t.icon} {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 inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5
text-[11px] font-bold tabular-nums text-[11px] font-bold tabular-nums
${isActive ${isActive
? "bg-emerald-600 text-white" ? "bg-[var(--admin-primary)] text-white"
: "bg-stone-200/70 text-stone-600 group-hover:bg-emerald-100 group-hover:text-emerald-700" : "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> </span>
{t.sublabel && ( {t.sublabel && (
<span <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} · {t.sublabel}
</span> </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"; "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"; 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 = { type AdminBadgeProps = {
children: React.ReactNode; children: ReactNode;
/** Legacy variant name. Falls back to "default". Mapped onto `tone` internally. */
variant?: BadgeVariant; variant?: BadgeVariant;
/** New tone name. Takes precedence over `variant` when both are provided. */
tone?: BadgeTone;
/** Optional leading dot indicator. */
dot?: boolean; dot?: boolean;
/** Extra classes — typically size overrides like `"text-xs px-2 py-0.5"`. */
className?: string; className?: string;
}; };
const variantClasses: Record<BadgeVariant, { bg: string; text: string; dot: string }> = { type ToneStyle = {
default: { bg: "bg-[var(--admin-border-light)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-text-muted)]" }, bg: string;
success: { bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", dot: "bg-[var(--admin-accent)]" }, text: string;
warning: { bg: "bg-[var(--admin-warning-light)]", text: "text-[var(--admin-warning)]", dot: "bg-[var(--admin-warning)]" }, /** Optional border color; falls back to the background when unset. */
danger: { bg: "bg-[var(--admin-danger-light)]", text: "text-[var(--admin-danger)]", dot: "bg-[var(--admin-danger)]" }, border?: string;
info: { bg: "bg-[var(--admin-border)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-info)]" }, 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({ export default function AdminBadge({
children, children,
variant = "default", variant = "default",
tone,
dot = false, dot = false,
className = "" className = "",
}: AdminBadgeProps) { }: 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 ( 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}`}> <span
{dot && <span className={`h-1.5 w-1.5 rounded-full ${dotColor}`} />} 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} {children}
</span> </span>
); );
@@ -39,19 +145,21 @@ type AdminStatusBadgeProps = {
className?: string; className?: string;
}; };
const statusConfig: Record<string, { variant: BadgeVariant; dot: boolean; label: string }> = { // Reuse the new `tone` prop directly so the status pill text style always
active: { variant: "success", dot: true, label: "Active" }, // matches the active AdminBadge palette.
inactive: { variant: "default", dot: true, label: "Inactive" }, const statusConfig: Record<string, { tone: BadgeTone; dot: boolean; label: string }> = {
pending: { variant: "warning", dot: true, label: "Pending" }, active: { tone: "success", dot: true, label: "Active" },
draft: { variant: "default", dot: true, label: "Draft" }, inactive: { tone: "neutral", dot: true, label: "Inactive" },
completed: { variant: "success", dot: true, label: "Completed" }, pending: { tone: "warning", dot: true, label: "Pending" },
cancelled: { variant: "danger", dot: true, label: "Cancelled" }, 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) { export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgeProps) {
const config = statusConfig[status] || statusConfig.inactive; const config = statusConfig[status] || statusConfig.inactive;
return ( return (
<AdminBadge variant={config.variant} dot={config.dot} className={className}> <AdminBadge tone={config.tone} dot={config.dot} className={className}>
{config.label} {config.label}
</AdminBadge> </AdminBadge>
); );
@@ -60,14 +168,25 @@ export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgePro
// Count badge (circular) // Count badge (circular)
type AdminCountBadgeProps = { type AdminCountBadgeProps = {
count: number; count: number;
tone?: BadgeTone;
/** @deprecated Use `tone` instead. */
variant?: BadgeVariant; variant?: BadgeVariant;
className?: string; className?: string;
}; };
export function AdminCountBadge({ count, variant = "default", className = "" }: AdminCountBadgeProps) { export function AdminCountBadge({
const { bg, text } = variantClasses[variant]; count,
tone,
variant = "default",
className = "",
}: AdminCountBadgeProps) {
const resolvedTone: BadgeTone = tone ?? variantToTone[variant];
const { bg, text } = toneStyles[resolvedTone];
return ( 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} {count}
</span> </span>
); );
@@ -12,6 +12,12 @@ export { default as AdminActionMenu, AdminActionButton } from "./AdminActionMenu
export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination"; export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination";
export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge"; 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 // Design system components
export { default as AdminButton, AdminIconButton } from "./AdminButton"; export { default as AdminButton, AdminIconButton } from "./AdminButton";
export { default as PageHeader } from "./PageHeader"; export { default as PageHeader } from "./PageHeader";
+54 -41
View File
@@ -1,59 +1,72 @@
/* Admin Design System - Design Tokens & Base Styles */ /* Admin Design System - Design Tokens & Base Styles */
/* === Color Tokens === */ /* === Color Tokens === */
/* Editorial operations palette. One primary (deep botanical), one accent (amber),
four neutrals. No citrus, no teal, no purple. */
:root { :root {
/* Background colors - Elegant warm palette */ /* Backgrounds — atelier cream family */
--admin-bg: #f5f4ef; /* parchment - main background */ --admin-bg: #FAF7F0; /* page background (atelier cream) */
--admin-bg-subtle: #f7f6f2; /* parchment 600 */ --admin-bg-subtle: #F4F1E8; /* inset wells */
--admin-card-bg: #ffffff; /* Pure white for cards */ --admin-card-bg: #FFFFFF; /* pure white cards */
--admin-card-bg-alt: #fafaf7; /* Slightly warm white */ --admin-card-bg-alt: #FCFAF4; /* warm white variant */
/* Text colors */ /* Text */
--admin-text-primary: #3c3825; /* parchment 100 - dark warm */ --admin-text-primary: #1A1814; /* ink */
--admin-text-secondary: #686763; /* dim_grey */ --admin-text-secondary: #4A4742; /* secondary text (was #686763 — too washed) */
--admin-text-muted: #a5a4a1; /* dim_grey 700 */ --admin-text-muted: #8A867E; /* tertiary / labels (was #a5a4a1 — too dim) */
/* Borders */ /* Borders */
--admin-border: #d0cbb4; /* parchment 400 */ --admin-border: #E8E4D7; /* default border (was #d0cbb4 — too dirty) */
--admin-border-light: #e5e2d9; /* soft_linen */ --admin-border-light: #F0EDE3; /* hairline */
--admin-border-strong: #D4CFBE; /* emphasized */
/* Accent - Fresh Produce Green */ /* Primary — deep botanical green */
--admin-accent: #16a34a; /* green-600 - fresh produce brand */ --admin-primary: #1F4D2A;
--admin-accent-hover: #15803d; /* green-700 */ --admin-primary-hover: #163C20;
--admin-accent-light: #dcfce7; /* green-100 */ --admin-primary-soft: #E8F0E5; /* 8% surface for selected rows / chips */
--admin-accent-text: #166534; /* green-800 */
/* Status Colors */ /* Accent — warm amber (the "one thing to do next") */
--admin-success: #16a34a; /* green-600 - fresh produce success */ --admin-accent: #B8761E;
--admin-success-accent: #15803d; /* green-700 */ --admin-accent-hover: #965D14;
--admin-warning: #aba278; /* parchment 300 */ --admin-accent-soft: #F7EBD5;
--admin-warning-light: #f5f4ef; /* parchment */
--admin-danger: #dc2626; /* red-600 */
--admin-danger-hover: #b91c1c; /* red-700 */
--admin-danger-light: #fef2f2; /* red-50 */
--admin-info: #686763; /* dim_grey */
--admin-accent-dot: #22c55e; /* green-500 */
/* Sidebar - Dark elegant */ /* Backward-compat aliases — existing var(--admin-accent) / --admin-accent-text /
--admin-sidebar-bg: #3c3825; /* parchment 100 */ --admin-accent-light / --admin-accent-hover continue to work. */
--admin-sidebar-text: #c3c3c1; /* dim_grey 800 */ --admin-accent-text: #14532D; /* was #166534 — slightly deeper for AA on cream */
--admin-sidebar-hover: #4c4945; /* dim_grey 200 */ --admin-accent-light: #DCFCE7;
--admin-sidebar-active: #5c584f; /* between */ --admin-accent-dot: #1F4D2A; /* was #22c55e — match primary */
/* Status (semantic) */
--admin-success: #1F4D2A;
--admin-success-soft: #E8F0E5;
--admin-warning: #B8761E; /* was #aba278 — looked like dirt */
--admin-warning-soft: #F7EBD5;
--admin-danger: #A8321C; /* was #dc2626 — slightly warmer */
--admin-danger-hover: #87281A;
--admin-danger-soft: #F7E3DE;
--admin-info: #4A4742; /* was #686763 — match new secondary */
/* Sidebar — dark warm */
--admin-sidebar-bg: #2A2520; /* was #3c3825 — deeper, more refined */
--admin-sidebar-text: #B8B4A8; /* was #c3c3c1 — warmer */
--admin-sidebar-hover: #3A352F;
--admin-sidebar-active: #4A443C;
--admin-sidebar-accent: #D4A24C; /* amber for active item */
/* Spacing */ /* Spacing */
--admin-page-padding: 1rem; /* px-4 - reduced for density */ --admin-page-padding: 1rem;
--admin-section-gap: 0.75rem; /* gap-3 - reduced */ --admin-section-gap: 0.75rem;
--admin-card-padding: 1rem; /* p-4 - reduced */ --admin-card-padding: 1rem;
/* Border Radius */ /* Border Radius */
--admin-radius-sm: 0.5rem; /* rounded-lg */ --admin-radius-sm: 0.5rem;
--admin-radius-md: 0.75rem; /* rounded-xl */ --admin-radius-md: 0.75rem;
--admin-radius-lg: 1rem; /* rounded-2xl */ --admin-radius-lg: 1rem;
/* Shadows - subtle warm shadows */ /* Shadows — warm-tinted */
--admin-shadow-sm: 0 1px 2px rgba(60, 56, 37, 0.04); --admin-shadow-sm: 0 1px 2px rgba(26, 24, 20, 0.04);
--admin-shadow-md: 0 4px 12px rgba(60, 56, 37, 0.08); --admin-shadow-md: 0 4px 12px rgba(26, 24, 20, 0.08);
--admin-shadow-lg: 0 10px 15px -3px rgba(60, 56, 37, 0.08), 0 4px 6px -4px rgba(60, 56, 37, 0.04); --admin-shadow-lg: 0 10px 30px -6px rgba(26, 24, 20, 0.10), 0 4px 8px -4px rgba(26, 24, 20, 0.04);
} }
/* === Base Admin Layout === */ /* === Base Admin Layout === */