Compare commits
33 Commits
v2.0.0
...
7047e086d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 7047e086d6 | |||
| 1e0e278451 | |||
| 467f7e63fd | |||
| 685a1269a4 | |||
| 6c0a282765 | |||
| 1a47bbea2f | |||
| 442c16d572 | |||
| 8e937344ff | |||
| 4ded68cec2 | |||
| 750efdd318 | |||
| 18fb44ed38 | |||
| bd2dadd9ee | |||
| c8fa2e8b52 | |||
| 4ebbc6dacf | |||
| 9458fd0506 | |||
| 83ad6536a3 | |||
| 244551ce70 | |||
| b1d4174721 | |||
| 001840ab05 | |||
| f0a703794a | |||
| 7d1e6f784b | |||
| b5f7252ac0 | |||
| 24cf9a7261 | |||
| 8428f3a490 | |||
| 77fb8fe7ee | |||
| eabc709076 | |||
| 4bd2ed0db2 | |||
| c1396096ad | |||
| a53516bfe6 | |||
| fb23c21ad9 | |||
| 0cf2ee7948 | |||
| 6c1c616c1c | |||
| 4909a78aca |
@@ -176,7 +176,7 @@ jobs:
|
|||||||
|
|
||||||
# Upload env file and sync build output
|
# Upload env file and sync build output
|
||||||
echo "Uploading env file..."
|
echo "Uploading env file..."
|
||||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no "$ENV_FILE" tyler@route.crispygoat.com:$APP_DIR/.env.production
|
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no "$ENV_FILE" tyler@route.crispygoat.com:$APP_DIR/.env
|
||||||
|
|
||||||
echo "Copying .next/..."
|
echo "Copying .next/..."
|
||||||
scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r .next tyler@route.crispygoat.com:$APP_DIR/
|
scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r .next tyler@route.crispygoat.com:$APP_DIR/
|
||||||
|
|||||||
@@ -47,3 +47,4 @@ playwright-report/
|
|||||||
.mcp.json
|
.mcp.json
|
||||||
.env*
|
.env*
|
||||||
public/videos/tuxedo-hero.mp4
|
public/videos/tuxedo-hero.mp4
|
||||||
|
.neon
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- Migration 003: Batch insert RPCs for tour stop / location seeding
|
||||||
|
-- Required by db/seeds/2026-tuxedo-tour-stops.sql and scripts/import-tuxedo-stops.ts
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- admin_create_locations_batch: insert or update locations from tour seed
|
||||||
|
-- Payload shape: { name, address, city, state, zip, phone, contact_name, contact_email, notes, active }[]
|
||||||
|
CREATE OR REPLACE FUNCTION admin_create_locations_batch(p_brand_id UUID, p_locations JSONB)
|
||||||
|
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_loc JSONB;
|
||||||
|
BEGIN
|
||||||
|
FOR v_loc IN SELECT * FROM jsonb_array_elements(p_locations)
|
||||||
|
LOOP
|
||||||
|
INSERT INTO locations (
|
||||||
|
brand_id, name, address, city, state, zip,
|
||||||
|
phone, contact_name, contact_email, notes, active
|
||||||
|
) VALUES (
|
||||||
|
p_brand_id,
|
||||||
|
v_loc->>'name',
|
||||||
|
v_loc->>'address',
|
||||||
|
v_loc->>'city',
|
||||||
|
v_loc->>'state',
|
||||||
|
NULLIF(v_loc->>'zip', '')::TEXT,
|
||||||
|
v_loc->>'phone',
|
||||||
|
v_loc->>'contact_name',
|
||||||
|
v_loc->>'contact_email',
|
||||||
|
v_loc->>'notes',
|
||||||
|
COALESCE((v_loc->>'active')::BOOLEAN, true)
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- admin_create_stops_batch: insert stops from tour seed
|
||||||
|
-- Payload shape: { city, state, location, date, time, address, zip, cutoff_time, active, notes }[]
|
||||||
|
-- date format: '2026-07-22 00:00:00+00' — cast to DATE
|
||||||
|
CREATE OR REPLACE FUNCTION admin_create_stops_batch(p_brand_id UUID, p_stops JSONB)
|
||||||
|
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_stop JSONB;
|
||||||
|
BEGIN
|
||||||
|
FOR v_stop IN SELECT * FROM jsonb_array_elements(p_stops)
|
||||||
|
LOOP
|
||||||
|
INSERT INTO stops (
|
||||||
|
brand_id, name, location, address, city, state, zip,
|
||||||
|
date, "time", cutoff_date, status, is_public, notes
|
||||||
|
) VALUES (
|
||||||
|
p_brand_id,
|
||||||
|
COALESCE(NULLIF(v_stop->>'name', ''), (v_stop->>'location')::TEXT || ' - ' || (v_stop->>'city')::TEXT || ', ' || (v_stop->>'state')::TEXT),
|
||||||
|
v_stop->>'location',
|
||||||
|
v_stop->>'address',
|
||||||
|
v_stop->>'city',
|
||||||
|
v_stop->>'state',
|
||||||
|
NULLIF(v_stop->>'zip', '')::TEXT,
|
||||||
|
CASE
|
||||||
|
WHEN v_stop->>'date' IS NULL THEN NULL
|
||||||
|
ELSE LEFT(v_stop->>'date', 10)::DATE
|
||||||
|
END,
|
||||||
|
v_stop->>'time',
|
||||||
|
NULLIF(v_stop->>'cutoff_time', '')::DATE,
|
||||||
|
'active',
|
||||||
|
COALESCE((v_stop->>'active')::BOOLEAN, true),
|
||||||
|
v_stop->>'notes'
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,255 @@
|
|||||||
|
# Nested Layout Skeleton
|
||||||
|
|
||||||
|
Visual map of how pages nest inside each other in the Next.js App Router.
|
||||||
|
Each level persists across navigation — children swap, parents stay.
|
||||||
|
|
||||||
|
## Mental model
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ app/layout.tsx (root) │
|
||||||
|
│ html / body / fonts / Providers / Toast / CookieBanner │
|
||||||
|
│ ───────────────────────────────────────────────────────── │
|
||||||
|
│ No global header. Each route group renders its own chrome. │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────┼─────────────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
Public routes Storefronts Admin
|
||||||
|
(/, /pricing, (/tuxedo/*, (/admin/*)
|
||||||
|
/contact, /login) /indian-river-direct/*)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full tree
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/
|
||||||
|
├── layout.tsx ────────────────────────────────────────────────── [ROOT]
|
||||||
|
│ • <html>, <body>
|
||||||
|
│ • next/font: Fraunces (display), Manrope (sans), Fragment_Mono
|
||||||
|
│ • <Providers> (theme, posthog, sentry, etc.)
|
||||||
|
│ • <ToastNotificationContainer />
|
||||||
|
│ • <CookieConsentBanner />
|
||||||
|
│ ─── pages render INSIDE ───
|
||||||
|
│ │
|
||||||
|
│ ├── error.tsx [ROOT-LEVEL] Atelier error page
|
||||||
|
│ ├── loading.tsx [ROOT-LEVEL] "Preparing your harvest"
|
||||||
|
│ ├── not-found.tsx [ROOT-LEVEL] "Off the beaten path"
|
||||||
|
│ │
|
||||||
|
│ ├── page.tsx / LandingPageClient
|
||||||
|
│ │ └── renders its own SiteHeader + Hero + SiteFooter
|
||||||
|
│ │
|
||||||
|
│ ├── login/
|
||||||
|
│ │ ├── page.tsx /login LoginClient (standalone)
|
||||||
|
│ │ └── loading.tsx [isolated]
|
||||||
|
│ │
|
||||||
|
│ ├── cart/
|
||||||
|
│ │ ├── page.tsx /cart CartClient (standalone)
|
||||||
|
│ │ └── loading.tsx
|
||||||
|
│ │
|
||||||
|
│ ├── checkout/
|
||||||
|
│ │ ├── page.tsx /checkout CheckoutClient (standalone)
|
||||||
|
│ │ ├── loading.tsx
|
||||||
|
│ │ └── success/page.tsx /checkout/success
|
||||||
|
│ │
|
||||||
|
│ ├── change-password/page.tsx
|
||||||
|
│ ├── pricing/page.tsx + loading.tsx
|
||||||
|
│ ├── contact/page.tsx + loading.tsx
|
||||||
|
│ ├── blog/page.tsx
|
||||||
|
│ ├── changelog/page.tsx
|
||||||
|
│ ├── roadmap/page.tsx
|
||||||
|
│ ├── waitlist/page.tsx
|
||||||
|
│ ├── security/page.tsx
|
||||||
|
│ ├── privacy-policy/page.tsx
|
||||||
|
│ ├── terms-and-conditions/page.tsx
|
||||||
|
│ ├── maintenance/page.tsx
|
||||||
|
│ │
|
||||||
|
│ ├── ─── STOREFRONTS ─────────────────────────────────────────
|
||||||
|
│ │
|
||||||
|
│ ├── tuxedo/
|
||||||
|
│ │ ├── layout.tsx ─────────────────────── [TUXEDO CHROME]
|
||||||
|
│ │ │ • Dark emerald gradient backdrop
|
||||||
|
│ │ │ • Ambient emerald orb (top-right blur)
|
||||||
|
│ │ │ • <StorefrontHeader> + <StorefrontFooter> per page
|
||||||
|
│ │ │ • BreadcrumbList + LocalBusiness JSON-LD in metadata
|
||||||
|
│ │ │ ─── INSIDE ───
|
||||||
|
│ │ │ ├── error.tsx
|
||||||
|
│ │ │ ├── loading.tsx
|
||||||
|
│ │ │ ├── page.tsx /tuxedo
|
||||||
|
│ │ │ ├── stops/
|
||||||
|
│ │ │ │ ├── page.tsx /tuxedo/stops
|
||||||
|
│ │ │ │ ├── loading.tsx
|
||||||
|
│ │ │ │ └── [slug]/
|
||||||
|
│ │ │ │ ├── page.tsx /tuxedo/stops/:slug
|
||||||
|
│ │ │ │ ├── error.tsx
|
||||||
|
│ │ │ │ └── loading.tsx
|
||||||
|
│ │ │ ├── about/
|
||||||
|
│ │ │ │ ├── layout.tsx
|
||||||
|
│ │ │ │ ├── page.tsx /tuxedo/about
|
||||||
|
│ │ │ │ ├── error.tsx
|
||||||
|
│ │ │ │ └── loading.tsx
|
||||||
|
│ │ │ ├── faq/
|
||||||
|
│ │ │ │ ├── layout.tsx
|
||||||
|
│ │ │ │ ├── page.tsx /tuxedo/faq
|
||||||
|
│ │ │ │ ├── error.tsx
|
||||||
|
│ │ │ │ └── loading.tsx
|
||||||
|
│ │ │ ├── contact/
|
||||||
|
│ │ │ │ ├── layout.tsx
|
||||||
|
│ │ │ │ ├── page.tsx /tuxedo/contact
|
||||||
|
│ │ │ │ ├── error.tsx
|
||||||
|
│ │ │ │ └── loading.tsx
|
||||||
|
│ │ │ ├── products/
|
||||||
|
│ │ │ │ └── [slug]/page.tsx /tuxedo/products/:slug
|
||||||
|
│ │ │ └── time-clock/page.tsx /tuxedo/time-clock
|
||||||
|
│ │
|
||||||
|
│ ├── indian-river-direct/
|
||||||
|
│ │ ├── layout.tsx ─────────────────── [IRD CHROME]
|
||||||
|
│ │ │ • Light blue/white glass gradient backdrop
|
||||||
|
│ │ │ • Three decorative blue orbs
|
||||||
|
│ │ │ • <StorefrontHeader> + <StorefrontFooter> per page
|
||||||
|
│ │ │ • LocalBusiness (FL, family-owned 1985) JSON-LD
|
||||||
|
│ │ │ ─── INSIDE ───
|
||||||
|
│ │ │ ├── error.tsx
|
||||||
|
│ │ │ ├── loading.tsx
|
||||||
|
│ │ │ ├── page.tsx /indian-river-direct
|
||||||
|
│ │ │ ├── stops/
|
||||||
|
│ │ │ │ ├── page.tsx
|
||||||
|
│ │ │ │ └── [slug]/
|
||||||
|
│ │ │ │ ├── page.tsx
|
||||||
|
│ │ │ │ ├── error.tsx
|
||||||
|
│ │ │ │ └── loading.tsx
|
||||||
|
│ │ │ ├── about/ (layout + page + error + loading)
|
||||||
|
│ │ │ ├── faq/ (layout + page + error + loading)
|
||||||
|
│ │ │ └── contact/ (layout + page + error + loading)
|
||||||
|
│ │
|
||||||
|
│ ├── ─── ADMIN ──────────────────────────────────────────────
|
||||||
|
│ │
|
||||||
|
│ └── admin/
|
||||||
|
│ ├── layout.tsx ───────────────────── [ADMIN SHELL]
|
||||||
|
│ │ • dynamic = "force-dynamic" (reads cookies)
|
||||||
|
│ │ • getAdminUser() → AdminSidebar + content area
|
||||||
|
│ │ • ToastProvider + ToastContainer
|
||||||
|
│ │ • Sidebar persists across all /admin/* routes
|
||||||
|
│ │ • Parchment background via .admin-section
|
||||||
|
│ │ ─── INSIDE ───
|
||||||
|
│ │ ├── error.tsx Admin error page
|
||||||
|
│ │ ├── loading.tsx Skeleton: header + stats + table
|
||||||
|
│ │ ├── page.tsx /admin (Dashboard)
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── orders/
|
||||||
|
│ │ │ ├── page.tsx /admin/orders
|
||||||
|
│ │ │ ├── [id]/page.tsx /admin/orders/:id
|
||||||
|
│ │ │ └── new/page.tsx /admin/orders/new
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── products/
|
||||||
|
│ │ │ ├── page.tsx /admin/products
|
||||||
|
│ │ │ ├── [id]/page.tsx
|
||||||
|
│ │ │ ├── new/page.tsx
|
||||||
|
│ │ │ └── import/page.tsx
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── stops/
|
||||||
|
│ │ │ ├── page.tsx /admin/stops
|
||||||
|
│ │ │ ├── [id]/page.tsx
|
||||||
|
│ │ │ └── new/page.tsx
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── communications/
|
||||||
|
│ │ │ ├── loading.tsx
|
||||||
|
│ │ │ ├── page.tsx /admin/communications
|
||||||
|
│ │ │ ├── compose/page.tsx
|
||||||
|
│ │ │ ├── campaigns/[id]/page.tsx
|
||||||
|
│ │ │ ├── contacts/page.tsx
|
||||||
|
│ │ │ ├── logs/page.tsx
|
||||||
|
│ │ │ ├── segments/page.tsx
|
||||||
|
│ │ │ ├── settings/page.tsx
|
||||||
|
│ │ │ ├── templates/page.tsx
|
||||||
|
│ │ │ ├── templates/[id]/page.tsx
|
||||||
|
│ │ │ ├── analytics/page.tsx
|
||||||
|
│ │ │ ├── abandoned-carts/page.tsx
|
||||||
|
│ │ │ └── welcome-sequence/page.tsx
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── settings/ (sub-tree of 13 pages: billing, brand,
|
||||||
|
│ │ │ payments, shipping, ai, apps,
|
||||||
|
│ │ │ integrations, square-sync, ...)
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── route-trace/ (lots, lookup, settings)
|
||||||
|
│ │ ├── me/
|
||||||
|
│ │ ├── pickup/ Store-employee landing
|
||||||
|
│ │ ├── sales/import/
|
||||||
|
│ │ ├── import/
|
||||||
|
│ │ ├── analytics/
|
||||||
|
│ │ ├── command-center/
|
||||||
|
│ │ ├── advanced/
|
||||||
|
│ │ ├── reports/
|
||||||
|
│ │ ├── shipping/
|
||||||
|
│ │ ├── taxes/
|
||||||
|
│ │ ├── time-tracking/
|
||||||
|
│ │ ├── water-log/ (sub-tree)
|
||||||
|
│ │ ├── wholesale/
|
||||||
|
│ │ └── launch-checklist/
|
||||||
|
│ │
|
||||||
|
│ ├── wholesale/ (separate sub-app, not under admin/layout)
|
||||||
|
│ │ ├── login/page.tsx
|
||||||
|
│ │ ├── portal/page.tsx
|
||||||
|
│ │ ├── register/page.tsx
|
||||||
|
│ │ ├── employee/page.tsx
|
||||||
|
│ │ └── payment/{success,cancel}/page.tsx
|
||||||
|
│ │
|
||||||
|
│ ├── water/ (separate sub-app, not under admin/layout)
|
||||||
|
│ │ ├── page.tsx
|
||||||
|
│ │ ├── loading.tsx
|
||||||
|
│ │ └── admin/ (login + page)
|
||||||
|
│ │
|
||||||
|
│ ├── ird/time-clock/ (time-clock for IRD employees)
|
||||||
|
│ ├── trace/[lotNumber]/page.tsx (public route-trace lookup)
|
||||||
|
│ └── protected-example/page.tsx (smoke test for middleware)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key nesting rules
|
||||||
|
|
||||||
|
1. **The root `app/layout.tsx` is the only one that renders `<html>` and `<body>`**. Every other layout is a child of it.
|
||||||
|
2. **A `layout.tsx` in a folder wraps every `page.tsx` in that folder and all subfolders**. Children swap; layout state persists.
|
||||||
|
3. **`loading.tsx` and `error.tsx` are per-folder** — Next.js shows them when navigating to a sibling `page.tsx`. They re-render as you move between sections.
|
||||||
|
4. **Storefronts are fully isolated**: each `tuxedo/layout.tsx` and `indian-river-direct/layout.tsx` provides its own backdrop and lets individual pages render their own `StorefrontHeader` / `StorefrontFooter`.
|
||||||
|
5. **Admin is a single SPA-feeling shell**: navigating between `/admin/orders` and `/admin/products` keeps the `AdminSidebar` mounted, only the content area swaps.
|
||||||
|
|
||||||
|
## Things that DO NOT yet nest (gaps)
|
||||||
|
|
||||||
|
| Page | Issue | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `/cart` | No shared layout with `/checkout` | Add `app/(shop)/layout.tsx` route group to wrap both |
|
||||||
|
| `/wholesale/*` | Wholesale pages have no shared layout | Add `app/wholesale/layout.tsx` with auth gate + nav |
|
||||||
|
| `/water/*` | Water pages have no shared layout | Add `app/water/layout.tsx` |
|
||||||
|
| `/admin/communications/*` | 13 pages share no sub-layout | Could add `app/admin/communications/layout.tsx` with section tabs |
|
||||||
|
| `/admin/settings/*` | 13 settings pages share no sub-layout | Add `app/admin/settings/layout.tsx` with settings sidebar |
|
||||||
|
|
||||||
|
## How to add a nested layout
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: add a settings sub-layout
|
||||||
|
mkdir -p src/app/admin/settings
|
||||||
|
# Create src/app/admin/settings/layout.tsx
|
||||||
|
# It will wrap every page under /admin/settings/* automatically.
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/app/admin/settings/layout.tsx
|
||||||
|
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[14rem_1fr] gap-6">
|
||||||
|
<SettingsNav /> {/* sticks while content swaps */}
|
||||||
|
<section>{children}</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to add parallel routes (modals as overlays)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p 'src/app/@modal'
|
||||||
|
mkdir -p 'src/app/admin/products/(.)new' # intercepts /admin/products/new
|
||||||
|
# Layout returns <>{children}{modal}</>
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes `/admin/products/new` open as a modal over `/admin/products` instead of a hard navigation.
|
||||||
@@ -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
|
||||||
+8
-2
@@ -12,8 +12,9 @@ const nextConfig: NextConfig = {
|
|||||||
// /home/tyler/package-lock.json as the root directory."
|
// /home/tyler/package-lock.json as the root directory."
|
||||||
// The deploy runner's APP_DIR is /home/tyler/route-commerce, so
|
// The deploy runner's APP_DIR is /home/tyler/route-commerce, so
|
||||||
// resolving relative to the project root is correct both locally and
|
// resolving relative to the project root is correct both locally and
|
||||||
// in CI.
|
// in CI. We resolve to an absolute path to avoid the warning in
|
||||||
outputFileTracingRoot: ".",
|
// Next.js 16 which prefers absolute paths here.
|
||||||
|
outputFileTracingRoot: __dirname,
|
||||||
|
|
||||||
// Enable strict mode
|
// Enable strict mode
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
@@ -111,6 +112,11 @@ const nextConfig: NextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
// Enable optimizePackageImports for better bundle size
|
// Enable optimizePackageImports for better bundle size
|
||||||
optimizePackageImports: ["lucide-react", "@radix-ui/react-icons", "framer-motion"],
|
optimizePackageImports: ["lucide-react", "@radix-ui/react-icons", "framer-motion"],
|
||||||
|
// Enable React's <ViewTransition> and Next.js' automatic route
|
||||||
|
// transitions. Combined with the smooth-transition wrappers around
|
||||||
|
// page content (see src/components/transitions), navigation feels
|
||||||
|
// like a single continuous app rather than a sequence of page loads.
|
||||||
|
viewTransition: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Compiler options
|
// Compiler options
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"migrate:one": "node scripts/migrate.js",
|
"migrate:one": "node scripts/migrate.js",
|
||||||
"db:migrate": "node scripts/migrate.js",
|
"db:migrate": "node scripts/migrate.js",
|
||||||
"db:seed": "tsx db/seed.ts",
|
"db:seed": "tsx db/seed.ts",
|
||||||
|
"db:seed:tour": "node scripts/seed-tuxedo-2026.js",
|
||||||
"db:reset": "node scripts/db-reset.js",
|
"db:reset": "node scripts/db-reset.js",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"type-check": "npx tsc --noEmit",
|
"type-check": "npx tsc --noEmit",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
"@neondatabase/auth": "^0.4.2-beta",
|
"@neondatabase/auth": "^0.4.2-beta",
|
||||||
|
"@neondatabase/serverless": "^1.1.0",
|
||||||
"@sentry/nextjs": "^10.55.0",
|
"@sentry/nextjs": "^10.55.0",
|
||||||
"@stripe/react-stripe-js": "^6.6.0",
|
"@stripe/react-stripe-js": "^6.6.0",
|
||||||
"@stripe/stripe-js": "^9.7.0",
|
"@stripe/stripe-js": "^9.7.0",
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Cleanup script: remove duplicate stops from the Tuxedo Corn 2026 tour.
|
||||||
|
* Keeps the row with the latest created_at per (brand_id, date, city, state, location, time).
|
||||||
|
* Run: DATABASE_URL="postgresql://..." npx tsx scripts/cleanup-duplicate-stops.ts
|
||||||
|
*/
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString:
|
||||||
|
process.env.DATABASE_ADMIN_URL ??
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// Find duplicate groups (same brand_id, date, city, state, location, time)
|
||||||
|
const dupes = await client.query(`
|
||||||
|
SELECT brand_id::text, date, city, state, location, time, count(*) as cnt, min(created_at::text) as oldest, max(created_at::text) as newest
|
||||||
|
FROM stops
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
GROUP BY brand_id, date, city, state, location, time
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY date, city
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(`Found ${dupes.rowCount} duplicate groups`);
|
||||||
|
if (dupes.rowCount === 0) {
|
||||||
|
await client.query("COMMIT");
|
||||||
|
console.log("No duplicates found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalDeleted = 0;
|
||||||
|
for (const row of dupes.rows) {
|
||||||
|
// Keep the row with the latest created_at, delete the rest
|
||||||
|
const del = await client.query(`
|
||||||
|
DELETE FROM stops
|
||||||
|
WHERE brand_id = $1
|
||||||
|
AND date = $2
|
||||||
|
AND city = $3
|
||||||
|
AND state = $4
|
||||||
|
AND location = $5
|
||||||
|
AND time IS NOT DISTINCT FROM $6
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND created_at < (
|
||||||
|
SELECT max(created_at) FROM stops s2
|
||||||
|
WHERE s2.brand_id = stops.brand_id
|
||||||
|
AND s2.date = stops.date
|
||||||
|
AND s2.city = stops.city
|
||||||
|
AND s2.state = stops.state
|
||||||
|
AND s2.location = stops.location
|
||||||
|
AND (s2.time IS NOT DISTINCT FROM stops.time)
|
||||||
|
AND s2.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
`, [row.brand_id, row.date, row.city, row.state, row.location, row.time]);
|
||||||
|
totalDeleted += del.rowCount ?? 0;
|
||||||
|
console.log(` Deleted ${del.rowCount} duplicate(s) for ${row.city}, ${row.state} on ${row.date}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
console.log(`\nDone. Total duplicates removed: ${totalDeleted}`);
|
||||||
|
} catch (e) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
// dotenv load temporarily disabled so shell-provided DATABASE_URL wins for this run
|
||||||
|
// import { config } from "dotenv";
|
||||||
|
// config({ path: ".env.local" });
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import * as fs from "fs";
|
||||||
|
|
||||||
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
const YEAR = 2026;
|
||||||
|
|
||||||
|
const MONTH_MAP: Record<string, string> = {
|
||||||
|
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
||||||
|
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ParsedStop {
|
||||||
|
week: string;
|
||||||
|
region: string;
|
||||||
|
date: string;
|
||||||
|
day: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
location: string;
|
||||||
|
time: string;
|
||||||
|
timeRange: string;
|
||||||
|
truck: string;
|
||||||
|
statusText: string;
|
||||||
|
notes: string;
|
||||||
|
address: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
contact: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExcelDate(s: unknown): string | null {
|
||||||
|
if (!s) return null;
|
||||||
|
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const mm = MONTH_MAP[m[1]];
|
||||||
|
if (!mm) return null;
|
||||||
|
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeRange(s: unknown): string {
|
||||||
|
if (!s) return "";
|
||||||
|
let c = String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
|
||||||
|
const m = /^(\d{1,2}:\d{2}\s*[AP]M)/i.exec(c);
|
||||||
|
// Prefer full range for display (e.g. "10:00 AM – 1:00 PM"); fall back to start only
|
||||||
|
return c || (m ? m[1].toUpperCase().replace(" ", " ") : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCityState(s: unknown): [string, string] {
|
||||||
|
if (!s) return ["", ""];
|
||||||
|
const parts = String(s).split(",").map((p) => p.trim());
|
||||||
|
if (parts.length === 1) return [parts[0], ""];
|
||||||
|
return [parts[0], parts[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWeekHeader(cells: string[]): boolean {
|
||||||
|
return /^Wk\s/i.test(cells[0] || "") && (cells[3] || "").trim() === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOffRow(cells: string[]): boolean {
|
||||||
|
const d = (cells[3] || "").trim();
|
||||||
|
return /OFF|Cross-?Dock/i.test(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDataRow(cells: string[]): boolean {
|
||||||
|
const day = (cells[3] || "").trim();
|
||||||
|
const city = (cells[4] || "").trim();
|
||||||
|
return !!(day && city && city.includes(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(s: string): string {
|
||||||
|
let out = (s || "").toLowerCase();
|
||||||
|
out = out.replace(/[^a-z0-9]+/g, "-");
|
||||||
|
return out.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStops(xlsxPath: string): Promise<{ stops: ParsedStop[]; skipped: Record<string, number>; dirCount: number; locations: ParsedLocation[] }> {
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
await wb.xlsx.readFile(xlsxPath);
|
||||||
|
|
||||||
|
const schedule = wb.getWorksheet("Full Schedule");
|
||||||
|
const directory = wb.getWorksheet("Stop Directory");
|
||||||
|
if (!schedule) throw new Error("Missing 'Full Schedule' sheet");
|
||||||
|
|
||||||
|
// Build directory lookup for address enrichment: "T1|host lower" -> info
|
||||||
|
const dirMap = new Map<string, { address: string | null; phone: string | null; contact: string | null; state: string }>();
|
||||||
|
if (directory) {
|
||||||
|
for (let r = 2; r <= directory.rowCount; r++) {
|
||||||
|
const row = directory.getRow(r);
|
||||||
|
const truck = String(row.getCell(1).value || "").trim();
|
||||||
|
const host = String(row.getCell(4).value || "").trim().toLowerCase();
|
||||||
|
if (!truck || !host) continue;
|
||||||
|
dirMap.set(`${truck}|${host}`, {
|
||||||
|
address: String(row.getCell(5).value || "").trim() || null,
|
||||||
|
phone: String(row.getCell(6).value || "").trim() || null,
|
||||||
|
contact: String(row.getCell(7).value || "").trim() || null,
|
||||||
|
state: String(row.getCell(3).value || "").trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stops: ParsedStop[] = [];
|
||||||
|
const skipped: Record<string, number> = { weekHeader: 0, off: 0, invalid: 0, noDate: 0 };
|
||||||
|
|
||||||
|
for (let r = 4; r <= schedule.rowCount; r++) {
|
||||||
|
const row = schedule.getRow(r);
|
||||||
|
const cells = Array.from({ length: 10 }, (_, i) => String(row.getCell(i + 1).value ?? "").trim());
|
||||||
|
|
||||||
|
if (isWeekHeader(cells)) {
|
||||||
|
skipped.weekHeader++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isOffRow(cells)) {
|
||||||
|
skipped.off++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isDataRow(cells)) {
|
||||||
|
skipped.invalid++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [wk, region, dateText, day, cityState, host, time, truck, status, notes] = cells;
|
||||||
|
const dateIso = parseExcelDate(dateText);
|
||||||
|
if (!dateIso) {
|
||||||
|
skipped.noDate++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [city, state] = splitCityState(cityState);
|
||||||
|
if (!city) {
|
||||||
|
skipped.invalid++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${truck}|${host.toLowerCase()}`;
|
||||||
|
const d = dirMap.get(key);
|
||||||
|
|
||||||
|
stops.push({
|
||||||
|
week: wk,
|
||||||
|
region,
|
||||||
|
date: dateIso,
|
||||||
|
day,
|
||||||
|
city,
|
||||||
|
state: state || (d?.state || ""),
|
||||||
|
location: host,
|
||||||
|
time: parseTimeRange(time),
|
||||||
|
timeRange: time,
|
||||||
|
truck,
|
||||||
|
statusText: status,
|
||||||
|
notes: notes || "",
|
||||||
|
address: d?.address || null,
|
||||||
|
phone: d?.phone || null,
|
||||||
|
contact: d?.contact || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stops, skipped, dirCount: dirMap.size, locations: loadLocations(directory) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedLocation = {
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
phone: string | null;
|
||||||
|
contactName: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadLocations(directory: ExcelJS.Worksheet | undefined): ParsedLocation[] {
|
||||||
|
if (!directory) return [];
|
||||||
|
const byKey = new Map<string, ParsedLocation>();
|
||||||
|
for (let r = 2; r <= directory.rowCount; r++) {
|
||||||
|
const row = directory.getRow(r);
|
||||||
|
const truck = String(row.getCell(1).value || "").trim();
|
||||||
|
const city = String(row.getCell(2).value || "").trim();
|
||||||
|
const state = String(row.getCell(3).value || "").trim();
|
||||||
|
const host = String(row.getCell(4).value || "").trim();
|
||||||
|
const address = String(row.getCell(5).value || "").trim() || null;
|
||||||
|
let phone = String(row.getCell(6).value || "").trim() || null;
|
||||||
|
const contact = String(row.getCell(7).value || "").trim() || null;
|
||||||
|
// notes column is 12
|
||||||
|
let notes = String(row.getCell(12).value || "").trim() || null;
|
||||||
|
|
||||||
|
if (!host || !city) continue;
|
||||||
|
|
||||||
|
// If phone field contains TBD email, move it to notes
|
||||||
|
if (phone && /@/.test(phone) && (!notes || notes.length < 10)) {
|
||||||
|
notes = phone;
|
||||||
|
phone = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${host.toLowerCase()}|${city.toLowerCase()}|${state.toLowerCase()}`;
|
||||||
|
if (!byKey.has(key)) {
|
||||||
|
byKey.set(key, {
|
||||||
|
name: host,
|
||||||
|
address,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
phone,
|
||||||
|
contactName: contact || null,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byKey.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocationRpcRow(l: ParsedLocation) {
|
||||||
|
return {
|
||||||
|
name: l.name,
|
||||||
|
address: l.address,
|
||||||
|
city: l.city,
|
||||||
|
state: l.state,
|
||||||
|
zip: null,
|
||||||
|
phone: l.phone,
|
||||||
|
contact_name: l.contactName,
|
||||||
|
contact_email: null,
|
||||||
|
notes: l.notes,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRpcRow(s: ParsedStop) {
|
||||||
|
// Date format matches the python seed script expectation for the RPC
|
||||||
|
const dateWithTz = `${s.date} 00:00:00+00`;
|
||||||
|
const combinedNotes = [
|
||||||
|
s.notes,
|
||||||
|
s.truck ? `Truck: ${s.truck}` : "",
|
||||||
|
s.statusText ? `Status: ${s.statusText}` : "",
|
||||||
|
s.phone ? `Phone: ${s.phone}` : "",
|
||||||
|
s.contact ? `Contact: ${s.contact}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" | ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
city: s.city,
|
||||||
|
state: s.state,
|
||||||
|
location: s.location,
|
||||||
|
date: dateWithTz,
|
||||||
|
time: s.time || s.timeRange || "",
|
||||||
|
address: s.address,
|
||||||
|
zip: null,
|
||||||
|
cutoff_time: null,
|
||||||
|
// active=true makes them visible on the public /tuxedo/stops storefront immediately
|
||||||
|
active: true,
|
||||||
|
// forward notes when the RPC supports it (enriched with truck/status/contact)
|
||||||
|
notes: combinedNotes || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const dryRun = args.includes("--dry-run") || args.includes("-n");
|
||||||
|
let xlsxPath = "./Tuxedo_Corn_2026_Tour_Schedule-3.xlsx";
|
||||||
|
const xArg = args.findIndex((a) => a === "--xlsx" || a === "-x");
|
||||||
|
if (xArg !== -1 && args[xArg + 1]) xlsxPath = args[xArg + 1];
|
||||||
|
const doClean = !args.includes("--no-clean");
|
||||||
|
const emitSql = args.includes("--emit-sql") || args.includes("--sql");
|
||||||
|
|
||||||
|
console.log(`[import-tuxedo-stops] xlsx=${xlsxPath} dryRun=${dryRun} clean=${doClean} emitSql=${emitSql}`);
|
||||||
|
|
||||||
|
const { stops, skipped, dirCount, locations: parsedLocations } = await loadStops(xlsxPath);
|
||||||
|
|
||||||
|
console.log(`\nParsed ${stops.length} stops + ${parsedLocations.length} unique locations`);
|
||||||
|
console.log(`Skipped: week-headers=${skipped.weekHeader}, off/cross-dock=${skipped.off}, invalid=${skipped.invalid}, no-date=${skipped.noDate}`);
|
||||||
|
console.log(`Stop Directory raw entries: ${dirCount}\n`);
|
||||||
|
|
||||||
|
if (!stops.length) {
|
||||||
|
console.error("No stops to insert. Check the xlsx path and filters.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show samples
|
||||||
|
console.log("Sample (first 3):");
|
||||||
|
stops.slice(0, 3).forEach((s) => {
|
||||||
|
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
|
||||||
|
if (s.address) console.log(` addr: ${s.address}`);
|
||||||
|
});
|
||||||
|
console.log("\nSample (last 3):");
|
||||||
|
stops.slice(-3).forEach((s) => {
|
||||||
|
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// By week/region/truck
|
||||||
|
const byWeek: Record<string, number> = {};
|
||||||
|
const byRegion: Record<string, number> = {};
|
||||||
|
const byTruck: Record<string, number> = {};
|
||||||
|
for (const s of stops) {
|
||||||
|
byWeek[s.week] = (byWeek[s.week] || 0) + 1;
|
||||||
|
byRegion[s.region] = (byRegion[s.region] || 0) + 1;
|
||||||
|
byTruck[s.truck] = (byTruck[s.truck] || 0) + 1;
|
||||||
|
}
|
||||||
|
console.log("\nBy week:", Object.fromEntries(Object.entries(byWeek).sort(([a], [b]) => Number(a) - Number(b))));
|
||||||
|
console.log("By region:", byRegion);
|
||||||
|
console.log("By truck:", byTruck);
|
||||||
|
|
||||||
|
const dates = [...stops.map((s) => s.date)].sort();
|
||||||
|
console.log(`\nDate range: ${dates[0]} → ${dates[dates.length - 1]}`);
|
||||||
|
|
||||||
|
if (emitSql) {
|
||||||
|
const BATCH = 50;
|
||||||
|
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
|
||||||
|
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`;
|
||||||
|
sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`;
|
||||||
|
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n`;
|
||||||
|
sql += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
|
||||||
|
sql += `BEGIN;\n\n`;
|
||||||
|
|
||||||
|
// Locations first (master directory)
|
||||||
|
if (parsedLocations.length > 0) {
|
||||||
|
sql += `-- ===== LOCATIONS (Stop Directory master records) =====\n`;
|
||||||
|
sql += `DELETE FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}';\n\n`;
|
||||||
|
const locPayload = parsedLocations.map(toLocationRpcRow);
|
||||||
|
const locJson = JSON.stringify(locPayload);
|
||||||
|
sql += `-- ${parsedLocations.length} unique locations from Stop Directory\n`;
|
||||||
|
sql += `SELECT admin_create_locations_batch('${TUXEDO_BRAND_ID}'::uuid, $$${locJson}$$::jsonb);\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stops (dated instances)
|
||||||
|
sql += `-- ===== STOPS (2026 tour schedule) =====\n`;
|
||||||
|
sql += `-- Clear prior 2026 tour stops for brand (date-scoped)\n`;
|
||||||
|
sql += `DELETE FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
|
||||||
|
for (let i = 0; i < stops.length; i += BATCH) {
|
||||||
|
const batchStops = stops.slice(i, i + BATCH);
|
||||||
|
const payload = batchStops.map(toRpcRow);
|
||||||
|
const payloadJson = JSON.stringify(payload);
|
||||||
|
const bnum = Math.floor(i / BATCH) + 1;
|
||||||
|
sql += `-- stops batch ${bnum}/${Math.ceil(stops.length / BATCH)} (${payload.length})\n`;
|
||||||
|
sql += `SELECT admin_create_stops_batch('${TUXEDO_BRAND_ID}'::uuid, $$${payloadJson}$$::jsonb);\n\n`;
|
||||||
|
}
|
||||||
|
sql += `-- Make stops visible (RPC inserts as draft + active=true in payload)\n`;
|
||||||
|
sql += `UPDATE stops SET status = 'active' WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
|
||||||
|
sql += `COMMIT;\n`;
|
||||||
|
const outPath = "db/seeds/2026-tuxedo-tour-stops.sql";
|
||||||
|
fs.writeFileSync(outPath, sql, "utf8");
|
||||||
|
console.log(`\nWrote ${outPath} (locations: ${parsedLocations.length}, stops: ${stops.length}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
const stopBatches = Math.ceil(stops.length / 50);
|
||||||
|
console.log(`\n[DRY RUN] Would load for brand:\n - ${parsedLocations.length} locations (full replace)\n - DELETE date-scoped 2026 stops then INSERT ${stops.length} stops in ${stopBatches} batch(es)\n via admin_*_batch RPCs.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString:
|
||||||
|
process.env.DATABASE_ADMIN_URL ??
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
if (doClean) {
|
||||||
|
const del = await client.query(
|
||||||
|
`DELETE FROM stops WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
|
||||||
|
[TUXEDO_BRAND_ID],
|
||||||
|
);
|
||||||
|
console.log(`\nCleared ${del.rowCount} prior 2026-dated stops for Tuxedo brand.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locations (from Stop Directory) — full replace for the brand from this xlsx
|
||||||
|
if (parsedLocations.length > 0) {
|
||||||
|
const locDel = await client.query(
|
||||||
|
`DELETE FROM locations WHERE brand_id = $1`,
|
||||||
|
[TUXEDO_BRAND_ID],
|
||||||
|
);
|
||||||
|
console.log(`Cleared ${locDel.rowCount} previous locations for Tuxedo brand.`);
|
||||||
|
|
||||||
|
const LOC_BATCH = 100; // locations are fewer and smaller
|
||||||
|
let locTotal = 0;
|
||||||
|
const locBatches = Math.ceil(parsedLocations.length / LOC_BATCH);
|
||||||
|
for (let i = 0; i < parsedLocations.length; i += LOC_BATCH) {
|
||||||
|
const batchLocs = parsedLocations.slice(i, i + LOC_BATCH);
|
||||||
|
const payload = batchLocs.map(toLocationRpcRow);
|
||||||
|
const bnum = Math.floor(i / LOC_BATCH) + 1;
|
||||||
|
process.stdout.write(` Locations batch ${bnum}/${locBatches} (${payload.length}) via admin_create_locations_batch... `);
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
"SELECT admin_create_locations_batch($1::uuid, $2::jsonb)",
|
||||||
|
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
|
||||||
|
);
|
||||||
|
locTotal += payload.length;
|
||||||
|
console.log("OK");
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log("FAIL");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` Inserted ${locTotal} locations.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATCH = 50;
|
||||||
|
let total = 0;
|
||||||
|
const batches = Math.ceil(stops.length / BATCH);
|
||||||
|
|
||||||
|
for (let i = 0; i < stops.length; i += BATCH) {
|
||||||
|
const batchStops = stops.slice(i, i + BATCH);
|
||||||
|
const payload = batchStops.map(toRpcRow);
|
||||||
|
const bnum = Math.floor(i / BATCH) + 1;
|
||||||
|
|
||||||
|
process.stdout.write(` Batch ${bnum}/${batches} (${payload.length}) via admin_create_stops_batch... `);
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
"SELECT admin_create_stops_batch($1::uuid, $2::jsonb)",
|
||||||
|
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
|
||||||
|
);
|
||||||
|
total += payload.length;
|
||||||
|
console.log("OK");
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log("FAIL");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPC inserts with status='draft' (per prior script); flip to active for public visibility.
|
||||||
|
// Scope by the tour date range so we only touch rows from this import.
|
||||||
|
if (total > 0) {
|
||||||
|
const pub = await client.query(
|
||||||
|
`UPDATE stops SET status = 'active' WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
|
||||||
|
[TUXEDO_BRAND_ID],
|
||||||
|
);
|
||||||
|
console.log(`\nPublished (status=active): ${pub.rowCount} stops.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
console.log(`\nDone. Inserted ${parsedLocations.length} locations + ${total} stops for Tuxedo Corn 2026 tour (brand ${TUXEDO_BRAND_ID}).`);
|
||||||
|
console.log("Locations are the master directory; stops are the dated schedule instances.");
|
||||||
|
console.log("They should now appear on /tuxedo/stops and in admin locations UI.");
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
console.error("\nInsert failed (rolled back):", err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Seed Tuxedo Corn 2026 tour: locations + stops.
|
||||||
|
*
|
||||||
|
* Uses sql`...` tagged template from @neondatabase/serverless (WebSocket mode)
|
||||||
|
* for INSERTs — this is the only Neon serverless API that actually executes writes.
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* node scripts/seed-tuxedo-2026.js
|
||||||
|
*
|
||||||
|
* Requires DATABASE_URL in .env.local (or pass explicitly).
|
||||||
|
*/
|
||||||
|
require("dotenv").config({ path: ".env.local" });
|
||||||
|
const { neon } = require("@neondatabase/serverless");
|
||||||
|
const ExcelJS = require("exceljs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
const YEAR = 2026;
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
console.error("❌ DATABASE_URL not set in .env.local");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanUrl = url.replace(/[?&]channel_binding=require/gi, "").trim();
|
||||||
|
// Use direct compute endpoint with WebSocket for full DDL + DML support
|
||||||
|
const directUrl = cleanUrl.replace("-pooler.", ".");
|
||||||
|
const sql = neon(directUrl, { webSocket: true });
|
||||||
|
|
||||||
|
const MONTH_MAP = {
|
||||||
|
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
||||||
|
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseExcelDate(s) {
|
||||||
|
if (!s) return null;
|
||||||
|
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const mm = MONTH_MAP[m[1]];
|
||||||
|
if (!mm) return null;
|
||||||
|
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeRange(s) {
|
||||||
|
if (!s) return "";
|
||||||
|
return String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseXlsx(xlsxPath) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.readFile(xlsxPath);
|
||||||
|
|
||||||
|
// --- Locations from Stop Directory ---
|
||||||
|
const locSheet = workbook.getWorksheet("Stop Directory");
|
||||||
|
const locations = [];
|
||||||
|
const seenLoc = new Set();
|
||||||
|
|
||||||
|
if (locSheet) {
|
||||||
|
locSheet.eachRow((row, rowNum) => {
|
||||||
|
if (rowNum === 1) return;
|
||||||
|
// Column mapping via getCell():
|
||||||
|
// col1=Truck, col2=City, col3=State, col4=Host(venue name), col5=Address, col6=Phone, col7=Contact, col12=Notes
|
||||||
|
const rawCity = String(row.getCell(2).value || "").trim();
|
||||||
|
const rawState = String(row.getCell(3).value || "").trim();
|
||||||
|
const rawName = String(row.getCell(4).value || "").trim();
|
||||||
|
const rawAddress = String(row.getCell(5).value || "").trim();
|
||||||
|
if (!rawCity || !rawName) return;
|
||||||
|
const key = `${rawName}|${rawCity}|${rawState}`;
|
||||||
|
if (seenLoc.has(key)) return;
|
||||||
|
seenLoc.add(key);
|
||||||
|
locations.push({
|
||||||
|
name: rawName,
|
||||||
|
address: rawAddress || null,
|
||||||
|
city: rawCity,
|
||||||
|
state: rawState,
|
||||||
|
zip: null,
|
||||||
|
phone: String(row.getCell(6).value || "").trim() || null,
|
||||||
|
contact_name: String(row.getCell(7).value || "").trim() || null,
|
||||||
|
contact_email: null,
|
||||||
|
notes: String(row.getCell(12).value || "").trim() || null,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Parsed ${locations.length} locations`);
|
||||||
|
|
||||||
|
// --- Stops from Full Schedule ---
|
||||||
|
const schedSheet = workbook.getWorksheet("Full Schedule");
|
||||||
|
const stops = [];
|
||||||
|
const venueAddressMap = new Map();
|
||||||
|
|
||||||
|
if (locSheet) {
|
||||||
|
locSheet.eachRow((row, rowNum) => {
|
||||||
|
if (rowNum === 1) return;
|
||||||
|
const name = String(row.getCell(4).value || "").trim();
|
||||||
|
const address = String(row.getCell(5).value || "").trim();
|
||||||
|
if (name && address) venueAddressMap.set(name, address);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schedSheet) {
|
||||||
|
schedSheet.eachRow((row) => {
|
||||||
|
const rowValues = row.values || [];
|
||||||
|
// Column mapping (1-indexed, col1=null):
|
||||||
|
// col2=Wk, col3=Type, col4=Date, col5=Day, col6=City/Location, col7=Host/Venue, col8=Time, col9=Truck, col10=Status, col11=Notes
|
||||||
|
const firstCell = String(rowValues[1] || "").trim();
|
||||||
|
// Skip title rows, week headers ("Wk 1"), OFF, Cross-Dock
|
||||||
|
if (!firstCell || /^Wk\s*\d+/i.test(firstCell) || firstCell === "OFF" || firstCell === "Cross-Dock") return;
|
||||||
|
|
||||||
|
const dateStr = parseExcelDate(rowValues[3]);
|
||||||
|
if (!dateStr) return;
|
||||||
|
|
||||||
|
const cityStateRaw = String(rowValues[5] || "").trim();
|
||||||
|
const cityParts = cityStateRaw.split(",").map((s) => s.trim());
|
||||||
|
const city = cityParts[0] || "";
|
||||||
|
const state = cityParts[1] || "";
|
||||||
|
const location = String(rowValues[6] || "").trim();
|
||||||
|
if (!city || !state || !location) return;
|
||||||
|
|
||||||
|
const rawTime = rowValues[7];
|
||||||
|
const time = rawTime ? parseTimeRange(rawTime) : "";
|
||||||
|
const truck = String(rowValues[8] || "").trim();
|
||||||
|
const statusText = String(rowValues[9] || "").trim();
|
||||||
|
const notesText = String(rowValues[10] || "").trim();
|
||||||
|
|
||||||
|
const venueAddress = venueAddressMap.get(location) || null;
|
||||||
|
const notes = [truck ? `Truck: ${truck}` : "", statusText, notesText].filter(Boolean).join(" | ");
|
||||||
|
|
||||||
|
stops.push({
|
||||||
|
name: `${location} - ${city}, ${state}`,
|
||||||
|
location,
|
||||||
|
address: venueAddress,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
zip: null,
|
||||||
|
date: dateStr,
|
||||||
|
time,
|
||||||
|
cutoff_date: null,
|
||||||
|
status: "active",
|
||||||
|
is_public: true,
|
||||||
|
notes: notes || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Parsed ${stops.length} stops`);
|
||||||
|
return { locations, stops };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedLocations(locs) {
|
||||||
|
if (locs.length === 0) return;
|
||||||
|
await sql`DELETE FROM locations WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid`;
|
||||||
|
for (const loc of locs) {
|
||||||
|
await sql`INSERT INTO locations (brand_id, name, address, city, state, zip, phone, contact_name, contact_email, notes, active)
|
||||||
|
VALUES (${TUXEDO_BRAND_ID}::uuid, ${loc.name}, ${loc.address}, ${loc.city}, ${loc.state}, ${loc.zip}, ${loc.phone}, ${loc.contact_name}, ${loc.contact_email}, ${loc.notes}, ${loc.active})`;
|
||||||
|
}
|
||||||
|
console.log(`Seeded ${locs.length} locations`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedStops(stops) {
|
||||||
|
if (stops.length === 0) return;
|
||||||
|
const minDate = `${YEAR}-07-01`;
|
||||||
|
const maxDate = `${YEAR}-09-30`;
|
||||||
|
await sql`DELETE FROM stops WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid AND date >= ${minDate} AND date <= ${maxDate}`;
|
||||||
|
for (const stop of stops) {
|
||||||
|
await sql`INSERT INTO stops (brand_id, name, location, address, city, state, zip, date, "time", cutoff_date, status, is_public, notes)
|
||||||
|
VALUES (${TUXEDO_BRAND_ID}::uuid, ${stop.name}, ${stop.location}, ${stop.address}, ${stop.city}, ${stop.state}, ${stop.zip}, ${stop.date}, ${stop.time}, ${stop.cutoff_date}, ${stop.status}, ${stop.is_public}, ${stop.notes})`;
|
||||||
|
}
|
||||||
|
console.log(`Seeded ${stops.length} stops`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const xlsxPath = path.join(__dirname, "..", "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx");
|
||||||
|
console.log("Parsing:", xlsxPath);
|
||||||
|
const { locations, stops } = await parseXlsx(xlsxPath);
|
||||||
|
if (locations.length === 0 && stops.length === 0) {
|
||||||
|
console.error("❌ No data parsed from xlsx");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`Seeding ${locations.length} locations + ${stops.length} stops for Tuxedo Corn...`);
|
||||||
|
await seedLocations(locations);
|
||||||
|
await seedStops(stops);
|
||||||
|
const locCount = await sql.query(`SELECT COUNT(*) FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
|
||||||
|
const stopCount = await sql.query(`SELECT COUNT(*) FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
|
||||||
|
console.log(`\n✅ Done — ${locCount[0].count} locations, ${stopCount[0].count} stops in DB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("❌ Seed failed:", e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -38,10 +38,7 @@ MONTH_MAP = {
|
|||||||
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
|
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_XLSX = (
|
DEFAULT_XLSX = "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" # run from repo root; override with --xlsx /path/to/file.xlsx
|
||||||
"/home/coder/dev/x1/kyle/route_commerce-main/"
|
|
||||||
"Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_excel_date(s):
|
def parse_excel_date(s):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import CommunicationsLoading from "@/components/admin/CommunicationsLoading";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return <CommunicationsLoading activeTab="campaigns" />;
|
return <LoadingFade />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { getSession } from "@/lib/auth";
|
|||||||
import "@/styles/admin-design-system.css";
|
import "@/styles/admin-design-system.css";
|
||||||
import { ToastProvider } from "@/components/admin/Toast";
|
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 { 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
|
||||||
@@ -97,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
|
||||||
@@ -104,9 +119,21 @@ 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}
|
||||||
/>
|
/>
|
||||||
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
{/* Cmd+K command palette — mounted globally so any admin page can summon it */}
|
||||||
{children}
|
<CommandPalette />
|
||||||
|
{/* The main content area swaps on every navigation. Wrapping
|
||||||
|
it in <SmoothViewTransition> opts the swap into a soft
|
||||||
|
crossfade via the View Transitions API — sidebar and
|
||||||
|
background stay mounted, only the body fades. */}
|
||||||
|
<div
|
||||||
|
id="page-content"
|
||||||
|
className="min-h-screen lg:pl-60 admin-section outline-none"
|
||||||
|
style={{ backgroundColor: "var(--admin-bg)" }}
|
||||||
|
>
|
||||||
|
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||||
|
<RouteAnnouncer />
|
||||||
</div>
|
</div>
|
||||||
</ToastProviderWrapper>
|
</ToastProviderWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-45
@@ -1,47 +1,14 @@
|
|||||||
import LoadingSkeleton, { SkeletonCard, SkeletonTable } from "@/components/shared/LoadingSkeleton";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin shell loading state.
|
||||||
|
*
|
||||||
|
* Replaces the previous skeleton (header + 4 stat cards + 2 panels +
|
||||||
|
* 5-row table) with a 1px-thick animated bar at the top of the page.
|
||||||
|
* The AdminSidebar and parchment background stay mounted, so the
|
||||||
|
* transition is a subtle fade — the user never sees an "empty" version
|
||||||
|
* of the admin while the next page streams in.
|
||||||
|
*/
|
||||||
export default function AdminLoading() {
|
export default function AdminLoading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<main className="min-h-screen px-4 sm:px-6 md:px-8 py-8" style={{ backgroundColor: "var(--admin-bg)" }}>
|
}
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
|
||||||
{/* Header skeleton */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<LoadingSkeleton variant="text" width="w-48" height="h-8" />
|
|
||||||
<LoadingSkeleton variant="text" width="w-64" height="h-4" />
|
|
||||||
</div>
|
|
||||||
<LoadingSkeleton variant="button" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats cards skeleton */}
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="rounded-xl border bg-white p-5"
|
|
||||||
style={{ borderColor: "var(--admin-border)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="animate-pulse rounded-xl h-10 w-10" style={{ backgroundColor: "var(--admin-bg)" }} />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="animate-pulse h-3 w-16 rounded" style={{ backgroundColor: "var(--admin-bg)" }} />
|
|
||||||
<div className="animate-pulse h-5 w-12 rounded" style={{ backgroundColor: "var(--admin-bg)" }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content cards skeleton */}
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
|
||||||
<SkeletonCard />
|
|
||||||
<SkeletonCard />
|
|
||||||
<SkeletonCard />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table skeleton */}
|
|
||||||
<SkeletonTable rows={8} cols={4} />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,12 +387,28 @@ 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>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
|||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||||
|
import { getDashboardStats } from "@/actions/dashboard";
|
||||||
import DashboardClient from "@/components/admin/DashboardClient";
|
import DashboardClient from "@/components/admin/DashboardClient";
|
||||||
import { pool } from "@/lib/db";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
@@ -122,6 +123,10 @@ export default async function AdminPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch dashboard stats server-side to eliminate client-side waterfall.
|
||||||
|
// Stats are now available immediately — no "—" placeholder flash.
|
||||||
|
const stats = await getDashboardStats();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardClient
|
<DashboardClient
|
||||||
brandId={adminUser?.brand_id ?? null}
|
brandId={adminUser?.brand_id ?? null}
|
||||||
@@ -131,6 +136,7 @@ export default async function AdminPage() {
|
|||||||
enabledAddons={enabledAddons}
|
enabledAddons={enabledAddons}
|
||||||
usage={usage}
|
usage={usage}
|
||||||
limits={limits}
|
limits={limits}
|
||||||
|
stats={stats}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+242
-169
@@ -1,12 +1,20 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
import { pool } from "@/lib/db";
|
||||||
import StopEditForm from "@/components/admin/StopEditForm";
|
import StopEditForm from "@/components/admin/StopEditForm";
|
||||||
import StopProductAssignment from "@/components/admin/StopProductAssignment";
|
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;
|
||||||
@@ -26,7 +34,6 @@ interface Stop {
|
|||||||
cutoff_time: string | null;
|
cutoff_time: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
location: string;
|
location: string;
|
||||||
slug: string;
|
|
||||||
active: boolean;
|
active: boolean;
|
||||||
brands?: { name: string; slug: string };
|
brands?: { name: string; slug: string };
|
||||||
}
|
}
|
||||||
@@ -48,15 +55,6 @@ interface ProductStop {
|
|||||||
export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const [{ data: stop, error }, { data: brands }] = await Promise.all([
|
|
||||||
supabase
|
|
||||||
.from("stops")
|
|
||||||
.select("*, brands(name, slug)")
|
|
||||||
.eq("id", id)
|
|
||||||
.single() as unknown as { data: Stop | null; error: { message: string } | null },
|
|
||||||
supabase.from("brands").select("id, name, slug") as unknown as { data: { id: string; name: string; slug: string }[] | null },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
|
|
||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
@@ -65,183 +63,258 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
|||||||
|
|
||||||
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
||||||
|
|
||||||
if (adminUser.brand_id && stop?.brand_id !== adminUser.brand_id) {
|
// Fetch stop
|
||||||
return <AdminAccessDenied />;
|
const { rows: stopsRows } = await pool.query<Stop & { brand_name: string; brand_slug: string }>(
|
||||||
}
|
`SELECT s.*, b.name as brand_name, b.slug as brand_slug
|
||||||
|
FROM stops s
|
||||||
|
LEFT JOIN brands b ON b.id = s.brand_id
|
||||||
|
WHERE s.id = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const stopRow = stopsRows[0];
|
||||||
|
|
||||||
if (error || !stop) {
|
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>
|
||||||
{error?.message ?? "Stop not found"}
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ data: allProducts }, { data: productStops }] = await Promise.all([
|
if (adminUser.brand_id && stopRow.brand_id !== adminUser.brand_id) {
|
||||||
supabase
|
return <AdminAccessDenied />;
|
||||||
.from("products")
|
}
|
||||||
.select("id, name, type, price")
|
|
||||||
.eq("brand_id", stop.brand_id)
|
|
||||||
.eq("active", true) as unknown as { data: Product[] | null },
|
|
||||||
supabase
|
|
||||||
.from("product_stops")
|
|
||||||
.select("id, product_id, products(id, name, type, price)")
|
|
||||||
.eq("stop_id", id) as unknown as { data: ProductStop[] | null },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const assignedProducts = (productStops ?? [])
|
const stop: Stop = {
|
||||||
.map((ps: ProductStop) => ({
|
id: stopRow.id,
|
||||||
id: ps.id,
|
brand_id: stopRow.brand_id,
|
||||||
product_id: ps.product_id,
|
city: stopRow.city ?? "",
|
||||||
products: ps.products ? {
|
state: stopRow.state ?? "",
|
||||||
name: ps.products.name,
|
address: stopRow.address ?? null,
|
||||||
type: ps.products.type,
|
zip: stopRow.zip ?? null,
|
||||||
price: ps.products.price,
|
date: stopRow.date ? String(stopRow.date) : "",
|
||||||
image_url: ps.products.image_url,
|
time: stopRow.time ?? "",
|
||||||
} : null,
|
cutoff_date: stopRow.cutoff_date ? String(stopRow.cutoff_date) : null,
|
||||||
}))
|
cutoff_time: stopRow.cutoff_date ? String(stopRow.cutoff_date) : null,
|
||||||
.filter(Boolean);
|
status: stopRow.status ?? "active",
|
||||||
|
location: stopRow.location ?? "",
|
||||||
|
active: stopRow.status === "active",
|
||||||
|
brands: { name: stopRow.brand_name ?? "", slug: stopRow.brand_slug ?? "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch all products for this brand
|
||||||
|
const { rows: productRows } = await pool.query<{ id: string; name: string; type: string; price: number; image_url: string | null }>(
|
||||||
|
`SELECT id, name, type, price, image_url
|
||||||
|
FROM products
|
||||||
|
WHERE brand_id = $1 AND active = true
|
||||||
|
ORDER BY name`,
|
||||||
|
[stop.brand_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch product-stop assignments
|
||||||
|
const { rows: productStopRows } = await pool.query<{ id: string; product_id: string; name: string; type: string; price: number; image_url: string | null }>(
|
||||||
|
`SELECT ps.id, ps.product_id, p.name, p.type, p.price, p.image_url
|
||||||
|
FROM product_stops ps
|
||||||
|
JOIN products p ON p.id = ps.product_id
|
||||||
|
WHERE ps.stop_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch brands list
|
||||||
|
const { rows: brandRows } = await pool.query<{ id: string; name: string; slug: string }>(
|
||||||
|
`SELECT id, name, slug FROM brands ORDER BY name`
|
||||||
|
);
|
||||||
|
|
||||||
|
const allProducts = productRows.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
type: p.type,
|
||||||
|
price: Number(p.price),
|
||||||
|
image_url: p.image_url,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const assignedProducts = productStopRows.map((ps) => ({
|
||||||
|
id: ps.id,
|
||||||
|
product_id: ps.product_id,
|
||||||
|
products: {
|
||||||
|
name: ps.name,
|
||||||
|
type: ps.type,
|
||||||
|
price: Number(ps.price),
|
||||||
|
image_url: ps.image_url,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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_time && (
|
|
||||||
<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_time).toLocaleString()}
|
|
||||||
</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.slug,
|
|
||||||
active: stop.active,
|
|
||||||
brand_id: stop.brand_id,
|
|
||||||
address: stop.address,
|
|
||||||
zip: stop.zip,
|
|
||||||
cutoff_time: stop.cutoff_time,
|
|
||||||
}}
|
|
||||||
brands={brands ?? []}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
import { pool } from "@/lib/db";
|
||||||
import NewStopForm from "@/components/admin/NewStopForm";
|
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;
|
||||||
@@ -32,49 +40,67 @@ export default async function NewStopPage({
|
|||||||
|
|
||||||
let duplicateFrom: Stop | null = null;
|
let duplicateFrom: Stop | null = null;
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
const { data } = await supabase
|
const { rows } = await pool.query<Stop>(
|
||||||
.from("stops")
|
`SELECT city, state, location, date, time, brand_id, active, address, zip, cutoff_date
|
||||||
.select("city, state, location, date, time, brand_id, active, address, zip, cutoff_time")
|
FROM stops WHERE id = $1 LIMIT 1`,
|
||||||
.eq("id", duplicate)
|
[duplicate]
|
||||||
.single() as unknown as { data: Stop | null };
|
);
|
||||||
duplicateFrom = data;
|
const row = rows[0];
|
||||||
|
if (row) {
|
||||||
|
duplicateFrom = {
|
||||||
|
city: row.city ?? "",
|
||||||
|
state: row.state ?? "",
|
||||||
|
location: row.location ?? "",
|
||||||
|
date: row.date ? String(row.date) : "",
|
||||||
|
time: row.time ?? "",
|
||||||
|
brand_id: row.brand_id,
|
||||||
|
active: row.active ?? true,
|
||||||
|
address: row.address ?? null,
|
||||||
|
zip: row.zip ?? null,
|
||||||
|
cutoff_time: row.cutoff_time ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const brandId =
|
const brandId =
|
||||||
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
|
||||||
const [{ data: allProducts }] = await Promise.all([
|
const { rows: productRows } = await pool.query<{ id: string; name: string; type: string; price: number }>(
|
||||||
supabase
|
`SELECT id, name, type, price FROM products WHERE brand_id = $1 AND active = true ORDER BY name`,
|
||||||
.from("products")
|
[brandId]
|
||||||
.select("id, name, type, price")
|
);
|
||||||
.eq("brand_id", brandId)
|
|
||||||
.eq("active", true) as unknown as { data: { id: string; name: string; type: string; price: number }[] | null },
|
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>
|
||||||
|
|||||||
@@ -1,28 +1,11 @@
|
|||||||
import StopsDashboardClient from "@/components/admin/stops/StopsDashboardClient";
|
import { pool } from "@/lib/db";
|
||||||
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
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 { PageHeader } from "@/components/admin/design-system";
|
import { PageHeader } from "@/components/admin/design-system";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
interface Stop {
|
|
||||||
id: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
date: string;
|
|
||||||
time: string;
|
|
||||||
location: string;
|
|
||||||
active: boolean;
|
|
||||||
deleted_at: string | null;
|
|
||||||
brand_id: string;
|
|
||||||
address: string | null;
|
|
||||||
zip: string | null;
|
|
||||||
cutoff_time: string | null;
|
|
||||||
status: string;
|
|
||||||
brands: { name: string } | { name: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const StopIcon = () => (
|
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">
|
<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"/>
|
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
|
||||||
@@ -30,85 +13,123 @@ const StopIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default async function AdminStopsPage() {
|
interface PageProps {
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminStopsPage({ searchParams }: PageProps) {
|
||||||
|
const params = await searchParams;
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
|
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
if (!adminUser) return <AdminAccessDenied />;
|
||||||
|
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
||||||
|
|
||||||
if (!adminUser.can_manage_stops) {
|
interface DbStopRow {
|
||||||
redirect("/admin/pickup");
|
id: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
date: Date | string;
|
||||||
|
time: string | null;
|
||||||
|
location: string;
|
||||||
|
status: string;
|
||||||
|
brand_id: string;
|
||||||
|
address: string | null;
|
||||||
|
zip: string | null;
|
||||||
|
cutoff_date: string | null;
|
||||||
|
brand_name?: string;
|
||||||
}
|
}
|
||||||
|
let stops: DbStopRow[] = [];
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
let query = supabase
|
// Resolve active brand from cookie/URL (respects platform_admin "All brands" = null)
|
||||||
.from("stops")
|
let activeBrandId: string | null = null;
|
||||||
.select(`
|
try {
|
||||||
id,
|
activeBrandId = await getActiveBrandId(adminUser);
|
||||||
city,
|
|
||||||
state,
|
|
||||||
date,
|
|
||||||
time,
|
|
||||||
location,
|
|
||||||
active,
|
|
||||||
deleted_at,
|
|
||||||
brand_id,
|
|
||||||
address,
|
|
||||||
zip,
|
|
||||||
cutoff_time,
|
|
||||||
status,
|
|
||||||
brands (
|
|
||||||
name
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.is("deleted_at", null)
|
|
||||||
.order("date", { ascending: true });
|
|
||||||
|
|
||||||
if (adminUser?.brand_id) {
|
// If brand-scoped (not platform_admin) and no active brand, restrict query
|
||||||
query = query.eq("brand_id", adminUser.brand_id);
|
const brandCondition =
|
||||||
|
activeBrandId
|
||||||
|
? `brand_id = '${activeBrandId}'`
|
||||||
|
: adminUser.role === "platform_admin"
|
||||||
|
? "1=1"
|
||||||
|
: `brand_id IN ('${(adminUser.brand_ids ?? []).join("','")}')`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT s.id, s.city, s.state, s.date, s."time", s.location, s.status,
|
||||||
|
s.brand_id, s.address, s.zip, s.cutoff_date,
|
||||||
|
b.name as brand_name
|
||||||
|
FROM stops s
|
||||||
|
LEFT JOIN brands b ON b.id = s.brand_id
|
||||||
|
WHERE ${brandCondition}
|
||||||
|
ORDER BY s.date ASC
|
||||||
|
LIMIT 200`
|
||||||
|
);
|
||||||
|
stops = rows;
|
||||||
|
console.log("[admin/stops] Fetched", rows.length, "stops");
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("[admin/stops] Query error:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: stops, error } = await query as unknown as { data: Stop[] | null; error: { message: string } | null };
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<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
|
<div className="rounded-2xl border border-[var(--admin-danger)]/30 bg-[var(--admin-danger-soft)] p-5">
|
||||||
</h1>
|
<h2 className="text-lg font-semibold text-[var(--admin-danger)]">Error loading stops</h2>
|
||||||
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)]">
|
<pre className="mt-3 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)] overflow-auto">
|
||||||
{error.message}
|
{error}
|
||||||
</pre>
|
</pre>
|
||||||
|
<div className="mt-4 p-4 rounded-xl bg-[var(--admin-bg-subtle)] text-sm text-[var(--admin-text-secondary)]">
|
||||||
|
<p className="font-semibold text-[var(--admin-text-primary)]">Debug info:</p>
|
||||||
|
<p>adminUser.brand_id: {adminUser.brand_id ?? "null"}</p>
|
||||||
|
<p>adminUser.role: {adminUser.role}</p>
|
||||||
|
<p>adminUser.email: {adminUser.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transform stops for the client component
|
||||||
|
const stopsForClient = stops.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
city: s.city,
|
||||||
|
state: s.state,
|
||||||
|
date: s.date instanceof Date ? s.date.toISOString().split("T")[0] : String(s.date).split("T")[0],
|
||||||
|
time: s.time || "",
|
||||||
|
location: s.location,
|
||||||
|
active: s.status === "active",
|
||||||
|
brand_id: s.brand_id,
|
||||||
|
status: s.status,
|
||||||
|
address: s.address,
|
||||||
|
zip: s.zip,
|
||||||
|
cutoff_time: s.cutoff_date,
|
||||||
|
brands: s.brand_name ? { name: s.brand_name } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main 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
|
<PageHeader
|
||||||
breadcrumb={[
|
|
||||||
{ label: "Admin", href: "/admin" },
|
|
||||||
{ label: "Stops & Routes" }
|
|
||||||
]}
|
|
||||||
icon={<StopIcon />}
|
|
||||||
title="Stops & Routes"
|
title="Stops & Routes"
|
||||||
subtitle={adminUser?.brand_id ? "Managing stops for your brand." : "Manage routes, pickup locations, dates, and cutoff times."}
|
subtitle="Schedule and manage pickup locations and dates."
|
||||||
actions={<StopsHeaderActions brandId={adminUser?.brand_id ?? ""} />}
|
icon={<StopIcon />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="px-4 sm:px-6 md:px-8 pb-6 sm:pb-8">
|
||||||
{/* Content */}
|
<StopTableClient stops={stopsForClient} />
|
||||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
|
||||||
<StopsDashboardClient stops={stops ?? []} />
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ export async function GET() {
|
|||||||
{ status: "ok", message: "admin_users table present" },
|
{ status: "ok", message: "admin_users table present" },
|
||||||
{ status: 200 }
|
{ status: 200 }
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "Database schema check failed";
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
status: "error",
|
status: "error",
|
||||||
message: err?.message ?? "Database schema check failed",
|
message,
|
||||||
hint: "Run migrations against the DATABASE_URL (see docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md)",
|
hint: "Run migrations against the DATABASE_URL (see docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md)",
|
||||||
},
|
},
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
|
|||||||
@@ -9,19 +9,25 @@ type RequestBody = {
|
|||||||
/** Raw file content (CSV text). AI parsing is attempted if useAI=true. */
|
/** Raw file content (CSV text). AI parsing is attempted if useAI=true. */
|
||||||
text: string;
|
text: string;
|
||||||
brandId: string;
|
brandId: string;
|
||||||
/** If true and OPENAI_API_KEY is set, parse unstructured text with AI. */
|
/** If true and an AI API key is set, parse unstructured text with AI. */
|
||||||
useAI?: boolean;
|
useAI?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AI_MODEL = "gpt-4o-mini";
|
async function parseWithAI(text: string, _brandId: string): Promise<{
|
||||||
|
|
||||||
async function parseWithAI(text: string, brandId: string): Promise<{
|
|
||||||
stops: ParsedStop[];
|
stops: ParsedStop[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}> {
|
}> {
|
||||||
const apiKey = process.env.OPENAI_API_KEY;
|
// Prefer MiniMax (env-level) — fall back to OpenAI.
|
||||||
|
const provider: "minimax" | "openai" =
|
||||||
|
process.env.MINIMAX_API_KEY ? "minimax" : process.env.OPENAI_API_KEY ? "openai" : "openai";
|
||||||
|
const apiKey = provider === "minimax" ? process.env.MINIMAX_API_KEY! : process.env.OPENAI_API_KEY!;
|
||||||
|
const baseURL = provider === "minimax"
|
||||||
|
? (process.env.MINIMAX_BASE_URL || "https://api.minimax.io/v1")
|
||||||
|
: "https://api.openai.com/v1";
|
||||||
|
const model = provider === "minimax" ? "MiniMax-M3" : "gpt-4o-mini";
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error("OPENAI_API_KEY is not configured. Use CSV format for reliable parsing.");
|
throw new Error("MINIMAX_API_KEY or OPENAI_API_KEY is not configured. Use CSV format for reliable parsing.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemPrompt = `You are a schedule extraction assistant. Given raw schedule text, extract all stop entries.
|
const systemPrompt = `You are a schedule extraction assistant. Given raw schedule text, extract all stop entries.
|
||||||
@@ -37,26 +43,27 @@ Return a JSON array where each entry has:
|
|||||||
|
|
||||||
If a row lacks required fields (city, state, location), omit it and add a warning.`;
|
If a row lacks required fields (city, state, location), omit it and add a warning.`;
|
||||||
|
|
||||||
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
const res = await fetch(`${baseURL}/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: AI_MODEL,
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: `Extract stops from this schedule:\n\n${text.slice(0, 8000)}` },
|
{ role: "user", content: `Extract stops from this schedule:\n\n${text.slice(0, 8000)}` },
|
||||||
],
|
],
|
||||||
response_format: { type: "json_object" },
|
// response_format is OpenAI-specific. MiniMax /v1/chat/completions may not honor it.
|
||||||
|
...(provider === "openai" ? { response_format: { type: "json_object" } } : {}),
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.text();
|
const err = await res.text();
|
||||||
throw new Error(`OpenAI API error: ${res.status} — ${err}`);
|
throw new Error(`${provider} API error: ${res.status} — ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function BlogPage() {
|
|||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="py-12 sm:py-16 md:py-20">
|
<section className="py-12 sm:py-16 md:py-20">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
|
||||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Blog & Resources
|
Blog & Resources
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg sm:text-xl text-[#6b8f71]">
|
<p className="text-lg sm:text-xl text-[#6b8f71]">
|
||||||
@@ -151,7 +151,7 @@ export default function BlogPage() {
|
|||||||
<span className="inline-block w-fit px-3 py-1 bg-[#c97a3e]/10 text-[#c97a3e] text-sm font-medium rounded-full mb-4">
|
<span className="inline-block w-fit px-3 py-1 bg-[#c97a3e]/10 text-[#c97a3e] text-sm font-medium rounded-full mb-4">
|
||||||
Featured
|
Featured
|
||||||
</span>
|
</span>
|
||||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Getting Started with Route Commerce
|
Getting Started with Route Commerce
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[#666] mb-6">
|
<p className="text-[#666] mb-6">
|
||||||
@@ -234,7 +234,7 @@ export default function BlogPage() {
|
|||||||
{/* Newsletter */}
|
{/* Newsletter */}
|
||||||
<section className="py-20 bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f]">
|
<section className="py-20 bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f]">
|
||||||
<div className="max-w-2xl mx-auto px-6 text-center">
|
<div className="max-w-2xl mx-auto px-6 text-center">
|
||||||
<h2 className="text-3xl font-bold text-white mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h2 className="text-3xl font-bold text-white mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Stay in the Loop
|
Stay in the Loop
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[#faf8f5]/80 mb-8">
|
<p className="text-[#faf8f5]/80 mb-8">
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export default function BrandsPage() {
|
|||||||
|
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
.brands-page {
|
.brands-page {
|
||||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
font-family: var(--font-manrope);
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Your Cart — Route Commerce",
|
|
||||||
description: "Review and manage your shopping cart. Select pickup stops, adjust quantities, and proceed to checkout.",
|
|
||||||
keywords: ["cart", "shopping cart", "produce order", "checkout", "pickup"],
|
|
||||||
robots: {
|
|
||||||
index: false,
|
|
||||||
follow: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen relative">
|
}
|
||||||
{/* Dark background matching cart page */}
|
|
||||||
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" />
|
|
||||||
<div className="fixed inset-0 pointer-events-none">
|
|
||||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Minimal header skeleton */}
|
|
||||||
<div className="h-20 border-b border-white/5" />
|
|
||||||
|
|
||||||
{/* Content skeleton */}
|
|
||||||
<main className="px-6 py-12 relative">
|
|
||||||
<div className="mx-auto grid max-w-6xl gap-10 lg:grid-cols-[1fr_380px]">
|
|
||||||
<div>
|
|
||||||
{/* Title skeleton */}
|
|
||||||
<div className="h-10 w-56 rounded-lg bg-white/5 animate-pulse" />
|
|
||||||
<div className="h-5 w-80 rounded mt-3 bg-white/5 animate-pulse" />
|
|
||||||
|
|
||||||
{/* Cart items skeleton */}
|
|
||||||
<div className="mt-10 space-y-4">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="glass-card p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-5 w-40 rounded bg-white/5 animate-pulse" />
|
|
||||||
<div className="h-4 w-24 rounded bg-white/5 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-11 w-11 rounded-xl bg-white/5 animate-pulse" />
|
|
||||||
<div className="h-11 w-10 rounded bg-white/5 animate-pulse" />
|
|
||||||
<div className="h-11 w-11 rounded-xl bg-white/5 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
|
|
||||||
<div className="h-5 w-20 rounded bg-white/5 animate-pulse" />
|
|
||||||
<div className="h-4 w-16 rounded bg-white/5 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar skeleton */}
|
|
||||||
<aside>
|
|
||||||
<div className="glass-card p-6 sticky top-6">
|
|
||||||
<div className="h-6 w-32 rounded bg-white/5 animate-pulse" />
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
<div className="h-4 w-full rounded bg-white/5 animate-pulse" />
|
|
||||||
<div className="h-4 w-3/4 rounded bg-white/5 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 h-14 w-full rounded-xl bg-white/5 animate-pulse" />
|
|
||||||
<div className="mt-4 h-4 w-32 mx-auto rounded bg-white/5 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Status for accessibility */}
|
|
||||||
<span role="status" className="sr-only">Loading your cart...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export default function ChangelogPage() {
|
|||||||
<main className="max-w-4xl mx-auto px-6 py-16">
|
<main className="max-w-4xl mx-auto px-6 py-16">
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Changelog
|
Changelog
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
<p className="text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||||
|
|||||||
@@ -1,65 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Processing...",
|
|
||||||
description: "Loading checkout...",
|
|
||||||
robots: {
|
|
||||||
index: false,
|
|
||||||
follow: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
|
}
|
||||||
<div className="h-20 border-b border-slate-100/50" />
|
|
||||||
|
|
||||||
<main className="px-6 py-12">
|
|
||||||
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
|
|
||||||
<div>
|
|
||||||
{/* Title skeleton */}
|
|
||||||
<div className="h-10 w-32 rounded-lg bg-slate-200 animate-pulse" />
|
|
||||||
<div className="h-5 w-64 rounded mt-3 bg-slate-100 animate-pulse" />
|
|
||||||
|
|
||||||
{/* Form skeleton */}
|
|
||||||
<div className="mt-8 space-y-6">
|
|
||||||
<div className="rounded-2xl bg-white p-6 shadow-sm border border-slate-100">
|
|
||||||
<div className="h-6 w-40 rounded bg-slate-100 animate-pulse" />
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-20 rounded bg-slate-100 animate-pulse" />
|
|
||||||
<div className="h-12 w-full rounded-xl bg-slate-100 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
|
|
||||||
<div className="h-12 w-full rounded-xl bg-slate-100 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Button skeleton */}
|
|
||||||
<div className="h-14 w-full rounded-xl bg-slate-100 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar skeleton */}
|
|
||||||
<aside>
|
|
||||||
<div className="rounded-2xl bg-white p-6 shadow-sm border border-slate-100 sticky top-6">
|
|
||||||
<div className="h-6 w-32 rounded bg-slate-100 animate-pulse" />
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="flex justify-between">
|
|
||||||
<div className="h-4 w-32 rounded bg-slate-100 animate-pulse" />
|
|
||||||
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<span role="status" className="sr-only">Loading checkout...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,78 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Loading contact...",
|
|
||||||
description: "Loading...",
|
|
||||||
robots: {
|
|
||||||
index: false,
|
|
||||||
follow: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen bg-white">
|
}
|
||||||
{/* Header skeleton */}
|
|
||||||
<header className="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-xl border-b border-[#e5e5e5]">
|
|
||||||
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-[#e5e5e5] to-[#f5f5f5] animate-pulse" />
|
|
||||||
<div className="h-6 w-36 rounded bg-[#e5e5e5] animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="h-5 w-24 rounded bg-[#e5e5e5] animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="max-w-5xl mx-auto px-6 py-32">
|
|
||||||
{/* Page header skeleton */}
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<div className="h-4 w-20 rounded-full bg-[#e5e5e5] mx-auto mb-4 animate-pulse" />
|
|
||||||
<div className="h-12 w-64 rounded-lg bg-[#e5e5e5] mx-auto mb-4 animate-pulse" />
|
|
||||||
<div className="h-5 w-96 rounded bg-[#e5e5e5] mx-auto animate-pulse" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact cards skeleton */}
|
|
||||||
<div className="grid gap-6 md:grid-cols-3 mb-16">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="rounded-3xl bg-white border border-[#e5e5e5] p-8">
|
|
||||||
<div className="h-14 w-14 rounded-2xl bg-[#e5e5e5] mx-auto mb-5 animate-pulse" />
|
|
||||||
<div className="h-5 w-24 rounded bg-[#e5e5e5] mx-auto mb-3 animate-pulse" />
|
|
||||||
<div className="h-4 w-40 rounded bg-[#e5e5e5] mx-auto animate-pulse" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form skeleton */}
|
|
||||||
<div className="rounded-3xl bg-white border border-[#e5e5e5] p-10">
|
|
||||||
<div className="h-5 w-20 rounded-full bg-[#e5e5e5] mb-3 animate-pulse" />
|
|
||||||
<div className="h-8 w-40 rounded bg-[#e5e5e5] mb-4 animate-pulse" />
|
|
||||||
<div className="h-1 w-12 bg-[#e5e5e5] mb-8 animate-pulse" />
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-20 rounded bg-[#e5e5e5] animate-pulse" />
|
|
||||||
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-16 rounded bg-[#e5e5e5] animate-pulse" />
|
|
||||||
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-16 rounded bg-[#e5e5e5] animate-pulse" />
|
|
||||||
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-20 rounded bg-[#e5e5e5] animate-pulse" />
|
|
||||||
<div className="h-32 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="h-12 w-32 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<span role="status" className="sr-only">Loading contact page...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+74
-34
@@ -1,7 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root error boundary — the safety net for any uncaught error in the
|
||||||
|
* app. Renders inside the root layout, so it inherits the same font
|
||||||
|
* variables and design tokens as everything else.
|
||||||
|
*/
|
||||||
export default function ErrorPage({
|
export default function ErrorPage({
|
||||||
error,
|
error,
|
||||||
reset,
|
reset,
|
||||||
@@ -9,44 +15,78 @@ export default function ErrorPage({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Surface to the console for development; the Sentry-ready logger
|
||||||
|
// in src/lib/sentry.ts can be wired in here when keys are configured.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[app/error]", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center px-6 relative">
|
<main className="atelier-canvas relative min-h-screen flex items-center justify-center px-6 py-16 overflow-hidden">
|
||||||
{/* Background */}
|
<div className="atelier-grain" aria-hidden="true" />
|
||||||
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" />
|
|
||||||
<div className="fixed inset-0 pointer-events-none">
|
{/* Ambient orbs */}
|
||||||
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-red-500/5 rounded-full blur-3xl" />
|
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-30"
|
||||||
|
style={{ background: "radial-gradient(circle, rgba(185, 28, 28, 0.12) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-1/4 right-1/4 w-96 h-96 rounded-full opacity-20"
|
||||||
|
style={{ background: "radial-gradient(circle, rgba(202, 138, 4, 0.16) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center max-w-md mx-auto relative">
|
<div className="relative z-10 max-w-md w-full text-center atelier-enter">
|
||||||
<div className="glass-card p-10">
|
<div className="atelier-section-num justify-center mb-4">No. 500</div>
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-red-500/10 border border-red-500/20 mb-6">
|
|
||||||
<svg className="w-8 h-8 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<h1 className="atelier-title text-5xl sm:text-6xl">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
Something <span className="atelier-italic text-[#786B53]">broke</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-stone-600 text-sm leading-relaxed">
|
||||||
|
We hit an unexpected snag loading this page. Our harvest crew has been notified.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error.message && (
|
||||||
|
<details className="mt-6 text-left">
|
||||||
|
<summary className="atelier-fineprint cursor-pointer hover:text-stone-900 transition-colors">
|
||||||
|
Error details
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 rounded-lg border border-stone-200 bg-white/70 backdrop-blur-sm p-3.5 text-xs font-mono text-stone-700 break-words">
|
||||||
|
{error.message}
|
||||||
|
{error.digest && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-stone-200 text-stone-500">
|
||||||
|
digest: <span className="text-stone-700">{error.digest}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="atelier-rule my-8" />
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
className="atelier-cta"
|
||||||
|
aria-label="Retry loading this page"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
<span className="relative z-10">Try again</span>
|
||||||
<h1 className="text-3xl font-semibold text-white tracking-tight">Something went wrong</h1>
|
<span className="atelier-cta-shimmer" aria-hidden="true" />
|
||||||
<p className="mt-3 text-zinc-400 text-sm">
|
</button>
|
||||||
{error.message || "An unexpected error occurred."}
|
<Link
|
||||||
</p>
|
href="/"
|
||||||
{error.digest && (
|
className="atelier-discard text-center"
|
||||||
<p className="mt-2 text-xs text-zinc-600 font-mono">Digest: {error.digest}</p>
|
>
|
||||||
)}
|
← Return home
|
||||||
<div className="mt-8 flex items-center justify-center gap-4">
|
</Link>
|
||||||
<button
|
|
||||||
onClick={reset}
|
|
||||||
className="rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 px-5 py-2.5 text-sm font-medium text-white transition-all backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-lg shadow-emerald-500/20"
|
|
||||||
>
|
|
||||||
Go home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,70 @@ select:-webkit-autofill:focus {
|
|||||||
color: #999999;
|
color: #999999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── View Transitions (route fades) ────────────────────────────────────── */
|
||||||
|
/*
|
||||||
|
* Native browser View Transitions API, opt-in via the
|
||||||
|
* <ViewTransition name="page-content"> component and Next.js's
|
||||||
|
* experimental.viewTransition config. These rules define the actual
|
||||||
|
* crossfade / slide. Without them, transitions still work but use
|
||||||
|
* the browser default (instant cut).
|
||||||
|
*
|
||||||
|
* 220ms is the sweet spot for route changes: long enough to soften
|
||||||
|
* the cut, short enough to feel like a single app. The cubic-bezier
|
||||||
|
* keeps the start crisp and the finish gentle.
|
||||||
|
*/
|
||||||
|
::view-transition-old(page-content) {
|
||||||
|
animation: route-fade-out 180ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
::view-transition-new(page-content) {
|
||||||
|
animation: route-fade-in 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes route-fade-out {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes route-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top-of-page shimmer for the LoadingFade placeholder bar. */
|
||||||
|
@keyframes transition-shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(400%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce motion: respect the user's OS-level preference. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
::view-transition-old(page-content),
|
||||||
|
::view-transition-new(page-content) {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
@keyframes transition-shimmer {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
/* Atelier modal + stagger animations also respect reduced-motion */
|
||||||
|
.atelier-enter,
|
||||||
|
.atelier-backdrop,
|
||||||
|
.atelier-stagger > *,
|
||||||
|
.atelier-cta .atelier-cta-shimmer {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
.atelier-stagger > * {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Light mode placeholder (used by storefront) */
|
/* Light mode placeholder (used by storefront) */
|
||||||
.light ::placeholder,
|
.light ::placeholder,
|
||||||
.light::-webkit-input-placeholder,
|
.light::-webkit-input-placeholder,
|
||||||
@@ -1030,3 +1094,75 @@ select:-webkit-autofill:focus {
|
|||||||
color: #991B1B;
|
color: #991B1B;
|
||||||
}
|
}
|
||||||
.atelier-error svg { flex-shrink: 0; margin-top: 1px; }
|
.atelier-error svg { flex-shrink: 0; margin-top: 1px; }
|
||||||
|
|
||||||
|
/* ─── Polish utilities — "Atelier des Récoltes" additions ─────── */
|
||||||
|
|
||||||
|
/* Tabular numerals for prices, quantities, dates */
|
||||||
|
.atelier-numerals {
|
||||||
|
font-family: var(--font-fraunces);
|
||||||
|
font-variant-numeric: tabular-nums lining-nums;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline editorial pill — for tags, badges, status chips */
|
||||||
|
.atelier-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px 4px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid rgba(28, 25, 23, 0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: var(--font-fragment-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #1C1917;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.atelier-pill:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-color: rgba(34, 78, 47, 0.25);
|
||||||
|
}
|
||||||
|
.atelier-pill .atelier-pill-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #16A34A;
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
||||||
|
}
|
||||||
|
.atelier-pill.is-gold .atelier-pill-dot {
|
||||||
|
background: #CA8A04;
|
||||||
|
box-shadow: 0 0 0 2px rgba(202, 138, 4, 0.18);
|
||||||
|
}
|
||||||
|
.atelier-pill.is-muted .atelier-pill-dot {
|
||||||
|
background: #A8A29E;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Refined focus ring for atelier inputs (replaces default browser ring) */
|
||||||
|
.atelier-input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 1px 0 0 #224E2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small caps editorial small-text — for footnote, fine print */
|
||||||
|
.atelier-fineprint {
|
||||||
|
font-family: var(--font-fragment-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #786B53;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Soft cream canvas (lighter than atelier-canvas) — for sub-pages */
|
||||||
|
.atelier-canvas-soft {
|
||||||
|
background-color: #FDFBF6;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 60% 40% at 50% 0%, rgba(180, 155, 100, 0.06) 0%, transparent 60%);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,40 +1,6 @@
|
|||||||
"use client";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen bg-stone-50">
|
}
|
||||||
<div className="mx-auto max-w-4xl px-6 py-20">
|
|
||||||
{/* Hero skeleton */}
|
|
||||||
<div className="mb-20 animate-pulse text-center">
|
|
||||||
<div className="h-3 w-24 rounded bg-blue-100 mx-auto mb-5" />
|
|
||||||
<div className="h-20 w-full max-w-xl mx-auto rounded-lg bg-blue-50 mb-5" />
|
|
||||||
<div className="h-8 w-full max-w-2xl mx-auto rounded bg-blue-50" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content sections skeleton */}
|
|
||||||
<div className="space-y-20">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.1 }}
|
|
||||||
className="grid md:grid-cols-2 gap-12 items-center"
|
|
||||||
>
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-4 w-20 rounded bg-blue-100" />
|
|
||||||
<div className="h-8 w-3/4 rounded bg-stone-200" />
|
|
||||||
<div className="h-4 w-full rounded bg-stone-100" />
|
|
||||||
<div className="h-4 w-full rounded bg-stone-100" />
|
|
||||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
<div className="h-64 rounded-3xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100" />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,62 +1,6 @@
|
|||||||
"use client";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen bg-stone-50">
|
}
|
||||||
<div className="mx-auto max-w-4xl px-6 py-20">
|
|
||||||
{/* Header skeleton */}
|
|
||||||
<div className="mb-16 animate-pulse text-center">
|
|
||||||
<div className="h-4 w-20 rounded bg-blue-100 mx-auto mb-5" />
|
|
||||||
<div className="h-16 w-64 mx-auto rounded-lg bg-stone-200 mb-5" />
|
|
||||||
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact cards skeleton */}
|
|
||||||
<div className="grid gap-6 md:grid-cols-3 mb-16">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.1 }}
|
|
||||||
className="rounded-3xl bg-white border-2 border-stone-200 p-8 shadow-xl text-center"
|
|
||||||
>
|
|
||||||
<div className="h-14 w-14 rounded-2xl bg-blue-100 mx-auto mb-5" />
|
|
||||||
<div className="h-5 w-24 mx-auto rounded bg-stone-200 mb-3" />
|
|
||||||
<div className="h-4 w-full max-w-[160px] mx-auto rounded bg-stone-100" />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form skeleton */}
|
|
||||||
<div className="rounded-3xl bg-white border-2 border-stone-200 p-10 shadow-xl">
|
|
||||||
<div className="animate-pulse space-y-6">
|
|
||||||
<div className="h-6 w-32 rounded bg-stone-200" />
|
|
||||||
<div className="h-8 w-48 rounded bg-stone-200" />
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 mt-8">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
|
||||||
<div className="h-14 rounded-xl bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
|
||||||
<div className="h-14 rounded-xl bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-16 rounded bg-stone-100" />
|
|
||||||
<div className="h-14 rounded-xl bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
|
||||||
<div className="h-32 rounded-xl bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
<div className="h-14 w-40 rounded-xl bg-blue-100 mt-8" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,41 +1,6 @@
|
|||||||
"use client";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen bg-stone-50">
|
}
|
||||||
<div className="mx-auto max-w-3xl px-6 py-20">
|
|
||||||
{/* Header skeleton */}
|
|
||||||
<div className="mb-14 text-center animate-pulse">
|
|
||||||
<div className="h-4 w-16 rounded bg-blue-100 mx-auto mb-4" />
|
|
||||||
<div className="h-16 w-48 mx-auto rounded-lg bg-stone-200 mb-5" />
|
|
||||||
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search skeleton */}
|
|
||||||
<div className="mb-12">
|
|
||||||
<div className="h-14 rounded-2xl bg-white border-2 border-stone-200 shadow-lg" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FAQ items skeleton */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[0, 1, 2, 3, 4].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.05 }}
|
|
||||||
className="rounded-2xl bg-white border-2 border-stone-200 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-6 py-5">
|
|
||||||
<div className="h-5 w-3/4 rounded bg-stone-200" />
|
|
||||||
<div className="h-8 w-8 rounded-full bg-blue-100" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +1,35 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
|
||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||||
|
|
||||||
// BreadcrumbList schema for SEO
|
// Structured data for the Indian River Direct storefront.
|
||||||
const indianRiverBreadcrumbSchema = {
|
// LocalBusiness + BreadcrumbList for the family farm brand.
|
||||||
|
const indianRiverStructuredData = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "BreadcrumbList",
|
"@graph": [
|
||||||
"itemListElement": [
|
|
||||||
{
|
{
|
||||||
"@type": "ListItem",
|
"@type": "BreadcrumbList",
|
||||||
"position": 1,
|
itemListElement: [
|
||||||
"name": "Home",
|
{ "@type": "ListItem", position: 1, name: "Home", item: BASE_URL },
|
||||||
"item": BASE_URL,
|
{ "@type": "ListItem", position: 2, name: "Indian River Direct", item: `${BASE_URL}/indian-river-direct` },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "ListItem",
|
"@type": "LocalBusiness",
|
||||||
"position": 2,
|
"@id": `${BASE_URL}/indian-river-direct/#localbusiness`,
|
||||||
"name": "Indian River Direct",
|
name: "Indian River Direct",
|
||||||
"item": `${BASE_URL}/indian-river-direct`,
|
description:
|
||||||
|
"Fresh peaches and citrus from our Florida groves to truckload sales in your neighborhood. Family-owned since 1985.",
|
||||||
|
url: `${BASE_URL}/indian-river-direct`,
|
||||||
|
image: `${BASE_URL}/og-default.svg`,
|
||||||
|
priceRange: "$$",
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
addressRegion: "FL",
|
||||||
|
addressCountry: "US",
|
||||||
|
},
|
||||||
|
foundingDate: "1985",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -41,7 +53,7 @@ export const metadata: Metadata = {
|
|||||||
type: "website",
|
type: "website",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/og-indian-river.jpg",
|
url: "/og-default.svg",
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
alt: "Indian River Direct - Fresh Peaches & Citrus",
|
alt: "Indian River Direct - Fresh Peaches & Citrus",
|
||||||
@@ -54,7 +66,7 @@ export const metadata: Metadata = {
|
|||||||
description: "Fresh peaches and citrus from our Florida groves. Family-owned since 1985. Pre-order for 2026 season.",
|
description: "Fresh peaches and citrus from our Florida groves. Family-owned since 1985. Pre-order for 2026 season.",
|
||||||
site: "@IndianRiverDirect",
|
site: "@IndianRiverDirect",
|
||||||
creator: "@IndianRiverDirect",
|
creator: "@IndianRiverDirect",
|
||||||
images: ["/og-indian-river.jpg"],
|
images: ["/og-default.svg"],
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${BASE_URL}/indian-river-direct`,
|
canonical: `${BASE_URL}/indian-river-direct`,
|
||||||
@@ -68,7 +80,7 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
other: {
|
other: {
|
||||||
"application/ld+json": JSON.stringify(indianRiverBreadcrumbSchema),
|
"application/ld+json": JSON.stringify(indianRiverStructuredData),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,7 +95,7 @@ export default function IndianRiverLayout({ children }: { children: React.ReactN
|
|||||||
<div className="min-h-screen relative">
|
<div className="min-h-screen relative">
|
||||||
{/* Glass morphism gradient background */}
|
{/* Glass morphism gradient background */}
|
||||||
<div className="fixed inset-0 bg-gradient-to-br from-blue-100/40 via-white/60 to-blue-50/30" />
|
<div className="fixed inset-0 bg-gradient-to-br from-blue-100/40 via-white/60 to-blue-50/30" />
|
||||||
|
|
||||||
{/* Decorative glass orbs */}
|
{/* Decorative glass orbs */}
|
||||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-blue-200/30 to-transparent rounded-full blur-3xl" />
|
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-blue-200/30 to-transparent rounded-full blur-3xl" />
|
||||||
@@ -91,8 +103,10 @@ export default function IndianRiverLayout({ children }: { children: React.ReactN
|
|||||||
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-gradient-to-br from-blue-50/40 to-transparent rounded-full blur-3xl" />
|
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-gradient-to-br from-blue-50/40 to-transparent rounded-full blur-3xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content wrapper */}
|
{/* Content wrapper — crossfades on navigation. */}
|
||||||
<div className="relative">{children}</div>
|
<div id="page-content" className="relative outline-none">
|
||||||
|
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,78 +1,7 @@
|
|||||||
"use client";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
/** Indian River Direct storefront loading — light blue glass backdrop
|
||||||
|
* stays mounted in the layout. */
|
||||||
export default function Loading() {
|
export default function IndianRiverLoading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<main className="min-h-screen bg-white/50 backdrop-blur-xl">
|
}
|
||||||
<div className="mx-auto max-w-6xl px-6 py-16">
|
|
||||||
{/* Header skeleton with blue accent */}
|
|
||||||
<div className="mb-16 animate-pulse">
|
|
||||||
<div className="h-3 w-20 rounded bg-blue-100 mb-4" />
|
|
||||||
<div className="h-16 w-80 rounded-lg bg-blue-50 mb-4" />
|
|
||||||
<div className="h-6 w-96 max-w-full rounded bg-blue-50" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Product cards skeleton */}
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.1 }}
|
|
||||||
className="rounded-3xl bg-white border-2 border-stone-200 overflow-hidden shadow-lg"
|
|
||||||
>
|
|
||||||
{/* Image skeleton */}
|
|
||||||
<div className="h-48 bg-gradient-to-br from-blue-50 to-white relative overflow-hidden">
|
|
||||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-blue-100/60 to-transparent" />
|
|
||||||
</div>
|
|
||||||
{/* Content skeleton */}
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
<div className="h-6 w-3/4 rounded bg-stone-200" />
|
|
||||||
<div className="h-4 w-full rounded bg-stone-100" />
|
|
||||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
|
||||||
<div className="pt-4 flex items-center justify-between">
|
|
||||||
<div className="h-8 w-20 rounded-lg bg-blue-50" />
|
|
||||||
<div className="h-11 w-28 rounded-xl bg-blue-100" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stops section skeleton */}
|
|
||||||
<div className="mt-20">
|
|
||||||
<div className="animate-pulse mb-10">
|
|
||||||
<div className="h-3 w-24 rounded bg-blue-100 mb-4" />
|
|
||||||
<div className="h-10 w-64 rounded-lg bg-blue-50 mb-3" />
|
|
||||||
<div className="h-4 w-48 rounded bg-blue-50" />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.05 }}
|
|
||||||
className="rounded-3xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100 p-6"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div>
|
|
||||||
<div className="h-6 w-32 rounded bg-stone-200 mb-2" />
|
|
||||||
<div className="h-4 w-full rounded bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
<div className="h-6 w-16 rounded-full bg-blue-100" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="h-4 w-28 rounded bg-stone-100" />
|
|
||||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,70 +1,6 @@
|
|||||||
"use client";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen bg-stone-50/80 backdrop-blur-xl">
|
}
|
||||||
<div className="mx-auto max-w-5xl px-6 py-20">
|
|
||||||
{/* Back navigation skeleton */}
|
|
||||||
<div className="mb-10 animate-pulse">
|
|
||||||
<div className="h-5 w-32 rounded bg-stone-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stop header skeleton */}
|
|
||||||
<div className="mb-12 animate-pulse rounded-3xl bg-white/60 border border-white/50 p-8">
|
|
||||||
<div className="h-3 w-32 rounded bg-blue-100 mb-4" />
|
|
||||||
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
|
|
||||||
<div className="h-5 w-full max-w-md rounded bg-stone-200 mb-4" />
|
|
||||||
<div className="h-px w-12 bg-blue-100" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stop info skeleton */}
|
|
||||||
<div className="mb-14 rounded-3xl bg-white/80 border border-white/50 p-8 shadow-xl">
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.1 }}
|
|
||||||
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
|
|
||||||
>
|
|
||||||
<div className="h-10 w-10 rounded-xl bg-blue-50" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="h-3 w-12 rounded bg-stone-200 mb-2" />
|
|
||||||
<div className="h-5 w-24 rounded bg-stone-200" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Products section skeleton */}
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-4 w-20 rounded bg-blue-100 mb-4" />
|
|
||||||
<div className="h-10 w-48 rounded bg-stone-200 mb-4" />
|
|
||||||
<div className="h-4 w-64 rounded bg-stone-200 mb-10" />
|
|
||||||
<div className="grid gap-8 md:grid-cols-3">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.1 }}
|
|
||||||
className="rounded-3xl bg-white overflow-hidden shadow-lg"
|
|
||||||
>
|
|
||||||
<div className="h-48 bg-gradient-to-br from-blue-50 to-white" />
|
|
||||||
<div className="p-6 space-y-3">
|
|
||||||
<div className="h-6 w-3/4 rounded bg-stone-200" />
|
|
||||||
<div className="h-4 w-full rounded bg-stone-100" />
|
|
||||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+8
-43
@@ -1,8 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Loading Route Commerce...",
|
title: "Loading — Route Commerce",
|
||||||
description: "Loading...",
|
description: "Loading content from Route Commerce",
|
||||||
robots: {
|
robots: {
|
||||||
index: false,
|
index: false,
|
||||||
follow: false,
|
follow: false,
|
||||||
@@ -10,44 +11,8 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
// No skeleton. The previous page stays visible while the next one
|
||||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden" style={{ backgroundColor: "#faf8f5" }}>
|
// streams in, and the View Transition API crossfades them. This
|
||||||
{/* Subtle loading animation */}
|
// thin top bar is the only signal that navigation is in flight.
|
||||||
<div className="flex flex-col items-center gap-6">
|
return <LoadingFade />;
|
||||||
<div
|
}
|
||||||
className="w-16 h-16 rounded-2xl flex items-center justify-center animate-pulse"
|
|
||||||
style={{
|
|
||||||
background: "linear-gradient(135deg, #1a4d2e 0%, #166534 100%)",
|
|
||||||
boxShadow: "0 8px 32px rgba(26, 77, 46, 0.3)"
|
|
||||||
}}
|
|
||||||
aria-label="Loading"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<svg className="w-8 h-8 text-white animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-lg font-semibold text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
|
||||||
Loading
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-stone-500 mt-1">Please wait...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Decorative elements */}
|
|
||||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-10"
|
|
||||||
style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-10"
|
|
||||||
style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+118
-99
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useId } from "react";
|
||||||
|
|
||||||
type LoginClientProps = {
|
type LoginClientProps = {
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -12,6 +12,8 @@ const REDIRECT_URL = "/admin";
|
|||||||
const isDev = process.env.NODE_ENV !== "production";
|
const isDev = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
export default function LoginClient({ error }: LoginClientProps) {
|
export default function LoginClient({ error }: LoginClientProps) {
|
||||||
|
const emailId = useId();
|
||||||
|
const passwordId = useId();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -54,156 +56,173 @@ export default function LoginClient({ error }: LoginClientProps) {
|
|||||||
window.location.href = REDIRECT_URL;
|
window.location.href = REDIRECT_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const displayError = error ?? localError;
|
||||||
<main
|
|
||||||
className="min-h-screen flex flex-col relative overflow-hidden"
|
|
||||||
style={{ backgroundColor: "#faf8f5", height: "100vh" }}
|
|
||||||
>
|
|
||||||
<style jsx global>{`
|
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
|
||||||
html, body { overflow: hidden; }
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="atelier-canvas relative min-h-screen flex flex-col overflow-hidden">
|
||||||
|
{/* Decorative grain layer */}
|
||||||
|
<div className="atelier-grain" aria-hidden="true" />
|
||||||
|
|
||||||
|
{/* Ambient background flourishes */}
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||||
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
|
<div
|
||||||
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
|
className="absolute -top-32 -right-32 w-[28rem] h-[28rem] rounded-full opacity-30"
|
||||||
<div className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10" style={{ background: "radial-gradient(circle, #1a4d2e15 0%, transparent 70%)", filter: "blur(30px)" }} />
|
style={{ background: "radial-gradient(circle at 30% 30%, rgba(202, 138, 4, 0.18) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-48 -left-48 w-[36rem] h-[36rem] rounded-full opacity-25"
|
||||||
|
style={{ background: "radial-gradient(circle at 70% 70%, rgba(34, 78, 47, 0.16) 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
{/* Editorial corner flourish */}
|
||||||
<div className="w-full max-w-sm">
|
<div className="absolute top-6 left-6 atelier-flourish w-20 h-20 opacity-40" aria-hidden="true" />
|
||||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" />
|
|
||||||
|
|
||||||
<div className="p-8 sm:p-10">
|
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
||||||
<div className="text-center mb-8">
|
<div className="w-full max-w-md">
|
||||||
|
{/* Editorial numeral + section label */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<span className="atelier-section-num">No. 01</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="atelier-enter relative bg-white/90 backdrop-blur-xl rounded-2xl ring-1 ring-stone-200/80 shadow-[0_24px_64px_-24px_rgba(20,83,45,0.25)] overflow-hidden">
|
||||||
|
{/* Top hairline rule */}
|
||||||
|
<div className="atelier-rule" />
|
||||||
|
|
||||||
|
<div className="px-8 sm:px-10 py-10">
|
||||||
|
{/* Brand mark */}
|
||||||
|
<div className="flex flex-col items-center mb-8">
|
||||||
<div
|
<div
|
||||||
className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5"
|
className="inline-flex h-14 w-14 items-center justify-center rounded-2xl mb-5"
|
||||||
style={{
|
style={{
|
||||||
background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
background: "linear-gradient(135deg, #14532D 0%, #1F6B3E 50%, #14532D 100%)",
|
||||||
boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)",
|
boxShadow: "0 10px 28px -6px rgba(20, 83, 45, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-7 w-7 text-white" 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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1 className="atelier-title text-3xl sm:text-4xl text-center">
|
||||||
className="text-3xl font-semibold text-stone-900"
|
Welcome <span className="atelier-italic text-[#786B53]">back</span>
|
||||||
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}
|
|
||||||
>
|
|
||||||
Welcome back
|
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p className="mt-2 text-sm text-stone-500 text-center">
|
||||||
className="mt-2 text-sm"
|
Sign in to your <em className="atelier-italic not-italic font-normal">atelier</em> account
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
|
|
||||||
>
|
|
||||||
Sign in to your account
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<form
|
||||||
<div>
|
onSubmit={(e) => {
|
||||||
<label className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
e.preventDefault();
|
||||||
|
handleSignIn();
|
||||||
|
}}
|
||||||
|
className="space-y-5"
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor={emailId} className="atelier-section-label">
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id={emailId}
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
|
className="atelier-input"
|
||||||
placeholder="you@example.com"
|
placeholder="you@yourfarm.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleSignIn(); }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
<div className="flex items-center justify-between">
|
||||||
Password
|
<label htmlFor={passwordId} className="atelier-section-label">
|
||||||
</label>
|
Password
|
||||||
|
</label>
|
||||||
|
<a
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-[10px] tracking-[0.15em] uppercase text-stone-500 hover:text-[#14532D] transition-colors"
|
||||||
|
style={{ fontFamily: "var(--font-fragment-mono)" }}
|
||||||
|
>
|
||||||
|
Forgot?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
|
id={passwordId}
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
|
className="atelier-input"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleSignIn(); }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(error || localError) && (
|
|
||||||
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">
|
{displayError && (
|
||||||
{error ?? localError}
|
<div
|
||||||
</p>
|
role="alert"
|
||||||
|
className="atelier-stagger rounded-lg border border-rose-200/80 bg-rose-50/80 px-3.5 py-2.5 text-sm text-rose-800 flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
<span>{displayError}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
onClick={handleSignIn}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full rounded-xl px-6 py-3.5 text-sm font-semibold text-white shadow-sm transition-all disabled:opacity-60 disabled:cursor-not-allowed active:scale-[0.98]"
|
className="atelier-cta w-full"
|
||||||
style={{
|
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
|
||||||
background: loading
|
|
||||||
? "linear-gradient(135deg, #6b8f71 0%, #7ba085 100%)"
|
|
||||||
: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
|
||||||
boxShadow: "0 8px 24px rgba(26, 77, 46, 0.20)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{loading ? "Signing in…" : "Sign in with email"}
|
<span className="relative z-10">{loading ? "Signing in…" : "Sign in"}</span>
|
||||||
|
{!loading && (
|
||||||
|
<svg className="h-4 w-4 relative z-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span className="atelier-cta-shimmer" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
{/* Development bypass buttons */}
|
{/* Development bypass buttons */}
|
||||||
{isDev && (
|
{isDev && (
|
||||||
<div className="mt-6 pt-6 border-t border-stone-200">
|
<div className="mt-8 pt-6 border-t border-stone-200/80">
|
||||||
<p className="text-xs text-stone-500 text-center mb-3" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
<p className="atelier-section-label text-center mb-3">Dev Mode · Quick Access</p>
|
||||||
Dev Mode — Quick Access
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<button
|
{[
|
||||||
type="button"
|
{ role: "platform_admin", label: "Platform", color: "#14532D" },
|
||||||
onClick={() => handleDevLogin("platform_admin")}
|
{ role: "brand_admin", label: "Brand", color: "#1F6B3E" },
|
||||||
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
|
{ role: "store_employee", label: "Store", color: "#6F8562" },
|
||||||
style={{
|
].map((opt) => (
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
<button
|
||||||
background: "#1a4d2e",
|
key={opt.role}
|
||||||
}}
|
type="button"
|
||||||
>
|
onClick={() => handleDevLogin(opt.role)}
|
||||||
Platform Admin
|
className="rounded-lg px-3 py-2 text-[11px] font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
||||||
</button>
|
style={{
|
||||||
<button
|
fontFamily: "var(--font-manrope)",
|
||||||
type="button"
|
background: opt.color,
|
||||||
onClick={() => handleDevLogin("brand_admin")}
|
letterSpacing: "0.04em",
|
||||||
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
|
}}
|
||||||
style={{
|
>
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
{opt.label}
|
||||||
background: "#2d6a45",
|
</button>
|
||||||
}}
|
))}
|
||||||
>
|
|
||||||
Brand Admin
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDevLogin("store_employee")}
|
|
||||||
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
|
|
||||||
style={{
|
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
|
||||||
background: "#6b8f71",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Store Employee
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="atelier-fineprint text-center mt-6">
|
||||||
|
Protected by Neon Auth · Encrypted at rest
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Loading...",
|
|
||||||
description: "Loading...",
|
|
||||||
robots: {
|
|
||||||
index: false,
|
|
||||||
follow: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen flex items-center justify-center px-6 py-12 bg-gradient-to-br from-stone-50 to-amber-50/30">
|
}
|
||||||
{/* Background orbs */}
|
|
||||||
<div
|
|
||||||
className="fixed w-96 h-96 rounded-full pointer-events-none animate-pulse"
|
|
||||||
style={{
|
|
||||||
background: 'radial-gradient(circle, rgba(34, 197, 94, 0.15) 0%, transparent 70%)',
|
|
||||||
top: '10%',
|
|
||||||
left: '10%',
|
|
||||||
filter: 'blur(60px)'
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Loading card */}
|
|
||||||
<div className="w-full max-w-sm relative">
|
|
||||||
<div className="bg-white/70 backdrop-blur-2xl rounded-[2rem] shadow-lg p-8">
|
|
||||||
{/* Logo skeleton */}
|
|
||||||
<div className="flex justify-center mb-10">
|
|
||||||
<div className="h-20 w-20 rounded-3xl bg-slate-200 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text skeleton */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="h-8 w-32 rounded bg-slate-200 mx-auto animate-pulse" />
|
|
||||||
<div className="h-4 w-24 rounded bg-slate-100 mx-auto animate-pulse" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form skeleton */}
|
|
||||||
<div className="mt-8 space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
|
|
||||||
<div className="h-12 rounded-xl bg-slate-100 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-20 rounded bg-slate-100 animate-pulse" />
|
|
||||||
<div className="h-12 rounded-xl bg-slate-100 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="h-12 rounded-xl bg-slate-200 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span role="status" className="sr-only">Loading login page...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function MaintenancePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Heading */}
|
{/* Heading */}
|
||||||
<h1 className="text-4xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h1 className="text-4xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Under Maintenance
|
Under Maintenance
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-[#6b8f71] mb-6">
|
<p className="text-lg text-[#6b8f71] mb-6">
|
||||||
|
|||||||
+64
-61
@@ -1,79 +1,82 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Page Not Found — Route Commerce",
|
||||||
|
description: "The page you are looking for could not be found. Explore the rest of Route Commerce.",
|
||||||
|
robots: {
|
||||||
|
index: false,
|
||||||
|
follow: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#faf8f5] flex items-center justify-center px-6">
|
<main className="atelier-canvas relative min-h-screen flex items-center justify-center px-6 py-16 overflow-hidden">
|
||||||
{/* Background decorations */}
|
<div className="atelier-grain" aria-hidden="true" />
|
||||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
|
||||||
<div className="absolute top-20 left-20 w-40 h-40 bg-[#1a4d2e]/5 rounded-full blur-3xl" />
|
{/* Ambient orbs */}
|
||||||
<div className="absolute bottom-40 right-20 w-60 h-60 bg-[#c97a3e]/5 rounded-full blur-3xl" />
|
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="absolute top-1/3 left-1/3 w-96 h-96 rounded-full opacity-30"
|
||||||
|
style={{ background: "radial-gradient(circle, rgba(34, 78, 47, 0.16) 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-1/3 right-1/3 w-96 h-96 rounded-full opacity-25"
|
||||||
|
style={{ background: "radial-gradient(circle, rgba(202, 138, 4, 0.16) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center max-w-md mx-auto relative">
|
<div className="relative z-10 max-w-lg w-full text-center atelier-enter">
|
||||||
{/* 404 Illustration */}
|
<div className="atelier-section-num justify-center mb-4">No. 404</div>
|
||||||
<div className="relative mb-8">
|
|
||||||
<div className="w-24 h-24 mx-auto bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] rounded-3xl flex items-center justify-center shadow-xl">
|
{/* Large editorial numeral */}
|
||||||
<svg className="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<div
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="atelier-numeral text-[10rem] sm:text-[14rem] leading-none select-none"
|
||||||
</svg>
|
aria-hidden="true"
|
||||||
</div>
|
>
|
||||||
<span className="absolute -top-4 -right-4 w-12 h-12 bg-[#c97a3e]/20 rounded-full flex items-center justify-center text-2xl">📦</span>
|
404
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-6xl font-bold text-[#1a1a1a] mb-4 tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h1 className="atelier-title text-3xl sm:text-4xl mt-2">
|
||||||
404
|
Off the <span className="atelier-italic text-[#786B53]">beaten path</span>
|
||||||
</h1>
|
</h1>
|
||||||
<h2 className="text-2xl font-semibold text-[#1a1a1a] mb-3">
|
<p className="mt-4 text-stone-600 text-sm leading-relaxed max-w-sm mx-auto">
|
||||||
Page not found
|
We searched the field, but couldn't find that page. The route may have moved or never existed.
|
||||||
</h2>
|
|
||||||
<p className="text-[#6b8f71] mb-8">
|
|
||||||
Looks like this route got lost along the way. Let's get you back on track.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Search suggestion */}
|
<div className="atelier-rule my-8" />
|
||||||
<div className="bg-white rounded-2xl p-4 border border-[#e5e5e5] mb-8">
|
|
||||||
<p className="text-sm text-[#888] mb-3">Try searching for what you need:</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search..."
|
|
||||||
className="flex-1 px-4 py-2 rounded-xl border border-[#e5e5e5] text-sm focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50"
|
|
||||||
/>
|
|
||||||
<button className="px-4 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick links */}
|
{/* Quick links */}
|
||||||
<div className="space-y-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||||
<p className="text-sm text-[#888]">Or explore these pages:</p>
|
{[
|
||||||
<div className="flex flex-wrap justify-center gap-3">
|
{ href: "/", label: "Home", desc: "Overview" },
|
||||||
<Link href="/" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
{ href: "/pricing", label: "Pricing", desc: "Plans" },
|
||||||
Home
|
{ href: "/contact", label: "Contact", desc: "Get in touch" },
|
||||||
|
{ href: "/tuxedo", label: "Tuxedo", desc: "Sweet corn" },
|
||||||
|
].map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="group flex flex-col items-center gap-1 px-3 py-3.5 rounded-xl border border-stone-200/80 bg-white/70 backdrop-blur-sm hover:border-[#14532D] hover:bg-white transition-all"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-stone-900 group-hover:text-[#14532D] transition-colors">
|
||||||
|
{link.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] tracking-[0.12em] uppercase text-stone-500" style={{ fontFamily: "var(--font-fragment-mono)" }}>
|
||||||
|
{link.desc}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/pricing" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
))}
|
||||||
Pricing
|
|
||||||
</Link>
|
|
||||||
<Link href="/blog" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
|
||||||
Blog
|
|
||||||
</Link>
|
|
||||||
<Link href="/roadmap" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
|
||||||
Roadmap
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Report broken link */}
|
<p className="atelier-fineprint text-center mt-10">
|
||||||
<div className="mt-12 pt-8 border-t border-[#e5e5e5]">
|
Lost? Reach us at{" "}
|
||||||
<p className="text-xs text-[#888]">
|
<a href="mailto:support@routecommerce.com" className="text-[#14532D] hover:underline normal-case tracking-normal">
|
||||||
Found a broken link?{" "}
|
support@routecommerce.com
|
||||||
<a href="mailto:support@routecommerce.com" className="text-[#1a4d2e] hover:underline">
|
</a>
|
||||||
Let us know
|
</p>
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-3
@@ -3,6 +3,63 @@ import LandingPageClient from "./LandingPageClient";
|
|||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||||
|
|
||||||
|
// JSON-LD structured data for the platform landing page.
|
||||||
|
// Schema.org SoftwareApplication + Organization + FAQPage so Google
|
||||||
|
// can render rich results for the product.
|
||||||
|
const structuredData = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "Organization",
|
||||||
|
"@id": `${BASE_URL}/#organization`,
|
||||||
|
name: "Route Commerce",
|
||||||
|
url: BASE_URL,
|
||||||
|
logo: `${BASE_URL}/logo.png`,
|
||||||
|
description:
|
||||||
|
"Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
|
||||||
|
sameAs: [
|
||||||
|
// Add social profiles here as they come online
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"@id": `${BASE_URL}/#software`,
|
||||||
|
name: "Route Commerce",
|
||||||
|
applicationCategory: "BusinessApplication",
|
||||||
|
applicationSubCategory: "Wholesale Distribution Platform",
|
||||||
|
operatingSystem: "Web",
|
||||||
|
url: BASE_URL,
|
||||||
|
description:
|
||||||
|
"All-in-one platform for produce wholesale distribution: orders, stops, routes, customer communications, and Stripe/Square payments.",
|
||||||
|
offers: {
|
||||||
|
"@type": "AggregateOffer",
|
||||||
|
priceCurrency: "USD",
|
||||||
|
lowPrice: 49,
|
||||||
|
highPrice: 399,
|
||||||
|
priceSpecification: [
|
||||||
|
{ "@type": "UnitPriceSpecification", name: "Starter", price: 49, priceCurrency: "USD", description: "$49/month" },
|
||||||
|
{ "@type": "UnitPriceSpecification", name: "Farm", price: 149, priceCurrency: "USD", description: "$149/month" },
|
||||||
|
{ "@type": "UnitPriceSpecification", name: "Enterprise", price: 399, priceCurrency: "USD", description: "Custom pricing" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
aggregateRating: {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
// Placeholder — replace with real review data once collected.
|
||||||
|
ratingValue: 4.9,
|
||||||
|
reviewCount: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "WebSite",
|
||||||
|
"@id": `${BASE_URL}/#website`,
|
||||||
|
url: BASE_URL,
|
||||||
|
name: "Route Commerce",
|
||||||
|
publisher: { "@id": `${BASE_URL}/#organization` },
|
||||||
|
inLanguage: "en-US",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Route Commerce — Fresh Produce Wholesale Platform",
|
title: "Route Commerce — Fresh Produce Wholesale Platform",
|
||||||
description: "The all-in-one platform for produce wholesale distribution. Manage orders, stops, routes, and customer communications. Built for farms, Co-ops, and distributors.",
|
description: "The all-in-one platform for produce wholesale distribution. Manage orders, stops, routes, and customer communications. Built for farms, Co-ops, and distributors.",
|
||||||
@@ -19,7 +76,7 @@ export const metadata: Metadata = {
|
|||||||
type: "website",
|
type: "website",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/og-default.jpg",
|
url: "/og-default.svg",
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
alt: "Route Commerce Platform",
|
alt: "Route Commerce Platform",
|
||||||
@@ -32,7 +89,7 @@ export const metadata: Metadata = {
|
|||||||
description: "The all-in-one platform for produce wholesale distribution.",
|
description: "The all-in-one platform for produce wholesale distribution.",
|
||||||
site: "@RouteCommerce",
|
site: "@RouteCommerce",
|
||||||
creator: "@RouteCommerce",
|
creator: "@RouteCommerce",
|
||||||
images: ["/og-default.jpg"],
|
images: ["/og-default.svg"],
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: BASE_URL,
|
canonical: BASE_URL,
|
||||||
@@ -61,5 +118,14 @@ export const viewport = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
return <LandingPageClient />;
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||||
|
/>
|
||||||
|
<LandingPageClient />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,61 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Loading pricing...",
|
|
||||||
description: "Loading...",
|
|
||||||
robots: {
|
|
||||||
index: false,
|
|
||||||
follow: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen bg-white">
|
}
|
||||||
{/* Nav skeleton */}
|
|
||||||
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/95 backdrop-blur">
|
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-9 w-9 rounded-xl bg-slate-200 animate-pulse" />
|
|
||||||
<div className="h-6 w-36 rounded bg-slate-200 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<div className="h-4 w-16 rounded bg-slate-200 animate-pulse" />
|
|
||||||
<div className="h-4 w-20 rounded bg-slate-200 animate-pulse" />
|
|
||||||
<div className="h-9 w-24 rounded-xl bg-slate-200 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Hero skeleton */}
|
|
||||||
<section className="bg-gradient-to-b from-slate-50 to-white px-6 py-20 text-center">
|
|
||||||
<div className="mx-auto max-w-3xl">
|
|
||||||
<div className="h-6 w-40 rounded-full bg-slate-200 mx-auto mb-4 animate-pulse" />
|
|
||||||
<div className="h-16 w-80 rounded-lg bg-slate-200 mx-auto mb-6 animate-pulse" />
|
|
||||||
<div className="h-6 w-96 rounded bg-slate-200 mx-auto animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Plan cards skeleton */}
|
|
||||||
<section className="mx-auto max-w-6xl px-6 py-6">
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className="rounded-2xl border-2 border-slate-200 p-6">
|
|
||||||
<div className="h-6 w-24 rounded bg-slate-200 mb-3 animate-pulse" />
|
|
||||||
<div className="h-4 w-32 rounded bg-slate-200 mb-1 animate-pulse" />
|
|
||||||
<div className="h-10 w-24 rounded bg-slate-200 mb-4 animate-pulse" />
|
|
||||||
<div className="h-12 w-full rounded-xl bg-slate-200 animate-pulse" />
|
|
||||||
<div className="mt-6 space-y-2.5">
|
|
||||||
{[1, 2, 3, 4].map((j) => (
|
|
||||||
<div key={j} className="h-4 w-full rounded bg-slate-100 animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<span role="status" className="sr-only">Loading pricing page...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { getSession, signOut } from "@/lib/auth";
|
import { getSession, signOut } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
// `getSession()` reads cookies(), which forces dynamic rendering.
|
||||||
|
// Without this, Next.js tries to prerender this page at build time
|
||||||
|
// and fails with "Dynamic server usage".
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /protected-example
|
* /protected-example
|
||||||
*
|
*
|
||||||
@@ -46,7 +51,7 @@ export default async function ProtectedExamplePage() {
|
|||||||
<header>
|
<header>
|
||||||
<h1
|
<h1
|
||||||
className="text-3xl font-semibold tracking-tight text-stone-900"
|
className="text-3xl font-semibold tracking-tight text-stone-900"
|
||||||
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}
|
style={{ fontFamily: "var(--font-fraunces)" }}
|
||||||
>
|
>
|
||||||
Protected example
|
Protected example
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default function RoadmapPage() {
|
|||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="bg-gradient-to-b from-white to-[#faf8f5] py-12 sm:py-16 md:py-20">
|
<section className="bg-gradient-to-b from-white to-[#faf8f5] py-12 sm:py-16 md:py-20">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
|
||||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Product Roadmap
|
Product Roadmap
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg sm:text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
<p className="text-lg sm:text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||||
@@ -189,7 +189,7 @@ export default function RoadmapPage() {
|
|||||||
<section id="suggest" className="py-16 bg-white border-t border-[#e5e5e5]">
|
<section id="suggest" className="py-16 bg-white border-t border-[#e5e5e5]">
|
||||||
<div className="max-w-2xl mx-auto px-6">
|
<div className="max-w-2xl mx-auto px-6">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Suggest a Feature
|
Suggest a Feature
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[#666]">Have an idea? We'd love to hear it. Share your suggestion and vote on others.</p>
|
<p className="text-[#666]">Have an idea? We'd love to hear it. Share your suggestion and vote on others.</p>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export default function SecurityPage() {
|
|||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="py-20">
|
<section className="py-20">
|
||||||
<div className="max-w-4xl mx-auto px-6 text-center">
|
<div className="max-w-4xl mx-auto px-6 text-center">
|
||||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Your Data is Safe with Us
|
Your Data is Safe with Us
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
<p className="text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||||
@@ -122,7 +122,7 @@ export default function SecurityPage() {
|
|||||||
{/* Security Features */}
|
{/* Security Features */}
|
||||||
<section className="py-16">
|
<section className="py-16">
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-12 text-center" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-12 text-center" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Enterprise-Grade Security
|
Enterprise-Grade Security
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@@ -144,7 +144,7 @@ export default function SecurityPage() {
|
|||||||
<div className="max-w-4xl mx-auto px-6">
|
<div className="max-w-4xl mx-auto px-6">
|
||||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Built on Trusted Infrastructure
|
Built on Trusted Infrastructure
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[#666] mb-6">
|
<p className="text-[#666] mb-6">
|
||||||
@@ -219,7 +219,7 @@ export default function SecurityPage() {
|
|||||||
{/* Privacy Compliance */}
|
{/* Privacy Compliance */}
|
||||||
<section className="py-16">
|
<section className="py-16">
|
||||||
<div className="max-w-4xl mx-auto px-6">
|
<div className="max-w-4xl mx-auto px-6">
|
||||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Privacy Compliance
|
Privacy Compliance
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
@@ -242,7 +242,7 @@ export default function SecurityPage() {
|
|||||||
{/* Report Vulnerability */}
|
{/* Report Vulnerability */}
|
||||||
<section className="py-16 bg-[#1a4d2e] text-white">
|
<section className="py-16 bg-[#1a4d2e] text-white">
|
||||||
<div className="max-w-2xl mx-auto px-6 text-center">
|
<div className="max-w-2xl mx-auto px-6 text-center">
|
||||||
<h2 className="text-3xl font-bold mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h2 className="text-3xl font-bold mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Found a Security Issue?
|
Found a Security Issue?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-white/80 mb-6">
|
<p className="text-white/80 mb-6">
|
||||||
|
|||||||
@@ -1,40 +1,6 @@
|
|||||||
"use client";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen bg-stone-50">
|
}
|
||||||
<div className="mx-auto max-w-4xl px-6 py-20">
|
|
||||||
{/* Hero skeleton */}
|
|
||||||
<div className="mb-20 animate-pulse text-center">
|
|
||||||
<div className="h-3 w-24 rounded bg-stone-200 mx-auto mb-5" />
|
|
||||||
<div className="h-20 w-full max-w-xl mx-auto rounded-lg bg-stone-200 mb-5" />
|
|
||||||
<div className="h-8 w-full max-w-2xl mx-auto rounded bg-stone-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content sections skeleton */}
|
|
||||||
<div className="space-y-20">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.1 }}
|
|
||||||
className="grid md:grid-cols-2 gap-12 items-center"
|
|
||||||
>
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-4 w-20 rounded bg-stone-200" />
|
|
||||||
<div className="h-8 w-3/4 rounded bg-stone-200" />
|
|
||||||
<div className="h-4 w-full rounded bg-stone-100" />
|
|
||||||
<div className="h-4 w-full rounded bg-stone-100" />
|
|
||||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
<div className="h-64 rounded-3xl bg-stone-200" />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,62 +1,6 @@
|
|||||||
"use client";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen bg-stone-50">
|
}
|
||||||
<div className="mx-auto max-w-4xl px-6 py-20">
|
|
||||||
{/* Header skeleton */}
|
|
||||||
<div className="mb-16 animate-pulse text-center">
|
|
||||||
<div className="h-3 w-20 rounded bg-emerald-100 mx-auto mb-5" />
|
|
||||||
<div className="h-16 w-64 mx-auto rounded-lg bg-stone-200 mb-5" />
|
|
||||||
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact cards skeleton */}
|
|
||||||
<div className="grid gap-6 md:grid-cols-3 mb-16">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.1 }}
|
|
||||||
className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center"
|
|
||||||
>
|
|
||||||
<div className="h-12 w-12 rounded-2xl bg-emerald-50 mx-auto mb-5" />
|
|
||||||
<div className="h-5 w-24 mx-auto rounded bg-stone-200 mb-3" />
|
|
||||||
<div className="h-4 w-full max-w-[160px] mx-auto rounded bg-stone-100" />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form skeleton */}
|
|
||||||
<div className="rounded-3xl bg-white p-10 shadow-sm ring-1 ring-stone-200/60">
|
|
||||||
<div className="animate-pulse space-y-6">
|
|
||||||
<div className="h-6 w-32 rounded bg-stone-200" />
|
|
||||||
<div className="h-8 w-48 rounded bg-stone-200" />
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 mt-8">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
|
||||||
<div className="h-12 rounded-xl bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
|
||||||
<div className="h-12 rounded-xl bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-16 rounded bg-stone-100" />
|
|
||||||
<div className="h-12 rounded-xl bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
|
||||||
<div className="h-32 rounded-xl bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
<div className="h-14 w-40 rounded-xl bg-emerald-100 mt-8" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,41 +1,6 @@
|
|||||||
"use client";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen bg-stone-50">
|
}
|
||||||
<div className="mx-auto max-w-3xl px-6 py-20">
|
|
||||||
{/* Header skeleton */}
|
|
||||||
<div className="mb-14 text-center animate-pulse">
|
|
||||||
<div className="h-3 w-16 rounded bg-emerald-100 mx-auto mb-4" />
|
|
||||||
<div className="h-16 w-48 mx-auto rounded-lg bg-stone-200 mb-5" />
|
|
||||||
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search skeleton */}
|
|
||||||
<div className="mb-12">
|
|
||||||
<div className="h-14 rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FAQ items skeleton */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[0, 1, 2, 3, 4].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.05 }}
|
|
||||||
className="rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-6 py-5">
|
|
||||||
<div className="h-5 w-3/4 rounded bg-stone-200" />
|
|
||||||
<div className="h-7 w-7 rounded-full bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+43
-16
@@ -1,23 +1,45 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
|
||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||||
|
|
||||||
// BreadcrumbList schema for SEO
|
// Structured data for the Tuxedo Corn storefront. Combining a
|
||||||
const tuxedoBreadcrumbSchema = {
|
// BreadcrumbList with a LocalBusiness (the farm stand) gives Google
|
||||||
|
// enough context to show rich results for the brand.
|
||||||
|
const tuxedoStructuredData = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "BreadcrumbList",
|
"@graph": [
|
||||||
"itemListElement": [
|
|
||||||
{
|
{
|
||||||
"@type": "ListItem",
|
"@type": "BreadcrumbList",
|
||||||
"position": 1,
|
itemListElement: [
|
||||||
"name": "Home",
|
{ "@type": "ListItem", position: 1, name: "Home", item: BASE_URL },
|
||||||
"item": BASE_URL,
|
{ "@type": "ListItem", position: 2, name: "Tuxedo Corn", item: `${BASE_URL}/tuxedo` },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "ListItem",
|
"@type": "LocalBusiness",
|
||||||
"position": 2,
|
"@id": `${BASE_URL}/tuxedo/#localbusiness`,
|
||||||
"name": "Tuxedo Corn",
|
name: "Tuxedo Corn",
|
||||||
"item": `${BASE_URL}/tuxedo`,
|
description:
|
||||||
|
"Premium sweet corn and seasonal produce, delivered fresh from our Colorado farm to pickup stops near you.",
|
||||||
|
url: `${BASE_URL}/tuxedo`,
|
||||||
|
image: `${BASE_URL}/og-default.svg`,
|
||||||
|
priceRange: "$$",
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
addressRegion: "CO",
|
||||||
|
addressCountry: "US",
|
||||||
|
},
|
||||||
|
openingHoursSpecification: [
|
||||||
|
{
|
||||||
|
"@type": "OpeningHoursSpecification",
|
||||||
|
dayOfWeek: ["Saturday", "Sunday"],
|
||||||
|
description: "Pickup stops scheduled seasonally — see stops page",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sameAs: [
|
||||||
|
// Social profiles can be added here
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -41,7 +63,7 @@ export const metadata: Metadata = {
|
|||||||
type: "website",
|
type: "website",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/og-tuxedo.jpg",
|
url: "/og-default.svg",
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
alt: "Tuxedo Corn - Olathe Sweet Sweet Corn",
|
alt: "Tuxedo Corn - Olathe Sweet Sweet Corn",
|
||||||
@@ -54,7 +76,7 @@ export const metadata: Metadata = {
|
|||||||
description: "Premium sweet corn and seasonal produce delivered fresh from our Colorado farm to pickup stops near you.",
|
description: "Premium sweet corn and seasonal produce delivered fresh from our Colorado farm to pickup stops near you.",
|
||||||
site: "@TuxedoCorn",
|
site: "@TuxedoCorn",
|
||||||
creator: "@TuxedoCorn",
|
creator: "@TuxedoCorn",
|
||||||
images: ["/og-tuxedo.jpg"],
|
images: ["/og-default.svg"],
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${BASE_URL}/tuxedo`,
|
canonical: `${BASE_URL}/tuxedo`,
|
||||||
@@ -68,7 +90,7 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
other: {
|
other: {
|
||||||
"application/ld+json": JSON.stringify(tuxedoBreadcrumbSchema),
|
"application/ld+json": JSON.stringify(tuxedoStructuredData),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,7 +108,12 @@ export default function TuxedoLayout({ children }: { children: React.ReactNode }
|
|||||||
<div className="fixed inset-0 pointer-events-none">
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" />
|
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">{children}</div>
|
{/* The storefront backdrop and ambient orbs are persistent. The
|
||||||
|
page body itself crossfades between routes via the View
|
||||||
|
Transitions API. */}
|
||||||
|
<div id="page-content" className="relative outline-none">
|
||||||
|
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,8 @@
|
|||||||
"use client";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
/** Tuxedo storefront loading — the emerald backdrop and grain
|
||||||
|
* texture persist in the layout, so we only need to signal that
|
||||||
export default function Loading() {
|
* navigation is in flight. */
|
||||||
return (
|
export default function TuxedoLoading() {
|
||||||
<main className="min-h-screen bg-stone-50">
|
return <LoadingFade />;
|
||||||
<div className="mx-auto max-w-6xl px-6 py-16">
|
}
|
||||||
{/* Header skeleton */}
|
|
||||||
<div className="mb-16 animate-pulse">
|
|
||||||
<div className="h-4 w-32 rounded bg-stone-200 mb-4" />
|
|
||||||
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
|
|
||||||
<div className="h-6 w-96 max-w-full rounded bg-stone-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cards skeleton */}
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.1 }}
|
|
||||||
className="rounded-3xl bg-white ring-1 ring-stone-200/60 overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* Image skeleton */}
|
|
||||||
<div className="h-48 bg-gradient-to-br from-stone-100 to-stone-50 relative overflow-hidden">
|
|
||||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-stone-200/60 to-transparent" />
|
|
||||||
</div>
|
|
||||||
{/* Content skeleton */}
|
|
||||||
<div className="p-7 space-y-4">
|
|
||||||
<div className="h-6 w-3/4 rounded bg-stone-200" />
|
|
||||||
<div className="h-4 w-full rounded bg-stone-100" />
|
|
||||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
|
||||||
<div className="pt-4 flex items-center justify-between">
|
|
||||||
<div className="h-8 w-20 rounded-lg bg-stone-200" />
|
|
||||||
<div className="h-11 w-28 rounded-xl bg-stone-200" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stops section skeleton */}
|
|
||||||
<div className="mt-20">
|
|
||||||
<div className="animate-pulse mb-10">
|
|
||||||
<div className="h-3 w-24 rounded bg-stone-200 mb-4" />
|
|
||||||
<div className="h-10 w-64 rounded-lg bg-stone-200 mb-3" />
|
|
||||||
<div className="h-4 w-48 rounded bg-stone-200" />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.05 }}
|
|
||||||
className="rounded-3xl bg-white p-7 ring-1 ring-stone-200/60"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-4 mb-5">
|
|
||||||
<div className="h-10 w-10 rounded-xl bg-stone-100" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="h-8 w-32 rounded bg-stone-200 mb-2" />
|
|
||||||
<div className="h-4 w-full rounded bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pl-[2.75rem]">
|
|
||||||
<div className="h-4 w-28 rounded bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,70 +1,6 @@
|
|||||||
"use client";
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<div className="min-h-screen bg-stone-50">
|
}
|
||||||
<div className="mx-auto max-w-5xl px-6 py-20">
|
|
||||||
{/* Back navigation skeleton */}
|
|
||||||
<div className="mb-10 animate-pulse">
|
|
||||||
<div className="h-5 w-32 rounded bg-stone-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stop header skeleton */}
|
|
||||||
<div className="mb-12 animate-pulse rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
|
|
||||||
<div className="h-3 w-32 rounded bg-emerald-100 mb-4" />
|
|
||||||
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
|
|
||||||
<div className="h-5 w-full max-w-md rounded bg-stone-200 mb-4" />
|
|
||||||
<div className="h-px w-12 bg-emerald-600" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stop info skeleton */}
|
|
||||||
<div className="mb-14 rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.1 }}
|
|
||||||
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
|
|
||||||
>
|
|
||||||
<div className="h-10 w-10 rounded-xl bg-emerald-50" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="h-3 w-12 rounded bg-stone-200 mb-2" />
|
|
||||||
<div className="h-5 w-24 rounded bg-stone-200" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Products section skeleton */}
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-4 w-20 rounded bg-emerald-100 mb-4" />
|
|
||||||
<div className="h-10 w-48 rounded bg-stone-200 mb-4" />
|
|
||||||
<div className="h-4 w-64 rounded bg-stone-200 mb-10" />
|
|
||||||
<div className="grid gap-8 md:grid-cols-3">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.1 }}
|
|
||||||
className="rounded-3xl bg-white overflow-hidden shadow-lg"
|
|
||||||
>
|
|
||||||
<div className="h-48 bg-gradient-to-br from-stone-100 to-stone-50" />
|
|
||||||
<div className="p-6 space-y-3">
|
|
||||||
<div className="h-6 w-3/4 rounded bg-stone-200" />
|
|
||||||
<div className="h-4 w-full rounded bg-stone-100" />
|
|
||||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function WaitlistPage() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-bold text-[#1a1a1a] tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>Route Commerce</span>
|
<span className="text-xl font-bold text-[#1a1a1a] tracking-tight" style={{ fontFamily: "var(--font-fraunces)" }}>Route Commerce</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -37,7 +37,7 @@ export default function WaitlistPage() {
|
|||||||
<span className="w-2 h-2 bg-[#c97a3e] rounded-full animate-pulse" />
|
<span className="w-2 h-2 bg-[#c97a3e] rounded-full animate-pulse" />
|
||||||
<span className="text-sm font-medium text-[#c97a3e]">Early Access</span>
|
<span className="text-sm font-medium text-[#c97a3e]">Early Access</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-5xl sm:text-6xl font-bold text-[#1a1a1a] leading-tight mb-6" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h1 className="text-5xl sm:text-6xl font-bold text-[#1a1a1a] leading-tight mb-6" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Join 500+ farms on the waitlist
|
Join 500+ farms on the waitlist
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-[#6b8f71] mb-8 leading-relaxed">
|
<p className="text-xl text-[#6b8f71] mb-8 leading-relaxed">
|
||||||
@@ -76,7 +76,7 @@ export default function WaitlistPage() {
|
|||||||
{/* Right - Form */}
|
{/* Right - Form */}
|
||||||
<div>
|
<div>
|
||||||
<div className="bg-white rounded-3xl p-8 sm:p-10 shadow-xl border border-[#e5e5e5]">
|
<div className="bg-white rounded-3xl p-8 sm:p-10 shadow-xl border border-[#e5e5e5]">
|
||||||
<h2 className="text-2xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h2 className="text-2xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Reserve Your Spot
|
Reserve Your Spot
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[#888] mb-6">No credit card required. We'll notify you when it's your turn.</p>
|
<p className="text-[#888] mb-6">No credit card required. We'll notify you when it's your turn.</p>
|
||||||
@@ -89,7 +89,7 @@ export default function WaitlistPage() {
|
|||||||
{/* FAQ Section */}
|
{/* FAQ Section */}
|
||||||
<section className="border-t border-[#e5e5e5] bg-white">
|
<section className="border-t border-[#e5e5e5] bg-white">
|
||||||
<div className="max-w-3xl mx-auto px-6 py-16">
|
<div className="max-w-3xl mx-auto px-6 py-16">
|
||||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
Frequently Asked Questions
|
Frequently Asked Questions
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
|
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
|
||||||
|
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return <LoadingFade />;
|
||||||
<main className="min-h-screen bg-slate-100 px-4 py-6">
|
}
|
||||||
<div className="mx-auto max-w-lg">
|
|
||||||
<div className="animate-pulse space-y-5">
|
|
||||||
<div className="h-16 rounded-xl bg-slate-800" />
|
|
||||||
<div className="h-8 w-48 rounded bg-slate-300" />
|
|
||||||
<div className="h-12 w-full rounded-xl bg-white" />
|
|
||||||
<div className="h-12 w-full rounded-xl bg-white" />
|
|
||||||
<div className="h-12 w-full rounded-xl bg-white" />
|
|
||||||
<div className="h-12 w-full rounded-xl bg-white" />
|
|
||||||
<div className="h-10 w-full rounded-xl bg-slate-300" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -318,11 +296,11 @@ export default function AdminOrdersPanel({
|
|||||||
|
|
||||||
async function handleBulkMarkPickup() {
|
async function handleBulkMarkPickup() {
|
||||||
if (selectedOrders.size === 0) return;
|
if (selectedOrders.size === 0) return;
|
||||||
|
|
||||||
setBulkMarkingUp(true);
|
setBulkMarkingUp(true);
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
|
||||||
for (const orderId of selectedOrders) {
|
for (const orderId of selectedOrders) {
|
||||||
const result = await markPickupComplete(orderId, brandId);
|
const result = await markPickupComplete(orderId, brandId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -338,10 +316,10 @@ export default function AdminOrdersPanel({
|
|||||||
failCount++;
|
failCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setBulkMarkingUp(false);
|
setBulkMarkingUp(false);
|
||||||
setSelectedOrders(new Set());
|
setSelectedOrders(new Set());
|
||||||
|
|
||||||
if (failCount === 0) {
|
if (failCount === 0) {
|
||||||
showSuccess(`${successCount} order${successCount !== 1 ? 's' : ''} marked as picked up`);
|
showSuccess(`${successCount} order${successCount !== 1 ? 's' : ''} marked as picked up`);
|
||||||
} else {
|
} else {
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,225 +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 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({
|
||||||
@@ -227,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();
|
||||||
@@ -236,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(() => {
|
||||||
@@ -282,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 (e.key === "ArrowDown") {
|
if (total === 0) return;
|
||||||
e.preventDefault();
|
|
||||||
const nextIndex = (index + 1) % navLinks.length;
|
if (e.key === "ArrowDown") {
|
||||||
const nextLink = document.querySelector(`[data-nav-index="${nextIndex}"]`) as HTMLAnchorElement;
|
e.preventDefault();
|
||||||
nextLink?.focus();
|
const nextIndex = (index + 1) % total;
|
||||||
} else if (e.key === "ArrowUp") {
|
const nextLink = document.querySelector(
|
||||||
e.preventDefault();
|
`[data-nav-index="${nextIndex}"]`,
|
||||||
const prevIndex = index === 0 ? navLinks.length - 1 : index - 1;
|
) as HTMLAnchorElement | null;
|
||||||
const prevLink = document.querySelector(`[data-nav-index="${prevIndex}"]`) as HTMLAnchorElement;
|
nextLink?.focus();
|
||||||
prevLink?.focus();
|
} else if (e.key === "ArrowUp") {
|
||||||
} else if (e.key === "Enter" || e.key === " ") {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
const prevIndex = index === 0 ? total - 1 : index - 1;
|
||||||
router.push(href);
|
const prevLink = document.querySelector(
|
||||||
if (mobileOpen) closeMobileMenu();
|
`[data-nav-index="${prevIndex}"]`,
|
||||||
}
|
) as HTMLAnchorElement | null;
|
||||||
}, [router, mobileOpen, closeMobileMenu]);
|
prevLink?.focus();
|
||||||
|
} else if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push(href);
|
||||||
|
if (mobileOpen) closeMobileMenu();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router, mobileOpen, closeMobileMenu, visibleItems.length],
|
||||||
|
);
|
||||||
|
|
||||||
async function handleLogout() {
|
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 */}
|
||||||
@@ -318,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 */}
|
||||||
@@ -331,27 +365,27 @@ 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"
|
||||||
>
|
>
|
||||||
{/* Logo row with close button on mobile */}
|
{/* Logo row with close button on mobile */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center h-16 px-5 border-b flex-shrink-0"
|
className="flex items-center h-16 px-5 border-b flex-shrink-0"
|
||||||
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
@@ -361,17 +395,15 @@ export default function AdminSidebar({
|
|||||||
className="flex items-center gap-3 text-white hover:opacity-90 transition-opacity"
|
className="flex items-center gap-3 text-white hover:opacity-90 transition-opacity"
|
||||||
aria-label="Admin Dashboard home"
|
aria-label="Admin Dashboard home"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
|
|
||||||
{/* Mobile close button */}
|
{/* Mobile close button */}
|
||||||
<button
|
<button
|
||||||
ref={closeButtonRef}
|
ref={closeButtonRef}
|
||||||
@@ -380,113 +412,93 @@ 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: role + sign out */}
|
{/* Bottom: command-palette tip + brand picker + role + sign out */}
|
||||||
<div
|
<div
|
||||||
className="px-4 py-5 border-t flex-shrink-0"
|
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)" }}
|
||||||
>
|
>
|
||||||
{roleLabel && (
|
{/* Cmd+K hint — the command palette itself is mounted separately
|
||||||
<div
|
in the admin layout; this is just a discoverability nudge. */}
|
||||||
className="px-3 py-2.5 mb-3 rounded-xl border"
|
<div
|
||||||
style={{
|
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)",
|
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)" }}>
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brand selector — only show when admin has access to brands */}
|
||||||
|
{brands && brands.length > 0 && (
|
||||||
|
<BrandSelector
|
||||||
|
brands={brands}
|
||||||
|
activeBrandId={activeBrandId ?? null}
|
||||||
|
showAllBrandsOption={userRole === "platform_admin"}
|
||||||
|
isMultiBrandAdmin={(brandIds?.length ?? 0) > 1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{roleLabel && (
|
||||||
|
<div
|
||||||
|
className="px-3 py-2.5 rounded-xl border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||||
|
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)" }}
|
||||||
|
>
|
||||||
{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
|
||||||
@@ -496,11 +508,11 @@ 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>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type Stop = {
|
|||||||
time: string;
|
time: string;
|
||||||
location: string;
|
location: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
deleted_at?: string | null;
|
|
||||||
brand_id: string;
|
brand_id: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
brands: { name: string } | { name: string }[];
|
brands: { name: string } | { name: string }[];
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CommandPalette — Cmd+K / Ctrl+K global quick-launcher for the admin.
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - Toggled with Cmd+K (mac) or Ctrl+K (win/linux) on `document`.
|
||||||
|
* - Closes on Escape, on backdrop click, or after a navigation.
|
||||||
|
* - Fuzzy case-insensitive match on `label` / `category` / `keywords`.
|
||||||
|
* - Top 8 results. Renders null when closed (no DOM noise).
|
||||||
|
* - Keyboard: ↑/↓ to move, Enter to select, type to filter.
|
||||||
|
*
|
||||||
|
* Self-contained: does not read from the sidebar, does not depend on any
|
||||||
|
* server data. The static list lives in `./command-palette-data.ts`.
|
||||||
|
*
|
||||||
|
* The component is intentionally NOT mounted in the admin layout by this
|
||||||
|
* file. The main thread / sidebar agent wires it in once it's reviewed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Clock,
|
||||||
|
Command,
|
||||||
|
CreditCard,
|
||||||
|
Droplets,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
LucideIcon,
|
||||||
|
Map as MapIcon,
|
||||||
|
MapPin,
|
||||||
|
Megaphone,
|
||||||
|
Package,
|
||||||
|
Plug,
|
||||||
|
PlusCircle,
|
||||||
|
Puzzle,
|
||||||
|
Receipt,
|
||||||
|
RefreshCw,
|
||||||
|
Route,
|
||||||
|
ScrollText,
|
||||||
|
Search,
|
||||||
|
Send,
|
||||||
|
Settings,
|
||||||
|
ShoppingCart,
|
||||||
|
Sparkles,
|
||||||
|
Store,
|
||||||
|
Tag,
|
||||||
|
Truck,
|
||||||
|
Upload,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
Wallet,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { PALETTE_ENTRIES, type PaletteEntry } from "./command-palette-data";
|
||||||
|
|
||||||
|
const MAX_RESULTS = 8;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Icon registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
// Pages
|
||||||
|
LayoutDashboard,
|
||||||
|
Command,
|
||||||
|
ShoppingCart,
|
||||||
|
MapPin,
|
||||||
|
Package,
|
||||||
|
Truck,
|
||||||
|
Send,
|
||||||
|
Megaphone,
|
||||||
|
FileText,
|
||||||
|
Users,
|
||||||
|
ScrollText,
|
||||||
|
Store,
|
||||||
|
Upload,
|
||||||
|
Sparkles,
|
||||||
|
Clock,
|
||||||
|
Droplets,
|
||||||
|
Route,
|
||||||
|
BarChart3,
|
||||||
|
Receipt,
|
||||||
|
Settings,
|
||||||
|
Tag,
|
||||||
|
CreditCard,
|
||||||
|
Plug,
|
||||||
|
Wallet,
|
||||||
|
Puzzle,
|
||||||
|
// Quick actions
|
||||||
|
PlusCircle,
|
||||||
|
UserPlus,
|
||||||
|
RefreshCw,
|
||||||
|
// Misc / fallbacks
|
||||||
|
Search,
|
||||||
|
Map: MapIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filtering / scoring
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function scoreEntry(entry: PaletteEntry, query: string): number {
|
||||||
|
if (!query) return 1;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const label = entry.label.toLowerCase();
|
||||||
|
const category = entry.category.toLowerCase();
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
if (label === q) score += 100;
|
||||||
|
else if (label.startsWith(q)) score += 50;
|
||||||
|
else if (label.includes(q)) score += 25;
|
||||||
|
|
||||||
|
if (category.toLowerCase().includes(q)) score += 10;
|
||||||
|
if (entry.keywords?.some((k) => k.toLowerCase().includes(q))) score += 5;
|
||||||
|
|
||||||
|
// Quick actions get a small boost when no specific match (so they surface
|
||||||
|
// on the empty query list, alongside the first batch of pages).
|
||||||
|
if (!query && entry.type === "action") score = 0.5;
|
||||||
|
if (!query && entry.type === "page") score = 1 - entry.label.length * 0.001;
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterEntries(query: string): PaletteEntry[] {
|
||||||
|
const scored = PALETTE_ENTRIES
|
||||||
|
.map((e) => ({ entry: e, score: scoreEntry(e, query) }))
|
||||||
|
.filter((r) => r.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, MAX_RESULTS);
|
||||||
|
return scored.map((r) => r.entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Highlight helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||||
|
if (!query) return <>{text}</>;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const idx = text.toLowerCase().indexOf(q);
|
||||||
|
if (idx === -1) return <>{text}</>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{text.slice(0, idx)}
|
||||||
|
<mark
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--admin-primary)",
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text.slice(idx, idx + query.length)}
|
||||||
|
</mark>
|
||||||
|
{text.slice(idx + query.length)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function CommandPalette() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Open/close on Cmd+K / Ctrl+K. Toggle so the same shortcut closes it.
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset on open, focus the input, and lock body scroll.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQuery("");
|
||||||
|
setSelected(0);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
// Defer focus to next frame so the input is mounted and the modal
|
||||||
|
// animation has started (keeps the focus ring from flickering in).
|
||||||
|
const raf = requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Global Escape while open (covers the case where focus is not on input).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Filtering
|
||||||
|
const results = useMemo(() => filterEntries(query), [query]);
|
||||||
|
|
||||||
|
// Reset selection on every query change so the top result is always active.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// Clamp selection to results length
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected >= results.length && results.length > 0) {
|
||||||
|
setSelected(results.length - 1);
|
||||||
|
} else if (results.length === 0) {
|
||||||
|
setSelected(0);
|
||||||
|
}
|
||||||
|
}, [results, selected]);
|
||||||
|
|
||||||
|
const navigate = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
setOpen(false);
|
||||||
|
router.push(href);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
// In-input keyboard nav. Attached to the input itself so it works whether
|
||||||
|
// the user is typing or the input just has focus.
|
||||||
|
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (results.length === 0) return;
|
||||||
|
setSelected((s) => (s + 1) % results.length);
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (results.length === 0) return;
|
||||||
|
setSelected((s) => (s - 1 + results.length) % results.length);
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const entry = results[selected];
|
||||||
|
if (entry) navigate(entry.href);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Command palette"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 60,
|
||||||
|
backgroundColor: "rgba(26, 24, 20, 0.4)",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
WebkitBackdropFilter: "blur(4px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingTop: "15vh",
|
||||||
|
animation: "cp-fade-in 180ms ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Local keyframes — scoped via the dialog's runtime so they don't
|
||||||
|
leak into the global stylesheet. */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes cp-fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes cp-scale-in {
|
||||||
|
from { opacity: 0; transform: scale(0.98); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "36rem",
|
||||||
|
margin: "0 1rem",
|
||||||
|
backgroundColor: "var(--admin-card-bg)",
|
||||||
|
border: "1px solid var(--admin-border)",
|
||||||
|
borderRadius: "var(--admin-radius-lg)",
|
||||||
|
boxShadow: "var(--admin-shadow-lg)",
|
||||||
|
overflow: "hidden",
|
||||||
|
animation: "cp-scale-in 180ms ease-out",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search input row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
padding: "0.875rem 1rem",
|
||||||
|
borderBottom: "1px solid var(--admin-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search
|
||||||
|
size={18}
|
||||||
|
style={{ color: "var(--admin-text-muted)", flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
placeholder="Search pages, actions, anything…"
|
||||||
|
aria-label="Search command palette"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
background: "transparent",
|
||||||
|
fontSize: "1rem",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: "var(--admin-text-primary)",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Results"
|
||||||
|
style={{
|
||||||
|
maxHeight: "60vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "0.375rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
TODO (next pass): recent items
|
||||||
|
- Persist a small ring buffer of last-visited entries in localStorage
|
||||||
|
under "rc-cmdk-recent" (cap at 5).
|
||||||
|
- Read on mount, render as a "Recent" section above "Pages" when
|
||||||
|
present and the query is empty.
|
||||||
|
- Update on `navigate()` after a successful route push.
|
||||||
|
Skipped for v1 per the design spec.
|
||||||
|
*/}
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "2rem 1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--admin-text-muted)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No results for “{query}”
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
results.map((entry, i) => {
|
||||||
|
const Icon = ICON_MAP[entry.iconName] ?? Search;
|
||||||
|
const isSelected = i === selected;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onClick={() => navigate(entry.href)}
|
||||||
|
onMouseEnter={() => setSelected(i)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.625rem 0.75rem",
|
||||||
|
borderRadius: "var(--admin-radius-md)",
|
||||||
|
border: "none",
|
||||||
|
background: isSelected
|
||||||
|
? "var(--admin-primary-soft)"
|
||||||
|
: "transparent",
|
||||||
|
color: isSelected
|
||||||
|
? "var(--admin-primary)"
|
||||||
|
: "var(--admin-text-primary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: 1.3,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
transition:
|
||||||
|
"background-color 80ms ease-out, color 80ms ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={16}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: isSelected ? 1 : 0.7,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HighlightedText text={entry.label} query={query} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.6875rem",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--admin-text-muted)",
|
||||||
|
fontWeight: 500,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.category}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer hint */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem 0.625rem",
|
||||||
|
borderTop: "1px solid var(--admin-border)",
|
||||||
|
fontSize: "0.6875rem",
|
||||||
|
color: "var(--admin-text-muted)",
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↑↓ to navigate · ↵ to select · esc to close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,473 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { createStop } from "@/actions/stops/create-stop";
|
||||||
|
import { updateStop } from "@/actions/stops/update-stop";
|
||||||
|
import GlassModal from "@/components/admin/GlassModal";
|
||||||
|
|
||||||
|
type Stop = {
|
||||||
|
id: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
location: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
address?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
cutoff_time?: string | null;
|
||||||
|
active: boolean;
|
||||||
|
brand_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
brandId: string;
|
||||||
|
stop?: Stop | null;
|
||||||
|
onSuccess?: (stopId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Pin icon for the modal header */
|
||||||
|
const PinIcon = () => (
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function EditStopModal({ isOpen, onClose, brandId, stop, onSuccess }: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [city, setCity] = useState("");
|
||||||
|
const [stateField, setStateField] = useState("");
|
||||||
|
const [location, setLocation] = useState("");
|
||||||
|
const [date, setDate] = useState("");
|
||||||
|
const [time, setTime] = useState("");
|
||||||
|
const [address, setAddress] = useState("");
|
||||||
|
const [zip, setZip] = useState("");
|
||||||
|
const [cutoffTime, setCutoffTime] = useState("");
|
||||||
|
const [status, setStatus] = useState<"draft" | "active">("draft");
|
||||||
|
|
||||||
|
const cityRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isEditing = Boolean(stop);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (stop) {
|
||||||
|
// Edit mode - populate from existing stop.
|
||||||
|
// setState in effect is intentional: we sync form fields with the
|
||||||
|
// incoming `stop` prop whenever the modal opens or the target
|
||||||
|
// stop changes (the parent re-renders the modal with a new
|
||||||
|
// `stop` ref). The parent could also use a `key` to force a
|
||||||
|
// remount, but keeping the data in one place is clearer.
|
||||||
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
|
setCity(stop.city);
|
||||||
|
setStateField(stop.state);
|
||||||
|
setLocation(stop.location);
|
||||||
|
setDate(stop.date);
|
||||||
|
setTime(stop.time || "");
|
||||||
|
setAddress(stop.address ?? "");
|
||||||
|
setZip(stop.zip ?? "");
|
||||||
|
setCutoffTime(stop.cutoff_time ? stop.cutoff_time.slice(0, 16) : "");
|
||||||
|
setStatus(stop.active ? "active" : "draft");
|
||||||
|
} else {
|
||||||
|
// Add mode - reset form
|
||||||
|
setCity("");
|
||||||
|
setStateField("");
|
||||||
|
setLocation("");
|
||||||
|
setDate("");
|
||||||
|
setTime("");
|
||||||
|
setAddress("");
|
||||||
|
setZip("");
|
||||||
|
setCutoffTime("");
|
||||||
|
setStatus("draft");
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
requestAnimationFrame(() => cityRef.current?.focus());
|
||||||
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
|
}
|
||||||
|
}, [isOpen, stop]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
|
||||||
|
setError("City, state, location, and date are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if (isEditing && stop) {
|
||||||
|
result = await updateStop(stop.id, brandId, {
|
||||||
|
city: city.trim(),
|
||||||
|
state: stateField.trim(),
|
||||||
|
location: location.trim(),
|
||||||
|
date,
|
||||||
|
time: time || "08:00",
|
||||||
|
address: address || null,
|
||||||
|
zip: zip || null,
|
||||||
|
cutoff_time: cutoffTime || null,
|
||||||
|
active: status === "active",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = await createStop(brandId, {
|
||||||
|
city: city.trim(),
|
||||||
|
state: stateField.trim(),
|
||||||
|
location: location.trim(),
|
||||||
|
date,
|
||||||
|
time: time || "08:00",
|
||||||
|
address: address || undefined,
|
||||||
|
zip: zip || undefined,
|
||||||
|
cutoff_time: cutoffTime || undefined,
|
||||||
|
active: status === "active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const newStopId = isEditing ? stop!.id : (result as { success: true; id: string }).id;
|
||||||
|
onSuccess?.(newStopId);
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(result.error ?? `Failed to ${isEditing ? "update" : "create"} stop`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
brandId,
|
||||||
|
city,
|
||||||
|
stateField,
|
||||||
|
location,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
address,
|
||||||
|
zip,
|
||||||
|
cutoffTime,
|
||||||
|
status,
|
||||||
|
isEditing,
|
||||||
|
stop,
|
||||||
|
onSuccess,
|
||||||
|
onClose,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = isEditing ? "Edit Stop" : "Add Stop";
|
||||||
|
const eyebrow = isEditing
|
||||||
|
? `${stop?.city}, ${stop?.state}`
|
||||||
|
: "New stop on the route";
|
||||||
|
const submitLabel = loading
|
||||||
|
? isEditing ? "Saving…" : "Creating…"
|
||||||
|
: isEditing
|
||||||
|
? "Save Changes"
|
||||||
|
: status === "active"
|
||||||
|
? "Create & Publish"
|
||||||
|
: "Save as Draft";
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassModal
|
||||||
|
title={title}
|
||||||
|
eyebrow={eyebrow}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="max-w-xl"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||||
|
style={{ background: "var(--admin-danger-soft)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)" }}
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 1 — Where: City + State */}
|
||||||
|
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
|
||||||
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-city" className="ha-field-label">
|
||||||
|
<PinIcon />
|
||||||
|
<span>City</span>
|
||||||
|
<span className="ha-field-label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={cityRef}
|
||||||
|
id="stop-city"
|
||||||
|
type="text"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => setCity(e.target.value)}
|
||||||
|
placeholder="Denver"
|
||||||
|
className="ha-field-input"
|
||||||
|
required
|
||||||
|
autoComplete="address-level2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-state" className="ha-field-label">
|
||||||
|
<span>State</span>
|
||||||
|
<span className="ha-field-label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stop-state"
|
||||||
|
type="text"
|
||||||
|
value={stateField}
|
||||||
|
onChange={(e) => setStateField(e.target.value.toUpperCase())}
|
||||||
|
placeholder="CO"
|
||||||
|
maxLength={2}
|
||||||
|
className="ha-field-input ha-field-input-mono text-center"
|
||||||
|
style={{ textTransform: "uppercase" }}
|
||||||
|
required
|
||||||
|
autoComplete="address-level1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2 — Where at: Location (full) */}
|
||||||
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-location" className="ha-field-label">
|
||||||
|
<span>Location / Venue</span>
|
||||||
|
<span className="ha-field-label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stop-location"
|
||||||
|
type="text"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
placeholder="Whole Foods Market — Highlands"
|
||||||
|
className="ha-field-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3 — When: Date + Time */}
|
||||||
|
<div className="grid grid-cols-[1fr_1fr] gap-3">
|
||||||
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-date" className="ha-field-label">
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
|
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>Pickup Date</span>
|
||||||
|
<span className="ha-field-label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stop-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="ha-field-input ha-field-input-mono"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-time" className="ha-field-label">
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 6v6l4 2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>Pickup Time</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stop-time"
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
className="ha-field-input ha-field-input-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4 — Address + ZIP + Cutoff */}
|
||||||
|
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
|
||||||
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-address" className="ha-field-label">
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
|
||||||
|
<path d="M9 22V12h6v10" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>Street Address</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stop-address"
|
||||||
|
type="text"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder="123 Main St"
|
||||||
|
className="ha-field-input"
|
||||||
|
autoComplete="street-address"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-zip" className="ha-field-label">
|
||||||
|
<span>ZIP</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stop-zip"
|
||||||
|
type="text"
|
||||||
|
value={zip}
|
||||||
|
onChange={(e) => setZip(e.target.value)}
|
||||||
|
placeholder="80202"
|
||||||
|
maxLength={10}
|
||||||
|
className="ha-field-input ha-field-input-mono"
|
||||||
|
autoComplete="postal-code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-cutoff" className="ha-field-label">
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 7v5l3 2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>Cutoff</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stop-cutoff"
|
||||||
|
type="time"
|
||||||
|
value={cutoffTime}
|
||||||
|
onChange={(e) => setCutoffTime(e.target.value)}
|
||||||
|
className="ha-field-input ha-field-input-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 5 — Status: segmented control */}
|
||||||
|
<div className="ha-field pt-0.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="ha-field-label">
|
||||||
|
<span>Visibility</span>
|
||||||
|
</label>
|
||||||
|
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
|
||||||
|
Draft is hidden from customers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={status === "draft"}
|
||||||
|
onClick={() => setStatus("draft")}
|
||||||
|
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="ha-segment-dot" />
|
||||||
|
<span>Save as Draft</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={status === "active"}
|
||||||
|
onClick={() => setStatus("active")}
|
||||||
|
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="ha-segment-dot" />
|
||||||
|
<span>Publish Now</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="ha-modal-footer">
|
||||||
|
<span className="ha-modal-footer-hint">
|
||||||
|
<kbd>Esc</kbd>
|
||||||
|
to close
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="ha-btn-ghost"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="ha-btn-primary"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||||
|
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>{submitLabel}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isEditing ? (
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||||
|
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
) : status === "active" ? (
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||||
|
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span>{submitLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</GlassModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for easy integration
|
||||||
|
export function useEditStopModal(brandId: string) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [editingStop, setEditingStop] = useState<{
|
||||||
|
id: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
location: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
address?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
cutoff_time?: string | null;
|
||||||
|
active: boolean;
|
||||||
|
brand_id: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const open = useCallback((stop?: typeof editingStop) => {
|
||||||
|
setEditingStop(stop ?? null);
|
||||||
|
setIsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setEditingStop(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const Modal = useCallback(() => (
|
||||||
|
<EditStopModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={close}
|
||||||
|
brandId={brandId}
|
||||||
|
stop={editingStop}
|
||||||
|
/>
|
||||||
|
), [isOpen, close, brandId, editingStop]);
|
||||||
|
|
||||||
|
return { open, close, Modal, editingStop };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,9 +500,10 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 "{product.name}"?
|
Delete "{product.name}"?
|
||||||
</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>
|
||||||
|
|||||||
@@ -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 "{openProduct.name}"?
|
Delete "{openProduct.name}"?
|
||||||
</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 "{product.name}"?
|
Delete "{product.name}"?
|
||||||
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export type StopForView = {
|
|||||||
time: string;
|
time: string;
|
||||||
location: string;
|
location: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
deleted_at?: string | null;
|
|
||||||
brand_id: string;
|
brand_id: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}, []);
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminBadge({
|
// New tone palette. Every value references a `--admin-*` design token
|
||||||
children,
|
// declared in `admin-design-system.css`.
|
||||||
variant = "default",
|
const toneStyles: Record<BadgeTone, ToneStyle> = {
|
||||||
|
neutral: {
|
||||||
|
bg: "var(--admin-bg-subtle)",
|
||||||
|
text: "var(--admin-text-secondary)",
|
||||||
|
border: "var(--admin-border)",
|
||||||
|
dot: "var(--admin-text-muted)",
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
bg: "var(--admin-primary-soft)",
|
||||||
|
text: "var(--admin-primary)",
|
||||||
|
border: "var(--admin-primary)",
|
||||||
|
dot: "var(--admin-primary)",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
bg: "var(--admin-accent-soft)",
|
||||||
|
text: "var(--admin-accent)",
|
||||||
|
border: "var(--admin-accent)",
|
||||||
|
dot: "var(--admin-accent)",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
// "success" shares the primary botanical green per the unified palette.
|
||||||
|
bg: "var(--admin-primary-soft)",
|
||||||
|
text: "var(--admin-primary)",
|
||||||
|
border: "var(--admin-primary)",
|
||||||
|
dot: "var(--admin-primary)",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: "var(--admin-warning-soft)",
|
||||||
|
text: "var(--admin-warning)",
|
||||||
|
border: "var(--admin-warning)",
|
||||||
|
dot: "var(--admin-warning)",
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
bg: "var(--admin-danger-soft)",
|
||||||
|
text: "var(--admin-danger)",
|
||||||
|
border: "var(--admin-danger)",
|
||||||
|
dot: "var(--admin-danger)",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
bg: "var(--admin-bg-subtle)",
|
||||||
|
text: "var(--admin-text-secondary)",
|
||||||
|
border: "var(--admin-border)",
|
||||||
|
dot: "var(--admin-text-secondary)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map the legacy `variant` prop onto the new `tone` palette. The mapping
|
||||||
|
// preserves the visual intent of pre-refactor call sites.
|
||||||
|
const variantToTone: Record<BadgeVariant, BadgeTone> = {
|
||||||
|
default: "neutral",
|
||||||
|
success: "success",
|
||||||
|
warning: "warning",
|
||||||
|
danger: "danger",
|
||||||
|
info: "info",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminBadge({
|
||||||
|
children,
|
||||||
|
variant = "default",
|
||||||
|
tone,
|
||||||
dot = false,
|
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,15 +168,26 @@ 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";
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import StopsList from "./StopsList";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
stops: Stop[];
|
stops: Stop[];
|
||||||
|
page?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
totalCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TABS: { value: StopView; label: string; hint: string }[] = [
|
const TABS: { value: StopView; label: string; hint: string }[] = [
|
||||||
@@ -16,7 +19,7 @@ const TABS: { value: StopView; label: string; hint: string }[] = [
|
|||||||
{ value: "list", label: "List", hint: "All stops in order" },
|
{ value: "list", label: "List", hint: "All stops in order" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function StopsDashboardClient({ stops }: Props) {
|
export default function StopsDashboardClient({ stops, page = 1, totalPages = 1, totalCount = 0 }: Props) {
|
||||||
const [view, setView] = useState<StopView>("calendar");
|
const [view, setView] = useState<StopView>("calendar");
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -52,7 +55,7 @@ export default function StopsDashboardClient({ stops }: Props) {
|
|||||||
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.6 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
|
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0 0.6 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
@@ -134,6 +137,55 @@ export default function StopsDashboardClient({ stops }: Props) {
|
|||||||
value={stats.venues}
|
value={stats.venues}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="relative flex items-center justify-between border-t border-[var(--admin-border-light)] pt-4 mt-4">
|
||||||
|
<p className="font-mono text-xs text-[var(--admin-text-muted)]">
|
||||||
|
Showing {((page - 1) * 50) + 1}–{Math.min(page * 50, totalCount)} of {totalCount}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<a
|
||||||
|
href={page > 1 ? `?page=${page - 1}` : "#"}
|
||||||
|
aria-disabled={page <= 1}
|
||||||
|
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
|
||||||
|
page <= 1
|
||||||
|
? "text-[var(--admin-text-muted)] cursor-not-allowed pointer-events-none"
|
||||||
|
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</a>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||||
|
const p = i + 1;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={p}
|
||||||
|
href={`?page=${p}`}
|
||||||
|
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
|
||||||
|
p === page
|
||||||
|
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]"
|
||||||
|
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<a
|
||||||
|
href={page < totalPages ? `?page=${page + 1}` : "#"}
|
||||||
|
aria-disabled={page >= totalPages}
|
||||||
|
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
|
||||||
|
page >= totalPages
|
||||||
|
? "text-[var(--admin-text-muted)] cursor-not-allowed pointer-events-none"
|
||||||
|
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Active view */}
|
{/* Active view */}
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ export type Stop = {
|
|||||||
time: string; // HH:MM
|
time: string; // HH:MM
|
||||||
location: string;
|
location: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
deleted_at?: string | null;
|
|
||||||
brand_id: string;
|
brand_id: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
zip?: string | null;
|
zip?: string | null;
|
||||||
cutoff_time?: string | null;
|
cutoff_time?: string | null;
|
||||||
|
cutoff_date?: string | null;
|
||||||
brands: { name: string } | { name: string }[];
|
brands: { name: string } | { name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -231,22 +231,20 @@ export default function FeaturesAndStats() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
background: "#faf8f5",
|
background: "#faf8f5",
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a",
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
|
|
||||||
|
|
||||||
:global(body) {
|
:global(body) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-label {
|
.section-label {
|
||||||
font-family: "Plus Jakarta Sans", sans-serif;
|
font-family: var(--font-manrope);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
@@ -256,7 +254,7 @@ export default function FeaturesAndStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-family: "Cormorant Garamond", serif;
|
font-family: var(--font-fraunces);
|
||||||
font-size: clamp(2rem, 5vw, 3rem);
|
font-size: clamp(2rem, 5vw, 3rem);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a4d2e;
|
color: #1a4d2e;
|
||||||
@@ -265,7 +263,7 @@ export default function FeaturesAndStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section-subtitle {
|
.section-subtitle {
|
||||||
font-family: "Plus Jakarta Sans", sans-serif;
|
font-family: var(--font-manrope);
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
color: #4a4a4a;
|
color: #4a4a4a;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -304,7 +302,7 @@ export default function FeaturesAndStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feature-title {
|
.feature-title {
|
||||||
font-family: "Cormorant Garamond", serif;
|
font-family: var(--font-fraunces);
|
||||||
font-size: 1.375rem;
|
font-size: 1.375rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a4d2e;
|
color: #1a4d2e;
|
||||||
@@ -326,7 +324,7 @@ export default function FeaturesAndStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-number {
|
.stat-number {
|
||||||
font-family: "Cormorant Garamond", serif;
|
font-family: var(--font-fraunces);
|
||||||
font-size: clamp(2.5rem, 5vw, 4rem);
|
font-size: clamp(2.5rem, 5vw, 4rem);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1a4d2e;
|
color: #1a4d2e;
|
||||||
@@ -570,7 +568,7 @@ export default function FeaturesAndStats() {
|
|||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
fontFamily: "'Plus Jakarta Sans', sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
letterSpacing: "0.15em",
|
letterSpacing: "0.15em",
|
||||||
@@ -583,7 +581,7 @@ export default function FeaturesAndStats() {
|
|||||||
</span>
|
</span>
|
||||||
<h2
|
<h2
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Cormorant Garamond', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
fontSize: "clamp(2rem, 4vw, 2.75rem)",
|
fontSize: "clamp(2rem, 4vw, 2.75rem)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "#faf8f5",
|
color: "#faf8f5",
|
||||||
@@ -620,7 +618,7 @@ export default function FeaturesAndStats() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Cormorant Garamond', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
fontSize: "clamp(2.25rem, 4.5vw, 3.75rem)",
|
fontSize: "clamp(2.25rem, 4.5vw, 3.75rem)",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "#faf8f5",
|
color: "#faf8f5",
|
||||||
@@ -660,7 +658,7 @@ export default function FeaturesAndStats() {
|
|||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: "#6b8f71",
|
color: "#6b8f71",
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export default function HeroSection() {
|
|||||||
<h1
|
<h1
|
||||||
className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl xl:text-9xl font-bold leading-[0.9] tracking-tighter"
|
className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl xl:text-9xl font-bold leading-[0.9] tracking-tighter"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Playfair Display', 'Georgia', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -323,7 +323,7 @@ export default function HeroSection() {
|
|||||||
<p
|
<p
|
||||||
className="hero-reveal hero-subtitle text-lg sm:text-xl md:text-2xl max-w-xl leading-relaxed"
|
className="hero-reveal hero-subtitle text-lg sm:text-xl md:text-2xl max-w-xl leading-relaxed"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'DM Sans', sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
color: "#4a6d56",
|
color: "#4a6d56",
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
}}
|
}}
|
||||||
@@ -393,7 +393,7 @@ export default function HeroSection() {
|
|||||||
<div
|
<div
|
||||||
className="text-2xl sm:text-3xl lg:text-4xl font-bold"
|
className="text-2xl sm:text-3xl lg:text-4xl font-bold"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Playfair Display', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
color: "#1a4d2e",
|
color: "#1a4d2e",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -611,7 +611,7 @@ export default function HeroSection() {
|
|||||||
<div className="mb-8 reveal-scale">
|
<div className="mb-8 reveal-scale">
|
||||||
<span
|
<span
|
||||||
className="inline-block text-xs font-bold tracking-[0.3em] uppercase"
|
className="inline-block text-xs font-bold tracking-[0.3em] uppercase"
|
||||||
style={{ color: "#c97a3e", fontFamily: "'DM Sans', sans-serif" }}
|
style={{ color: "#c97a3e", fontFamily: "var(--font-manrope)" }}
|
||||||
>
|
>
|
||||||
The Challenge
|
The Challenge
|
||||||
</span>
|
</span>
|
||||||
@@ -621,7 +621,7 @@ export default function HeroSection() {
|
|||||||
<h2
|
<h2
|
||||||
className="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight mb-8"
|
className="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight mb-8"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Playfair Display', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
color: "#faf8f5",
|
color: "#faf8f5",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -636,7 +636,7 @@ export default function HeroSection() {
|
|||||||
className="text-xl max-w-2xl mx-auto"
|
className="text-xl max-w-2xl mx-auto"
|
||||||
style={{
|
style={{
|
||||||
color: "#86868b",
|
color: "#86868b",
|
||||||
fontFamily: "'DM Sans', sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -669,14 +669,14 @@ export default function HeroSection() {
|
|||||||
<div className="reveal-scale text-center mb-20">
|
<div className="reveal-scale text-center mb-20">
|
||||||
<span
|
<span
|
||||||
className="inline-block text-xs font-bold tracking-[0.2em] uppercase mb-6"
|
className="inline-block text-xs font-bold tracking-[0.2em] uppercase mb-6"
|
||||||
style={{ color: "#6b8f71", fontFamily: "'DM Sans', sans-serif" }}
|
style={{ color: "#6b8f71", fontFamily: "var(--font-manrope)" }}
|
||||||
>
|
>
|
||||||
Platform Features
|
Platform Features
|
||||||
</span>
|
</span>
|
||||||
<h2
|
<h2
|
||||||
className="text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight"
|
className="text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Playfair Display', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
color: "#1a4d2e",
|
color: "#1a4d2e",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -792,7 +792,7 @@ export default function HeroSection() {
|
|||||||
<h3
|
<h3
|
||||||
className="text-2xl font-bold mb-4"
|
className="text-2xl font-bold mb-4"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Playfair Display', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
color: "#1a4d2e",
|
color: "#1a4d2e",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -802,7 +802,7 @@ export default function HeroSection() {
|
|||||||
className="text-base leading-relaxed"
|
className="text-base leading-relaxed"
|
||||||
style={{
|
style={{
|
||||||
color: "#555",
|
color: "#555",
|
||||||
fontFamily: "'DM Sans', sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{feature.description}
|
{feature.description}
|
||||||
@@ -845,7 +845,7 @@ export default function HeroSection() {
|
|||||||
<div className="text-center mb-16 reveal-scale">
|
<div className="text-center mb-16 reveal-scale">
|
||||||
<span
|
<span
|
||||||
className="inline-block text-xs font-bold tracking-[0.2em] uppercase"
|
className="inline-block text-xs font-bold tracking-[0.2em] uppercase"
|
||||||
style={{ color: "#c97a3e", fontFamily: "'DM Sans', sans-serif" }}
|
style={{ color: "#c97a3e", fontFamily: "var(--font-manrope)" }}
|
||||||
>
|
>
|
||||||
Our Impact
|
Our Impact
|
||||||
</span>
|
</span>
|
||||||
@@ -863,7 +863,7 @@ export default function HeroSection() {
|
|||||||
<div
|
<div
|
||||||
className="text-5xl sm:text-6xl lg:text-7xl font-bold mb-4"
|
className="text-5xl sm:text-6xl lg:text-7xl font-bold mb-4"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Playfair Display', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
color: "#faf8f5",
|
color: "#faf8f5",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
@@ -874,7 +874,7 @@ export default function HeroSection() {
|
|||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
className="text-base font-medium"
|
className="text-base font-medium"
|
||||||
style={{ color: "#6b8f71", fontFamily: "'DM Sans', sans-serif" }}
|
style={{ color: "#6b8f71", fontFamily: "var(--font-manrope)" }}
|
||||||
>
|
>
|
||||||
{stat.label}
|
{stat.label}
|
||||||
</p>
|
</p>
|
||||||
@@ -914,7 +914,7 @@ export default function HeroSection() {
|
|||||||
style={{
|
style={{
|
||||||
color: "#c97a3e",
|
color: "#c97a3e",
|
||||||
background: "rgba(201, 122, 62, 0.1)",
|
background: "rgba(201, 122, 62, 0.1)",
|
||||||
fontFamily: "'DM Sans', sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Get Started Today
|
Get Started Today
|
||||||
@@ -926,7 +926,7 @@ export default function HeroSection() {
|
|||||||
<h2
|
<h2
|
||||||
className="text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight mb-8"
|
className="text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight mb-8"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Playfair Display', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
color: "#1a4d2e",
|
color: "#1a4d2e",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -941,7 +941,7 @@ export default function HeroSection() {
|
|||||||
className="text-xl mb-12 max-w-xl mx-auto"
|
className="text-xl mb-12 max-w-xl mx-auto"
|
||||||
style={{
|
style={{
|
||||||
color: "#555",
|
color: "#555",
|
||||||
fontFamily: "'DM Sans', sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Join hundreds of farms already using Route Commerce to deliver fresher produce faster.
|
Join hundreds of farms already using Route Commerce to deliver fresher produce faster.
|
||||||
@@ -998,6 +998,25 @@ export default function HeroSection() {
|
|||||||
66% { transform: translate(10px, -10px); }
|
66% { transform: translate(10px, -10px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes float-slow {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
50% { transform: translate(25px, -30px) scale(1.02); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-slow-delayed {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
50% { transform: translate(-30px, 20px) scale(1.03); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.parallax-float,
|
||||||
|
.parallax-float *,
|
||||||
|
[class*="animate-"] {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes draw-route {
|
@keyframes draw-route {
|
||||||
to { stroke-dashoffset: 0; }
|
to { stroke-dashoffset: 0; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function Header({ className = "" }: HeaderProps) {
|
|||||||
<span
|
<span
|
||||||
className="text-xl font-semibold tracking-tight"
|
className="text-xl font-semibold tracking-tight"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Cormorant Garamond', Georgia, serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -71,7 +71,7 @@ export function Header({ className = "" }: HeaderProps) {
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
className="text-sm font-medium transition-colors hover:opacity-70"
|
className="text-sm font-medium transition-colors hover:opacity-70"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -86,7 +86,7 @@ export function Header({ className = "" }: HeaderProps) {
|
|||||||
href="/login"
|
href="/login"
|
||||||
className="text-sm font-medium transition-opacity hover:opacity-70"
|
className="text-sm font-medium transition-opacity hover:opacity-70"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
color: "#1a4d2e",
|
color: "#1a4d2e",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -96,7 +96,7 @@ export function Header({ className = "" }: HeaderProps) {
|
|||||||
href="/admin"
|
href="/admin"
|
||||||
className="px-5 py-2.5 rounded-full text-sm font-semibold transition-all hover:opacity-90 active:scale-95"
|
className="px-5 py-2.5 rounded-full text-sm font-semibold transition-all hover:opacity-90 active:scale-95"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
background: "rgba(26, 77, 46, 0.9)",
|
background: "rgba(26, 77, 46, 0.9)",
|
||||||
backdropFilter: "blur(10px)",
|
backdropFilter: "blur(10px)",
|
||||||
WebkitBackdropFilter: "blur(10px)",
|
WebkitBackdropFilter: "blur(10px)",
|
||||||
@@ -160,7 +160,7 @@ export function Header({ className = "" }: HeaderProps) {
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
className="px-4 py-3 text-base font-medium rounded-lg transition-colors hover:bg-[#6b8f71]/10"
|
className="px-4 py-3 text-base font-medium rounded-lg transition-colors hover:bg-[#6b8f71]/10"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a",
|
||||||
}}
|
}}
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
@@ -176,7 +176,7 @@ export function Header({ className = "" }: HeaderProps) {
|
|||||||
href="/login"
|
href="/login"
|
||||||
className="py-3 text-base font-medium"
|
className="py-3 text-base font-medium"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
color: "#1a4d2e",
|
color: "#1a4d2e",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -186,7 +186,7 @@ export function Header({ className = "" }: HeaderProps) {
|
|||||||
href="/admin"
|
href="/admin"
|
||||||
className="py-3 rounded-full text-base font-semibold text-center"
|
className="py-3 rounded-full text-base font-semibold text-center"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
background: "rgba(26, 77, 46, 0.9)",
|
background: "rgba(26, 77, 46, 0.9)",
|
||||||
backdropFilter: "blur(10px)",
|
backdropFilter: "blur(10px)",
|
||||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||||
@@ -252,7 +252,7 @@ export function Footer({ className = "" }: FooterProps) {
|
|||||||
<span
|
<span
|
||||||
className="text-sm font-medium tracking-tight"
|
className="text-sm font-medium tracking-tight"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Cormorant Garamond', Georgia, serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -262,7 +262,7 @@ export function Footer({ className = "" }: FooterProps) {
|
|||||||
<span
|
<span
|
||||||
className="text-xs mt-1"
|
className="text-xs mt-1"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
color: "#9a9590",
|
color: "#9a9590",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -278,7 +278,7 @@ export function Footer({ className = "" }: FooterProps) {
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]"
|
className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
color: "#6b8f71",
|
color: "#6b8f71",
|
||||||
letterSpacing: "0.08em",
|
letterSpacing: "0.08em",
|
||||||
}}
|
}}
|
||||||
@@ -292,11 +292,11 @@ export function Footer({ className = "" }: FooterProps) {
|
|||||||
<span
|
<span
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
color: "#b5b0a8",
|
color: "#b5b0a8",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
© 2025
|
© {new Date().getFullYear()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,15 +319,11 @@ interface WrapperProps {
|
|||||||
export function LandingPageWrapper({ children, className = "" }: WrapperProps) {
|
export function LandingPageWrapper({ children, className = "" }: WrapperProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Google Fonts Import */}
|
{/* Fonts loaded via next/font in app/layout.tsx — no external @import needed */}
|
||||||
<style jsx global>{`
|
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`min-h-screen flex flex-col ${className}`}
|
className={`min-h-screen flex flex-col ${className}`}
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
scrollBehavior: "smooth",
|
scrollBehavior: "smooth",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function TestimonialsAndCTA() {
|
|||||||
<section
|
<section
|
||||||
style={{
|
style={{
|
||||||
background: "linear-gradient(180deg, #faf8f5 0%, #f5f2ed 100%)",
|
background: "linear-gradient(180deg, #faf8f5 0%, #f5f2ed 100%)",
|
||||||
fontFamily: "'Plus Jakarta Sans', sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Decorative botanical accent */}
|
{/* Decorative botanical accent */}
|
||||||
@@ -132,7 +132,7 @@ export default function TestimonialsAndCTA() {
|
|||||||
</span>
|
</span>
|
||||||
<h2
|
<h2
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Cormorant Garamond', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
fontSize: "clamp(2.5rem, 5vw, 3.5rem)",
|
fontSize: "clamp(2.5rem, 5vw, 3.5rem)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
lineHeight: 1.1,
|
lineHeight: 1.1,
|
||||||
@@ -193,7 +193,7 @@ export default function TestimonialsAndCTA() {
|
|||||||
top: "-8px",
|
top: "-8px",
|
||||||
right: "20px",
|
right: "20px",
|
||||||
fontSize: "80px",
|
fontSize: "80px",
|
||||||
fontFamily: "'Cormorant Garamond', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
color: "rgba(26, 77, 46, 0.06)",
|
color: "rgba(26, 77, 46, 0.06)",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
@@ -295,7 +295,7 @@ export default function TestimonialsAndCTA() {
|
|||||||
<div key={i} style={{ textAlign: "center" }}>
|
<div key={i} style={{ textAlign: "center" }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Cormorant Garamond', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
fontSize: "2.5rem",
|
fontSize: "2.5rem",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "#1a4d2e",
|
color: "#1a4d2e",
|
||||||
@@ -326,7 +326,7 @@ export default function TestimonialsAndCTA() {
|
|||||||
background: "linear-gradient(180deg, #f5f2ed 0%, #faf8f5 50%, #faf8f5 100%)",
|
background: "linear-gradient(180deg, #f5f2ed 0%, #faf8f5 50%, #faf8f5 100%)",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
fontFamily: "'Plus Jakarta Sans', sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Decorative gradient orbs */}
|
{/* Decorative gradient orbs */}
|
||||||
@@ -422,7 +422,7 @@ export default function TestimonialsAndCTA() {
|
|||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<h2
|
<h2
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Cormorant Garamond', serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
fontSize: "clamp(2.75rem, 6vw, 4rem)",
|
fontSize: "clamp(2.75rem, 6vw, 4rem)",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
lineHeight: 1.1,
|
lineHeight: 1.1,
|
||||||
@@ -626,8 +626,6 @@ export default function TestimonialsAndCTA() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap');
|
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
@keyframes fadeInUp {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -47,13 +47,8 @@ export default function SiteHeader() {
|
|||||||
borderColor: "rgba(107, 143, 113, 0.15)",
|
borderColor: "rgba(107, 143, 113, 0.15)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Google Fonts */}
|
|
||||||
<style jsx global>{`
|
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||||
{/* Logo */}
|
{/* Logo — uses Fraunces (loaded via next/font) for a refined serif wordmark */}
|
||||||
<Link href="/" className="flex items-center gap-2.5 group">
|
<Link href="/" className="flex items-center gap-2.5 group">
|
||||||
<div
|
<div
|
||||||
className="w-9 h-9 rounded-full flex items-center justify-center transition-transform group-hover:scale-105"
|
className="w-9 h-9 rounded-full flex items-center justify-center transition-transform group-hover:scale-105"
|
||||||
@@ -66,7 +61,7 @@ export default function SiteHeader() {
|
|||||||
<span
|
<span
|
||||||
className="text-lg sm:text-xl font-semibold tracking-tight"
|
className="text-lg sm:text-xl font-semibold tracking-tight"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Cormorant Garamond', Georgia, serif",
|
fontFamily: "var(--font-fraunces)",
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -74,7 +69,7 @@ export default function SiteHeader() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation — uses Manrope (loaded via next/font) for consistent small caps */}
|
||||||
<nav className="flex items-center gap-5 sm:gap-6">
|
<nav className="flex items-center gap-5 sm:gap-6">
|
||||||
{/* Brand quick links */}
|
{/* Brand quick links */}
|
||||||
{(!isAdminRoute || isStorefrontRoute) && (
|
{(!isAdminRoute || isStorefrontRoute) && (
|
||||||
@@ -83,7 +78,7 @@ export default function SiteHeader() {
|
|||||||
href="/tuxedo"
|
href="/tuxedo"
|
||||||
className="text-xs sm:text-sm font-medium uppercase tracking-wider transition-colors hover:opacity-70"
|
className="text-xs sm:text-sm font-medium uppercase tracking-wider transition-colors hover:opacity-70"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
color: "#6b8f71",
|
color: "#6b8f71",
|
||||||
letterSpacing: "0.06em",
|
letterSpacing: "0.06em",
|
||||||
}}
|
}}
|
||||||
@@ -94,7 +89,7 @@ export default function SiteHeader() {
|
|||||||
href="/indian-river-direct"
|
href="/indian-river-direct"
|
||||||
className="text-xs sm:text-sm font-medium uppercase tracking-wider transition-colors hover:opacity-70"
|
className="text-xs sm:text-sm font-medium uppercase tracking-wider transition-colors hover:opacity-70"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
color: "#6b8f71",
|
color: "#6b8f71",
|
||||||
letterSpacing: "0.06em",
|
letterSpacing: "0.06em",
|
||||||
}}
|
}}
|
||||||
@@ -110,7 +105,7 @@ export default function SiteHeader() {
|
|||||||
href="/admin"
|
href="/admin"
|
||||||
className="text-xs sm:text-sm font-medium uppercase tracking-wider transition-colors hover:opacity-70"
|
className="text-xs sm:text-sm font-medium uppercase tracking-wider transition-colors hover:opacity-70"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
fontFamily: "var(--font-manrope)",
|
||||||
color: "#6b8f71",
|
color: "#6b8f71",
|
||||||
letterSpacing: "0.06em",
|
letterSpacing: "0.06em",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
<h3 className="text-2xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||||
You're on the list!
|
You're on the list!
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[#6b8f71]">
|
<p className="text-[#6b8f71]">
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export default function TuxedoVideoHero({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined" || !sectionRef.current) return;
|
if (typeof window === "undefined" || !sectionRef.current) return;
|
||||||
|
|
||||||
|
// Respect reduced motion preference
|
||||||
|
const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
||||||
|
if (prefersReducedMotion) return;
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
// Scroll progress tracking
|
// Scroll progress tracking
|
||||||
ScrollTrigger.create({
|
ScrollTrigger.create({
|
||||||
@@ -466,7 +470,6 @@ export default function TuxedoVideoHero({
|
|||||||
animation: bounce 1.5s ease-in-out infinite;
|
animation: bounce 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Performance optimizations */
|
|
||||||
.parallax-float {
|
.parallax-float {
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
@@ -475,11 +478,21 @@ export default function TuxedoVideoHero({
|
|||||||
will-change: opacity, transform;
|
will-change: opacity, transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth transitions */
|
|
||||||
button, a {
|
button, a {
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.parallax-float, .animate-bounce, .hero-reveal {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
.hero-reveal {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ViewTransition } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtle loading placeholder used in place of skeleton `loading.tsx`
|
||||||
|
* files. Instead of a flashy "skeleton of the page you're waiting for",
|
||||||
|
* this renders a single thin animated bar that crossfades into the
|
||||||
|
* real content via the View Transitions API. The previous page stays
|
||||||
|
* visible underneath until the new page is ready, so the user never
|
||||||
|
* feels the gap.
|
||||||
|
*
|
||||||
|
* Drop this in a `loading.tsx` file:
|
||||||
|
*
|
||||||
|
* // app/admin/loading.tsx
|
||||||
|
* import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||||
|
* export default LoadingFade;
|
||||||
|
*/
|
||||||
|
export function LoadingFade() {
|
||||||
|
return (
|
||||||
|
<ViewTransition name="page-content" update="default">
|
||||||
|
<div
|
||||||
|
className="fixed top-0 left-0 right-0 z-50 h-0.5 overflow-hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full w-1/3 rounded-full"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, transparent 0%, #14532D 50%, transparent 100%)",
|
||||||
|
animation: "transition-shimmer 1.4s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ViewTransition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoadingFade;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Announce route changes to assistive tech. Without this, screen-reader
|
||||||
|
* users see no signal that navigation completed because the view
|
||||||
|
* transition is intentionally seamless for sighted users.
|
||||||
|
*
|
||||||
|
* The announcer also restores the keyboard focus to the top of the new
|
||||||
|
* page after navigation, so tab order picks up where the user expects
|
||||||
|
* (not stuck on the link they just clicked).
|
||||||
|
*/
|
||||||
|
export function RouteAnnouncer() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset focus to the page wrapper so keyboard nav continues from
|
||||||
|
// the top, not from the link in the sidebar.
|
||||||
|
const target = document.getElementById("page-content") ?? document.body;
|
||||||
|
if (target && "tabIndex" in target) {
|
||||||
|
(target as HTMLElement).tabIndex = -1;
|
||||||
|
(target as HTMLElement).focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
className="sr-only"
|
||||||
|
>
|
||||||
|
{/* Text is intentionally empty — the live region is the
|
||||||
|
announcement channel; the actual title is set per page via
|
||||||
|
the <h1> rendered inside the layout. */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ViewTransition } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smooth view-transition wrapper for any page-section content.
|
||||||
|
*
|
||||||
|
* Drop this around the main content area of a route (or around an
|
||||||
|
* individual <Suspense> chunk) to opt that subtree into a soft
|
||||||
|
* crossfade on navigation. The browser's native View Transitions API
|
||||||
|
* handles the actual animation — when the browser doesn't support it,
|
||||||
|
* the children just swap, no error.
|
||||||
|
*
|
||||||
|
* `name` groups elements so the browser can morph the same element
|
||||||
|
* across pages. Use the same name on the source and destination (e.g.
|
||||||
|
* the page header) for shared-element morphing. Use the default
|
||||||
|
* `"page-content"` for plain crossfades.
|
||||||
|
*
|
||||||
|
* The default `update="default"` plus a CSS `::view-transition-old/new`
|
||||||
|
* rule (see globals.css) gives us a 220ms crossfade with a tiny
|
||||||
|
* downward shift on the incoming page — fast enough to feel like a
|
||||||
|
* single app, slow enough to soften the cut between routes.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <main>
|
||||||
|
* <SmoothViewTransition>{children}</SmoothViewTransition>
|
||||||
|
* </main>
|
||||||
|
*/
|
||||||
|
export function SmoothViewTransition({
|
||||||
|
children,
|
||||||
|
name = "page-content",
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
name?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ViewTransition
|
||||||
|
name={name}
|
||||||
|
// `default` runs a CSS-driven crossfade using the
|
||||||
|
// ::view-transition-* pseudo-elements defined in globals.css.
|
||||||
|
// Other options: "none" (instant cut), or a custom string your
|
||||||
|
// CSS can match.
|
||||||
|
update="default"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ViewTransition>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,31 +41,25 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
|||||||
let sessionEmail: string | null = null;
|
let sessionEmail: string | null = null;
|
||||||
try {
|
try {
|
||||||
const { data: session } = await getSession();
|
const { data: session } = await getSession();
|
||||||
console.log("[admin-permissions] Full session:", JSON.stringify(session));
|
|
||||||
sessionEmail = session?.user?.email ?? null;
|
sessionEmail = session?.user?.email ?? null;
|
||||||
console.log("[admin-permissions] Session email:", sessionEmail);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[admin-permissions] getSession() failed:", err);
|
console.error("[admin-permissions] getSession() failed:", err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionEmail) {
|
if (!sessionEmail) {
|
||||||
console.log("[admin-permissions] No session email - returning null");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await withPlatformAdmin(async (db) => {
|
return await withPlatformAdmin(async (db) => {
|
||||||
console.log("[admin-permissions] Looking for user with email:", sessionEmail.toLowerCase());
|
|
||||||
const userRows = await db
|
const userRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(adminUsers)
|
.from(adminUsers)
|
||||||
.where(eq(adminUsers.email, sessionEmail.toLowerCase()))
|
.where(eq(adminUsers.email, sessionEmail.toLowerCase()))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
console.log("[admin-permissions] User rows found:", userRows.length, userRows);
|
|
||||||
const user = userRows[0];
|
const user = userRows[0];
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log("[admin-permissions] User not found in admin_users");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +77,10 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
|||||||
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
|
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
|
||||||
.where(eq(adminUserBrands.adminUserId, user.id));
|
.where(eq(adminUserBrands.adminUserId, user.id));
|
||||||
|
|
||||||
console.log("[admin-permissions] Membership rows found:", membershipRows.length, membershipRows);
|
|
||||||
|
|
||||||
// Brand-scoped roles (brand_admin, store_employee) require at least one brand link.
|
// Brand-scoped roles (brand_admin, store_employee) require at least one brand link.
|
||||||
// Platform admins may have zero or more explicit links; they get cross-brand access via role.
|
// Platform admins may have zero or more explicit links; they get cross-brand access via role.
|
||||||
if (membershipRows.length === 0 && role !== "platform_admin") {
|
if (membershipRows.length === 0 && role !== "platform_admin") {
|
||||||
// Signed in but not provisioned for any brand.
|
// Signed in but not provisioned for any brand.
|
||||||
console.log("[admin-permissions] No brand memberships for non-platform user — denying");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user