33 Commits

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

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

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

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

npx tsc --noEmit clean.
2026-06-17 00:15:44 -06:00
Tyler 4ded68cec2 feat(admin): grouped sidebar IA 2026-06-17 00:13:28 -06:00
Tyler 750efdd318 feat(admin): add Cmd+K command palette component
Adds a self-contained <CommandPalette /> client component plus a
static data file that lists every admin page (per the new IA in
docs/superpowers/specs/2026-06-17-admin-redesign.md §4) and a
small set of quick actions.

Behavior:
- Toggles on Cmd+K (mac) / Ctrl+K (win/linux) at document level.
- Escape or backdrop click closes; Enter or click navigates.
- Fuzzy case-insensitive match on label / category / keywords,
  top 8 results, highlighted match substring.
- ↑/↓ keyboard nav, mouse hover updates selection, auto-focus
  on input when opened, body scroll lock while open.
- Visual: uses existing --admin-card-bg / --admin-border /
  --admin-primary-soft / --admin-radius-lg / --admin-shadow-lg
  tokens; fade-in 180ms, scale 0.98→1 animation.
- Returns null when closed (no DOM noise).

Out of scope for this pass:
- Component is NOT mounted in the admin layout (main thread wires
  it in). The data file is the source of truth; the palette does
  not import from AdminSidebar.
- localStorage-based Recent items are left as a TODO comment
  in the component; v1 ships without persistence.

No new dependencies; uses lucide-react (already in package.json).
npx tsc --noEmit is clean.
2026-06-17 00:13:23 -06:00
Tyler 18fb44ed38 style(admin): unify color tokens (deep botanical + amber)
- --admin-bg: warm cream (#FAF7F0)
- --admin-text-primary: ink black (#1A1814)
- --admin-text-muted: warm grey (#8A867E) — was too dim
- --admin-border: soft beige (#E8E4D7) — was too dirty
- --admin-primary: deep botanical (#1F4D2A)
- --admin-accent: warm amber (#B8761E) — the 'one thing to do'
- --admin-warning: amber (was #aba278 — looked like dirt)
- --admin-danger: warmer rust (#A8321C)
- sidebar: deeper, more refined (#2A2520)
- drop purple/blue/citrus stat icons in DashboardClient; use semantic tokens
- use --admin-warning instead of hardcoded #f59e0b in usage bar
2026-06-17 00:08:46 -06:00
Tyler bd2dadd9ee chore(design): admin redesign spec + task checklist
- design spec: aesthetic, color tokens, IA, phased plan
- checklist: 7 phases, every task has notes column, revert cheatsheet
- working branch: design/ui-revamp-2026-06
2026-06-17 00:07:04 -06:00
Tyler c8fa2e8b52 polish: consolidate typography to next/font variables, add atelier utility classes
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
- Remove Google Fonts @import from 4 components (SiteHeader, LandingPageWrapper,
  TestimonialsAndCTA, FeaturesAndStats) — eliminate render-blocking external
  request and font flash
- Replace all unloaded Cormorant Garamond / Playfair Display / DM Sans /
  Plus Jakarta Sans references with the existing next/font variables
  (Fraunces, Manrope, Fragment_Mono) — visual coherence with the design system
- Update 9 pages (blog, brands, changelog, maintenance, protected-example,
  roadmap, security, waitlist, WaitlistForm) to use the loaded Fraunces
- Fix dead --font-geist / --font-jetbrains-mono references in
  admin-design-system.css (now point to --font-manrope / --font-fragment-mono
  which are actually loaded)
- Add atelier-pill, atelier-numerals, atelier-fineprint, atelier-canvas-soft
  utility classes; .atelier-input:focus-visible refined ring
- Add .ha-field-textarea, .ha-scroll, .ha-skeleton to admin design system
- Extend prefers-reduced-motion guard to atelier-enter/stagger/shimmer
- Apply atelier-fineprint to login/not-found/error pages for consistency
- No structural changes; build passes, 0 type errors
2026-06-16 23:50:16 -06:00
Tyler 4ebbc6dacf feat: smooth view transitions, no skeleton flash
Deploy to route.crispygoat.com / deploy (push) Successful in 4m21s
User pain point: skeleton loading.tsx files made the app feel like
a sequence of page reloads, exposing backend latency. Replaced with
a single 1px shimmer bar + crossfade via React's <ViewTransition>.

Changes:
- Enable experimental.viewTransition in next.config.ts
- Add SmoothViewTransition wrapper (ViewTransition name=page-content)
- Add LoadingFade component: thin animated bar instead of skeleton
- Add RouteAnnouncer for a11y (screen readers + focus reset)
- Add ::view-transition-old/new CSS for the crossfade (220ms, no
  jarring slide, respects prefers-reduced-motion)
- Wrap admin/tuxedo/IRD layout children in SmoothViewTransition
  (sidebar/header/footer stay mounted; only the body fades)
- Replace 19 skeleton loading.tsx files with the fade component

Result: navigation now feels like a single app, not a series of
preload-and-render events. The user never sees a 'skeleton of the
page they're about to load.'
2026-06-16 23:37:00 -06:00
Tyler 9458fd0506 docs: add nested layout skeleton map
Deploy to route.crispygoat.com / deploy (push) Successful in 4m20s
Visualizes the current layout nesting in the App Router, identifies
gaps where shared sub-layouts could be added (cart/checkout, wholesale,
water, admin/communications, admin/settings), and shows how to add
a new nested layout or parallel route for modal-style navigation.
2026-06-16 23:29:16 -06:00
Tyler 83ad6536a3 feat: production-readiness pass
Deploy to route.crispygoat.com / deploy (push) Successful in 5m46s
- Migrate login page to atelier design system (editorial modal style)
- Polish root error.tsx, not-found.tsx, loading.tsx with atelier design
- Add JSON-LD structured data: SoftwareApplication + LocalBusiness
- Fix next.config.ts outputFileTracingRoot absolute path warning
- Add force-dynamic to protected-example (uses cookies)
- Add proper type for stops page (replace : any)
- Fix unescaped quotes in StopTableClient
- Fix no-explicit-any in db-schema route
- Clean up console.logs in admin-permissions
- Fix inline any in admin/stops page
- Update OG image references to existing og-default.svg
2026-06-16 23:11:35 -06:00
Tyler 244551ce70 feat(stops): align with products page design
Deploy to route.crispygoat.com / deploy (push) Successful in 4m4s
- Use StopTableClient in server page component
- Add view mode toggle (table/card) matching products page
- Match stats cards styling to products page
- Add consistent filter bar layout with AdminViewModeTabs
- Add card view for stops with inline delete confirm
- Improve row actions with Edit button + dropdown menu
- Add 'Today' badge and past stop dimming in card view
2026-06-10 14:59:42 -06:00
Tyler b1d4174721 feat(stops): add edit modal, sorting, pagination, and stats bar
Deploy to route.crispygoat.com / deploy (push) Successful in 4m3s
- Add EditStopModal for unified add/edit stop workflow
- Enhance StopTableClient with sortable columns (date, city, location, status)
- Add stats bar showing total, active, upcoming, and draft counts
- Improve pagination with 'Showing X-Y of Z' indicator
- Add 'Today' badge for current-day stops
- Dim past stops visually
- Add Edit action to row menu with icons
- Reduce page size to 25 for better UX
2026-06-10 14:45:41 -06:00
Tyler 001840ab05 fix: add brand picker to sidebar, use getActiveBrandId in stops page
Deploy to route.crispygoat.com / deploy (push) Successful in 4m11s
- AdminSidebar now renders BrandSelector when brands are available
- Stops page uses getActiveBrandId() instead of adminUser.brand_id
  directly, so platform admins see all stops and brand selection
  via the sidebar picker works correctly
- BrandSelector already existed and was wired up in the layout,
  just never rendered in the sidebar
2026-06-10 14:32:50 -06:00
Tyler f0a703794a diagnostic: simplify stops page to bare query + debug info
Deploy to route.crispygoat.com / deploy (push) Successful in 4m3s
2026-06-10 14:22:44 -06:00
Tyler 7d1e6f784b fix: deploy writes .env (not .env.production) so PM2 loads secrets
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s
2026-06-10 14:06:02 -06:00
Tyler b5f7252ac0 fix: use correct column names for stops (cutoff_date not cutoff_time) and replace mock Supabase with real pool queries in stops detail/new pages
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
2026-06-10 13:48:30 -06:00
Tyler 24cf9a7261 fix: remove non-existent deleted_at column from stops query
Deploy to route.crispygoat.com / deploy (push) Successful in 3m59s
2026-06-10 13:37:39 -06:00
Tyler 8428f3a490 Add pagination to admin stops page (50/page)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m37s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:29:44 -06:00
Tyler 77fb8fe7ee Fix stops query: use status column not deleted_at
Deploy to route.crispygoat.com / deploy (push) Successful in 3m33s
stops table has no deleted_at column — soft delete is done via status='active'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:22:50 -06:00
Tyler eabc709076 Fix admin stops page: replace mock supabase with direct pool query
Deploy to route.crispygoat.com / deploy (push) Successful in 4m2s
The page was importing from @/lib/supabase which is a mock client that
returns empty results. Replaced with pool.query() against the real
Postgres stops table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:17:39 -06:00
Tyler 4bd2ed0db2 Remove Tuxedo Corn tour seed step from deploy workflow
The seed step on every deploy was inserting duplicate stops each run.
The cleanup script (scripts/cleanup-duplicate-stops.ts) can be used to
dedupe existing rows when needed — run manually via:
  DATABASE_URL="..." npx tsx scripts/cleanup-duplicate-stops.ts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:17:34 -06:00
Tyler c1396096ad Use tsx import script instead of psql for tour seed in deploy
Deploy to route.crispygoat.com / deploy (push) Successful in 3m47s
psql with Neon pooler times out on large batch RPCs. The tsx script
replaces -pooler. with direct compute endpoint and uses pg Pool with
proper batching + progress output.
2026-06-10 12:53:39 -06:00
Tyler a53516bfe6 Fix TypeScript return type in import-tuxedo-stops.ts
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
Add missing 'locations' property to loadStops() return type.
2026-06-10 12:49:16 -06:00
Tyler fb23c21ad9 Add Tuxedo Corn 2026 tour seed to deploy workflow
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
Run db/seeds/2026-tuxedo-tour-stops.sql via psql after migrations in
the deploy step. Uses admin_create_locations_batch + admin_create_stops_batch
RPCs (migration 0003) to insert 40 locations + 269 stops for the Tuxedo Corn
2026 tour. Wrapped in BEGIN/COMMIT with DELETE-first semantics — idempotent.
2026-06-10 12:47:51 -06:00
Tyler 0cf2ee7948 Admin dashboard: fix stats waterfall, redesign layout, add animations
Deploy to route.crispygoat.com / deploy (push) Failing after 3m13s
- Fetch dashboard stats server-side in admin/page.tsx to eliminate
  client-side useEffect waterfall and the '—' placeholder flash
- Pass pre-fetched stats as props to DashboardClient
- Lazy-load UpgradePlanModal via next/dynamic (only loads when needed)
- Redesign stats cards with compact layout, smaller icons, Fraunces serif values
- Improve Quick Actions + Usage row: side-by-side on desktop, stacked mobile
- Clean up Recent Orders as a proper list with icon/name/time/amount/badge
- Add staggered fade-up entrance animations (0/60/120/180/240ms delays)
- Consolidate loading.tsx skeleton to match actual dashboard structure
- Mobile-responsive: 2-col stats on mobile, stacked usage, collapsible header
2026-06-10 12:45:08 -06:00
Tyler 6c1c616c1c frontend polish: add reduced-motion support, dynamic copyright, missing animation keyframes
Deploy to route.crispygoat.com / deploy (push) Successful in 4m1s
- LandingPageWrapper: Use dynamic year for copyright
- HeroSection: Add float-slow/float-slow-delayed keyframes, add prefers-reduced-motion media query
- TuxedoVideoHero: Add reduced-motion JS guard and CSS media query for accessibility
2026-06-10 11:20:25 -06:00
Tyler 4909a78aca stops import: swap OpenAI for MiniMax as default AI provider
Deploy to route.crispygoat.com / deploy (push) Successful in 5m27s
2026-06-10 10:58:37 -06:00
102 changed files with 7640 additions and 3450 deletions
+1 -1
View File
@@ -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/
+1
View File
@@ -47,3 +47,4 @@ playwright-report/
.mcp.json .mcp.json
.env* .env*
public/videos/tuxedo-hero.mp4 public/videos/tuxedo-hero.mp4
.neon
+71
View File
@@ -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
+255
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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",
+78
View File
@@ -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);
});
+448
View File
@@ -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);
});
+192
View File
@@ -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);
});
+1 -4
View File
@@ -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):
+3 -2
View File
@@ -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 />;
} }
+29 -2
View File
@@ -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>
); );
+11 -44
View File
@@ -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>
);
} }
+235 -61
View File
@@ -4,6 +4,8 @@ import OrderEditForm from "@/components/admin/OrderEditForm";
import OrderPaymentSection from "@/components/admin/OrderPaymentSection"; import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
import OrderPickupAction from "@/components/admin/OrderPickupAction"; import OrderPickupAction from "@/components/admin/OrderPickupAction";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import AdminBadge from "@/components/admin/design-system/AdminBadge";
import { PageHeader } from "@/components/admin/design-system";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
@@ -34,16 +36,31 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
if (!order) { if (!order) {
return ( return (
<main className="min-h-screen px-6 py-10"> <main
className="min-h-screen px-6 py-10"
style={{ backgroundColor: "var(--admin-bg)" }}
>
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<Link <Link
href="/admin/orders" href="/admin/orders"
className="inline-flex items-center gap-2 text-sm text-stone-500 hover:text-stone-700" className="inline-flex items-center gap-2 text-sm transition-colors"
style={{ color: "var(--admin-text-muted)" }}
> >
Back to Orders Back to Orders
</Link> </Link>
<div className="mt-8 rounded-2xl border border-red-200 bg-red-50 p-8 text-center"> <div
<p className="text-lg font-semibold text-red-700">Order not found</p> className="mt-8 rounded-2xl border p-8 text-center"
style={{
borderColor: "var(--admin-danger-soft)",
backgroundColor: "var(--admin-danger-soft)",
}}
>
<p
className="text-lg font-semibold"
style={{ color: "var(--admin-danger)" }}
>
Order not found
</p>
</div> </div>
</div> </div>
</main> </main>
@@ -67,46 +84,66 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
const total = subtotal + taxAmount - discount_amount; const total = subtotal + taxAmount - discount_amount;
return ( return (
<main className="min-h-screen px-6 py-8"> <main
className="min-h-screen px-6 py-8"
style={{ backgroundColor: "var(--admin-bg)" }}
>
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
{/* Header */} {/* Back link */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link <Link
href="/admin/orders" href="/admin/orders"
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 bg-white text-stone-500 hover:bg-stone-50 transition-colors" className="inline-flex items-center gap-2 text-xs font-semibold transition-colors"
style={{ color: "var(--admin-text-muted)" }}
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg> </svg>
Back to Orders
</Link> </Link>
{/* Header */}
<div> <div>
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Order Details</h1> <p className="ha-eyebrow mb-2">
<p className="text-sm text-stone-500">{formatDate(order.created_at)}</p> Order #{order.id.slice(0, 8).toUpperCase()} · {formatDate(order.created_at)}
</div> </p>
</div> <PageHeader
<div className="flex items-center gap-3"> title={order.customer_name}
<span className={`rounded-full px-3 py-1 text-xs font-bold ${ subtitle={`${formatCurrency(total)} · ${order.pickup_complete ? "Picked Up" : "Awaiting Pickup"}${order.stops ? ` · ${order.stops.city}, ${order.stops.state}` : ""}`}
order.pickup_complete actions={
? "bg-green-50 text-green-700 border border-green-200" <div className="flex items-center gap-2">
: "bg-amber-50 text-amber-700 border border-amber-200" <AdminBadge tone={order.pickup_complete ? "success" : "warning"} dot>
}`}> {order.pickup_complete ? "Picked Up" : "Pending"}
{order.pickup_complete ? "✓ Picked Up" : "⏳ Pending"} </AdminBadge>
</span>
{order.payment_processor && ( {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"> <AdminBadge tone="info">{order.payment_processor}</AdminBadge>
{order.payment_processor}
</span>
)} )}
</div> </div>
}
/>
</div> </div>
{/* Customer info */} {/* Customer info */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm"> <div
className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400">Customer</p> <p
<h2 className="mt-1 text-2xl font-bold text-stone-950">{order.customer_name}</h2> className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--admin-text-muted)" }}
>
Customer
</p>
<h2
className="mt-1 text-2xl font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_name}
</h2>
</div> </div>
<OrderPickupAction <OrderPickupAction
orderId={order.id} orderId={order.id}
@@ -118,14 +155,34 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
<div className="mt-5 grid grid-cols-2 gap-4"> <div className="mt-5 grid grid-cols-2 gap-4">
{order.customer_phone && ( {order.customer_phone && (
<div> <div>
<p className="text-xs font-semibold text-stone-400">Phone</p> <p
<p className="mt-0.5 text-sm font-medium text-stone-700">{order.customer_phone}</p> className="text-xs font-semibold"
style={{ color: "var(--admin-text-muted)" }}
>
Phone
</p>
<p
className="mt-0.5 text-sm font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_phone}
</p>
</div> </div>
)} )}
{order.customer_email && ( {order.customer_email && (
<div> <div>
<p className="text-xs font-semibold text-stone-400">Email</p> <p
<p className="mt-0.5 text-sm font-medium text-stone-700">{order.customer_email}</p> className="text-xs font-semibold"
style={{ color: "var(--admin-text-muted)" }}
>
Email
</p>
<p
className="mt-0.5 text-sm font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_email}
</p>
</div> </div>
)} )}
</div> </div>
@@ -133,64 +190,133 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
{/* Stop info */} {/* Stop info */}
{order.stops && ( {order.stops && (
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm"> <div
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400">Pickup Location</p> className="rounded-2xl border p-6 shadow-sm"
<p className="mt-1 text-lg font-bold text-stone-950"> style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<p
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--admin-text-muted)" }}
>
Pickup Location
</p>
<p
className="mt-1 text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{order.stops.city}, {order.stops.state} {order.stops.city}, {order.stops.state}
</p> </p>
<p className="mt-0.5 text-sm text-stone-500">{formatDate(order.stops.date)}</p> <p
className="mt-0.5 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{formatDate(order.stops.date)}
</p>
</div> </div>
)} )}
{/* Order items */} {/* Order items */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm"> <div
<h3 className="text-lg font-bold text-stone-950">Order Items</h3> className="rounded-2xl border p-6 shadow-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<h3
className="text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Order Items
</h3>
{order.order_items && order.order_items.length > 0 ? ( {order.order_items && order.order_items.length > 0 ? (
<div className="mt-4 divide-y divide-stone-100"> <div
className="mt-4 divide-y"
style={{ borderColor: "var(--admin-border-light)" }}
>
{order.order_items.map((item: OrderItem) => ( {order.order_items.map((item: OrderItem) => (
<div <div
key={item.id} key={item.id}
className="flex items-center justify-between py-3" className="flex items-center justify-between py-3"
> >
<div> <div>
<p className="font-medium text-stone-900"> <p
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{item.products?.name ?? "Unknown Product"} {item.products?.name ?? "Unknown Product"}
</p> </p>
<p className="text-xs text-stone-400">Qty: {item.quantity} × {formatCurrency(Number(item.price))}</p> <p
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Qty: {item.quantity} × {formatCurrency(Number(item.price))}
</p>
</div> </div>
<p className="font-semibold text-stone-900"> <p
className="font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(Number(item.price) * item.quantity)} {formatCurrency(Number(item.price) * item.quantity)}
</p> </p>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="mt-4 text-sm text-stone-400">No items found</p> <p
className="mt-4 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
No items found
</p>
)} )}
{/* Totals */} {/* Totals */}
<div className="mt-6 border-t border-stone-100 pt-6 space-y-2"> <div
className="mt-6 border-t pt-6 space-y-2"
style={{ borderColor: "var(--admin-border-light)" }}
>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-stone-500">Subtotal</span> <span style={{ color: "var(--admin-text-muted)" }}>Subtotal</span>
<span className="text-stone-900">{formatCurrency(subtotal)}</span> <span style={{ color: "var(--admin-text-primary)" }}>
{formatCurrency(subtotal)}
</span>
</div> </div>
{taxAmount > 0 && ( {taxAmount > 0 && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-stone-500">Tax</span> <span style={{ color: "var(--admin-text-muted)" }}>Tax</span>
<span className="text-stone-900">{formatCurrency(taxAmount)}</span> <span style={{ color: "var(--admin-text-primary)" }}>
{formatCurrency(taxAmount)}
</span>
</div> </div>
)} )}
{discount_amount > 0 && ( {discount_amount > 0 && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-stone-500">Discount</span> <span style={{ color: "var(--admin-text-muted)" }}>Discount</span>
<span className="text-red-600">-{formatCurrency(discount_amount)}</span> <span style={{ color: "var(--admin-danger)" }}>
-{formatCurrency(discount_amount)}
</span>
{order.discount_reason && ( {order.discount_reason && (
<span className="text-xs text-stone-400 ml-2">({order.discount_reason})</span> <span
className="text-xs ml-2"
style={{ color: "var(--admin-text-muted)" }}
>
({order.discount_reason})
</span>
)} )}
</div> </div>
)} )}
<div className="flex justify-between text-lg font-bold text-stone-950 pt-2 border-t border-stone-200"> <div
className="flex justify-between text-lg font-bold pt-2 border-t"
style={{
color: "var(--admin-text-primary)",
borderColor: "var(--admin-border)",
}}
>
<span>Total</span> <span>Total</span>
<span>{formatCurrency(total)}</span> <span>{formatCurrency(total)}</span>
</div> </div>
@@ -198,9 +324,25 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
</div> </div>
{/* Payment & Refunds */} {/* Payment & Refunds */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm"> <div
<h3 className="text-lg font-bold text-stone-950">Payment & Refunds</h3> className="rounded-2xl border p-6 shadow-sm"
<p className="mt-1 text-sm text-stone-500">Record payment details and manage refunds</p> style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<h3
className="text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Payment & Refunds
</h3>
<p
className="mt-1 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
Record payment details and manage refunds
</p>
<div className="mt-5"> <div className="mt-5">
<OrderPaymentSection <OrderPaymentSection
@@ -218,9 +360,25 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
</div> </div>
{/* Edit form */} {/* Edit form */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm"> <div
<h3 className="text-lg font-bold text-stone-950">Edit Order</h3> className="rounded-2xl border p-6 shadow-sm"
<p className="mt-1 text-sm text-stone-500">Update customer details, pricing, and status</p> style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<h3
className="text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Edit Order
</h3>
<p
className="mt-1 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
Update customer details, pricing, and status
</p>
<div className="mt-5"> <div className="mt-5">
<OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} /> <OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} />
@@ -229,9 +387,25 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
{/* Internal notes */} {/* Internal notes */}
{order.internal_notes && ( {order.internal_notes && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-6"> <div
<p className="text-xs font-semibold uppercase tracking-wider text-amber-600">Internal Notes</p> className="rounded-2xl border p-6"
<p className="mt-1 text-sm text-stone-700">{order.internal_notes}</p> style={{
borderColor: "var(--admin-warning-soft)",
backgroundColor: "var(--admin-warning-soft)",
}}
>
<p
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--admin-warning)" }}
>
Internal Notes
</p>
<p
className="mt-1 text-sm"
style={{ color: "var(--admin-text-primary)" }}
>
{order.internal_notes}
</p>
</div> </div>
)} )}
</div> </div>
+10 -3
View File
@@ -65,11 +65,12 @@ export default async function AdminOrdersPage() {
} }
return ( return (
<div className="min-h-screen bg-[var(--admin-bg)]"> <div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6"> <div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader <PageHeader
title="Orders" title="Orders"
subtitle="Manage customer orders and pickup status" subtitle="View, filter, and manage customer orders."
icon={ icon={
<svg className="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg className="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/> <path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
@@ -82,7 +83,13 @@ export default async function AdminOrdersPage() {
{/* Content */} {/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6"> <div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden"> <div
className="rounded-2xl border overflow-hidden"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<AdminOrdersPanel <AdminOrdersPanel
initialOrders={brandOrders} initialOrders={brandOrders}
initialStops={brandStops} initialStops={brandStops}
+6
View File
@@ -3,6 +3,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { 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}
/> />
); );
} }
+36 -26
View File
@@ -1,6 +1,8 @@
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import ProductEditForm from "@/components/admin/ProductEditForm"; import ProductEditForm from "@/components/admin/ProductEditForm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { PageHeader, AdminBadge } from "@/components/admin/design-system";
import { Package as PackageIcon } from "lucide-react";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
@@ -51,15 +53,15 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
if (error || !product) { if (error || !product) {
return ( return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}> <main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-red-600">Product not found</h1> <h1 className="text-3xl font-bold text-[var(--admin-danger)]">Product not found</h1>
<pre className="mt-4 rounded-xl bg-white p-4 text-sm text-stone-600"> <pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
{error?.message ?? "Product not found"} {error?.message ?? "Product not found"}
</pre> </pre>
<Link <Link
href="/admin/products" href="/admin/products"
className="mt-4 inline-block text-stone-500 hover:text-stone-700" className="mt-4 inline-block text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]"
> >
Back to Products Back to Products
</Link> </Link>
@@ -69,58 +71,66 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
} }
return ( return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}> <main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<div className="ha-eyebrow mb-2">Operations</div>
<PageHeader
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
title="Edit product"
subtitle="Update product details, pricing, and availability."
actions={
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"}
</AdminBadge>
}
/>
<div className="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 className="mt-6 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)]">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500"> <p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
{product.brands?.name} {product.brands?.name}
</p> </p>
<h1 className="mt-2 text-3xl font-bold text-stone-950"> <h1 className="mt-2 text-3xl font-bold text-[var(--admin-text-primary)]">
{product.name} {product.name}
</h1> </h1>
<p className="mt-2 text-lg text-stone-600"> <p className="mt-2 text-lg text-[var(--admin-text-secondary)]">
{product.description} {product.description}
</p> </p>
</div> </div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
product.active
? "bg-emerald-100 text-emerald-600"
: "bg-stone-200 text-stone-500"
}`}
>
{product.active ? "Active" : "Inactive"}
</span>
</div> </div>
<div className="mt-6 grid grid-cols-2 gap-6"> <div className="mt-6 grid grid-cols-2 gap-6">
<div> <div>
<p className="text-sm font-medium text-stone-500">Price</p> <p className="text-sm font-medium text-[var(--admin-text-muted)]">Price</p>
<p className="mt-1 text-2xl font-bold text-stone-950"> <p
className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)} ${Number(product.price).toFixed(2)}
</p> </p>
</div> </div>
<div> <div>
<p className="text-sm font-medium text-stone-500">Type</p> <p className="text-sm font-medium text-[var(--admin-text-muted)]">Type</p>
<p className="mt-1 text-lg font-semibold text-stone-950"> <p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
{product.type} {product.type}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50"> <div className="mt-6 rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
<h2 className="text-2xl font-bold text-stone-950">Edit Product</h2> <h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Product</h2>
<p className="mt-1 text-stone-500"> <p className="mt-1 text-[var(--admin-text-muted)]">
Update product details, pricing, and availability. Update product details, pricing, and availability.
</p> </p>
+13 -14
View File
@@ -1,5 +1,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import NewProductForm from "@/components/admin/NewProductForm"; import NewProductForm from "@/components/admin/NewProductForm";
import { PageHeader } from "@/components/admin/design-system";
import { Package as PackageIcon } from "lucide-react";
import { getBrands } from "@/actions/admin/users"; import { getBrands } from "@/actions/admin/users";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
@@ -19,28 +21,25 @@ export default async function NewProductPage() {
} }
return ( return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}> <main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<div className="mb-8"> <div className="ha-eyebrow mb-2">Operations</div>
<PageHeader
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
title="New product"
subtitle="Add a product to your catalog with pricing, type, and availability."
/>
<div className="mb-6">
<Link <Link
href="/admin/products" href="/admin/products"
className="text-sm text-stone-500 hover:text-stone-700" className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
> >
Back to Products Back to Products
</Link> </Link>
</div> </div>
<div className="rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50"> <div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
<h1 className="text-3xl font-bold text-stone-950">
Create Product
</h1>
<p className="mt-2 text-stone-500">
{isPlatformAdmin
? "Add a new product to any brand you administer."
: "Add a new product to your brand's catalog."}
</p>
<NewProductForm <NewProductForm
defaultBrandId={adminUser.brand_id ?? ""} defaultBrandId={adminUser.brand_id ?? ""}
brands={brands} brands={brands}
+4 -14
View File
@@ -7,16 +7,6 @@ import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import ProductsClient from "@/components/admin/ProductsClient"; import ProductsClient from "@/components/admin/ProductsClient";
import { getBrands } from "@/actions/admin/users"; import { getBrands } from "@/actions/admin/users";
// Icon for page header
const PackageIcon = () => (
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m7.5 4.27 9 5.15"/>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
<path d="m3.3 7 8.7 5 8.7-5"/>
<path d="M12 22V12"/>
</svg>
);
// Shape ProductsClient expects (legacy columns mapped from the new schema). // Shape ProductsClient expects (legacy columns mapped from the new schema).
type ProductRow = { type ProductRow = {
id: string; id: string;
@@ -41,8 +31,8 @@ export default async function AdminProductsPage() {
return ( return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8"> <main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Access Denied</h1> <h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-danger)]">Access Denied</h1>
<p className="mt-2 text-sm text-stone-500">You do not have permission to manage products.</p> <p className="mt-2 text-sm text-[var(--admin-text-muted)]">You do not have permission to manage products.</p>
</div> </div>
</main> </main>
); );
@@ -133,8 +123,8 @@ export default async function AdminProductsPage() {
return ( return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8"> <main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Error loading products</h1> <h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-danger)]">Error loading products</h1>
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-stone-600"> <pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
{queryError} {queryError}
</pre> </pre>
</div> </div>
+147 -74
View File
@@ -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,81 +63,155 @@ 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="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">Stop not found</h1> <p className="ha-eyebrow mb-2">Operations</p>
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600"> <PageHeader
{error?.message ?? "Stop not found"} title="Stop not found"
subtitle="This stop may have been removed or you don't have access."
icon={<StopIcon />}
/>
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-5">
<pre className="rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border-light)] p-4 text-sm text-[var(--admin-text-secondary)] overflow-auto">
{id}
</pre> </pre>
<Link <Link
href="/admin/stops" href="/admin/stops"
className="mt-4 inline-block text-stone-500 hover:text-stone-700" className="ha-btn-ghost mt-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>
</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,
brand_id: stopRow.brand_id,
city: stopRow.city ?? "",
state: stopRow.state ?? "",
address: stopRow.address ?? null,
zip: stopRow.zip ?? null,
date: stopRow.date ? String(stopRow.date) : "",
time: stopRow.time ?? "",
cutoff_date: stopRow.cutoff_date ? String(stopRow.cutoff_date) : null,
cutoff_time: stopRow.cutoff_date ? String(stopRow.cutoff_date) : null,
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, id: ps.id,
product_id: ps.product_id, product_id: ps.product_id,
products: ps.products ? { products: {
name: ps.products.name, name: ps.name,
type: ps.products.type, type: ps.type,
price: ps.products.price, price: Number(ps.price),
image_url: ps.products.image_url, image_url: ps.image_url,
} : null, },
})) }));
.filter(Boolean);
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="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader
title={`${stop.city}, ${stop.state}`}
subtitle={detailSubtitle}
icon={<StopIcon />}
/>
</div>
<div className="px-4 sm:px-6 md:px-8 pb-8 sm:pb-10">
<div className="mx-auto max-w-4xl"> <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 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">
<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)]">
{stop.brands?.name} {stop.brands?.name}
</p> </p>
<h1 className="mt-2 text-4xl font-bold text-stone-950"> <h1 className="mt-2 text-4xl font-bold text-[var(--admin-text-primary)]">
{stop.city}, {stop.state} {stop.city}, {stop.state}
</h1> </h1>
<p className="mt-2 text-lg text-stone-600">{stop.location}</p> <p className="mt-2 text-lg text-[var(--admin-text-secondary)]">{stop.location}</p>
</div> </div>
<span <span
className={`shrink-0 rounded-full px-4 py-2 text-sm font-medium ${ className={`shrink-0 rounded-full px-4 py-2 text-sm font-medium ${
stop.active stop.active
? "bg-emerald-100 text-emerald-700" ? "bg-[var(--admin-success-soft)] text-[var(--admin-success)] border border-[var(--admin-success)]/30"
: "bg-stone-200 text-stone-500" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
}`} }`}
> >
{stop.active ? "Active" : "Inactive"} {stop.active ? "Active" : "Inactive"}
@@ -148,31 +220,31 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
<div className="mt-8 grid grid-cols-2 gap-6"> <div className="mt-8 grid grid-cols-2 gap-6">
<div> <div>
<p className="text-sm font-medium text-stone-500">Date</p> <p className="text-sm font-medium text-[var(--admin-text-muted)]">Date</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)]">
{stop.date} {stop.date}
</p> </p>
</div> </div>
<div> <div>
<p className="text-sm font-medium text-stone-500">Time</p> <p className="text-sm font-medium text-[var(--admin-text-muted)]">Time</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)]">
{stop.time} {stop.time}
</p> </p>
</div> </div>
{stop.address && ( {stop.address && (
<div> <div>
<p className="text-sm font-medium text-stone-500">Address</p> <p className="text-sm font-medium text-[var(--admin-text-muted)]">Address</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)]">
{stop.address} {stop.address}
{stop.zip ? `, ${stop.zip}` : ""} {stop.zip ? `, ${stop.zip}` : ""}
</p> </p>
</div> </div>
)} )}
{stop.cutoff_time && ( {stop.cutoff_date && (
<div> <div>
<p className="text-sm font-medium text-stone-500">Cutoff</p> <p className="text-sm font-medium text-[var(--admin-text-muted)]">Cutoff</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)]">
{new Date(stop.cutoff_time).toLocaleString()} {new Date(stop.cutoff_date + "T00:00:00").toLocaleDateString()}
</p> </p>
</div> </div>
)} )}
@@ -182,33 +254,33 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end">
<a <a
href={`/admin/stops/new?duplicate=${stop.id}`} 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" className="ha-btn-ghost"
> >
Duplicate Stop Duplicate Stop
</a> </a>
</div> </div>
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg"> <div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
<h2 className="text-2xl font-bold text-stone-950"> <h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">
Assigned Products Assigned Products
</h2> </h2>
<p className="mt-1 text-stone-600"> <p className="mt-1 text-[var(--admin-text-secondary)]">
Manage which products are available at this stop. Manage which products are available at this stop.
</p> </p>
<div className="mt-6"> <div className="mt-6">
<StopProductAssignment <StopProductAssignment
stopId={stop.id} stopId={stop.id}
allProducts={allProducts ?? []} allProducts={allProducts}
assignedProducts={assignedProducts} assignedProducts={assignedProducts}
callerUid={adminUser.user_id} callerUid={adminUser.user_id}
/> />
</div> </div>
</div> </div>
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg"> <div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
<h2 className="text-2xl font-bold text-stone-950">Edit Stop</h2> <h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Stop</h2>
<p className="mt-1 text-stone-600"> <p className="mt-1 text-[var(--admin-text-secondary)]">
Update stop details, location, and availability. Update stop details, location, and availability.
</p> </p>
@@ -221,22 +293,22 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
date: stop.date, date: stop.date,
time: stop.time, time: stop.time,
location: stop.location, location: stop.location,
slug: stop.slug, slug: stop.brands?.slug ?? "",
active: stop.active, active: stop.active,
brand_id: stop.brand_id, brand_id: stop.brand_id,
address: stop.address, address: stop.address,
zip: stop.zip, zip: stop.zip,
cutoff_time: stop.cutoff_time, cutoff_time: stop.cutoff_time,
}} }}
brands={brands ?? []} brands={brandRows ?? []}
/> />
</div> </div>
</div> </div>
{/* Message Customers */} {/* Message Customers */}
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg"> <div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
<h2 className="text-2xl font-bold text-stone-950">Message Customers</h2> <h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Message Customers</h2>
<p className="mt-1 text-stone-600"> <p className="mt-1 text-[var(--admin-text-secondary)]">
Send updates to customers with pending pickups at this stop. Send updates to customers with pending pickups at this stop.
</p> </p>
@@ -245,6 +317,7 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
</div> </div>
</div> </div>
</div> </div>
</div>
</main> </main>
); );
} }
+59 -33
View File
@@ -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,51 +40,69 @@ 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>
<Link <PageHeader
href="/admin/stops" title={pageTitle}
className="text-sm text-stone-500 hover:text-stone-700" subtitle={pageSubtitle}
> icon={<StopIcon />}
Back to Stops />
</Link>
</div> </div>
<div className="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">
<h1 className="text-3xl font-bold text-stone-950"> <div className="mx-auto max-w-4xl">
{duplicateFrom ? "Duplicate Stop" : "Create Stop"} <Link
</h1> href="/admin/stops"
className="ha-btn-ghost mb-4 inline-flex"
<p className="mt-2 text-stone-600"> >
{duplicateFrom <svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
? `Pre-filled from ${duplicateFrom.city}, ${duplicateFrom.state}. Edit and save to create.` <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
: "Add a new tour stop for Tuxedo Corn or Indian River Direct."} </svg>
</p> Back to Stops
</Link>
<div className="rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
<NewStopForm duplicateFrom={duplicateFrom} /> <NewStopForm duplicateFrom={duplicateFrom} />
</div> </div>
</div> </div>
</div>
</main> </main>
); );
} }
+94 -73
View File
@@ -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"> </div>
<StopsDashboardClient stops={stops ?? []} />
</div> </div>
</main>
); );
} }
+3 -2
View File
@@ -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 }
+17 -10
View File
@@ -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();
+3 -3
View File
@@ -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">
+1 -1
View File
@@ -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;
} }
+3 -73
View File
@@ -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>
);
} }
+1 -1
View File
@@ -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">
+3 -62
View File
@@ -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>
);
} }
+3 -75
View File
@@ -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>
);
} }
+63 -23
View File
@@ -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>
</svg> </h1>
</div> <p className="mt-4 text-stone-600 text-sm leading-relaxed">
<h1 className="text-3xl font-semibold text-white tracking-tight">Something went wrong</h1> We hit an unexpected snag loading this page. Our harvest crew has been notified.
<p className="mt-3 text-zinc-400 text-sm">
{error.message || "An unexpected error occurred."}
</p> </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 && ( {error.digest && (
<p className="mt-2 text-xs text-zinc-600 font-mono">Digest: {error.digest}</p> <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 className="mt-8 flex items-center justify-center gap-4"> </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 <button
type="button"
onClick={reset} 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" className="atelier-cta"
aria-label="Retry loading this page"
> >
Try again <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>
<span className="relative z-10">Try again</span>
<span className="atelier-cta-shimmer" aria-hidden="true" />
</button> </button>
<Link <Link
href="/" 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" className="atelier-discard text-center"
> >
Go home Return home
</Link> </Link>
</div> </div>
</div> </div>
</div> </main>
</div>
); );
} }
+136
View File
@@ -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;
}
+3 -37
View File
@@ -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>
);
} }
+3 -38
View File
@@ -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>
);
} }
+31 -17
View File
@@ -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),
}, },
}; };
@@ -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>
); );
} }
+5 -76
View File
@@ -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>
);
} }
+7 -42
View File
@@ -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>
);
} }
+112 -93
View File
@@ -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,154 +56,171 @@ 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">
<label htmlFor={passwordId} className="atelier-section-label">
Password Password
</label> </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"
<button
type="button"
onClick={handleSignIn}
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]"
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"} <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">
</button> <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> </div>
)}
<button
type="submit"
disabled={loading}
className="atelier-cta w-full"
>
<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>
</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">
{[
{ role: "platform_admin", label: "Platform", color: "#14532D" },
{ role: "brand_admin", label: "Brand", color: "#1F6B3E" },
{ role: "store_employee", label: "Store", color: "#6F8562" },
].map((opt) => (
<button <button
key={opt.role}
type="button" type="button"
onClick={() => handleDevLogin("platform_admin")} onClick={() => handleDevLogin(opt.role)}
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90" className="rounded-lg px-3 py-2 text-[11px] font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", fontFamily: "var(--font-manrope)",
background: "#1a4d2e", background: opt.color,
letterSpacing: "0.04em",
}} }}
> >
Platform Admin {opt.label}
</button>
<button
type="button"
onClick={() => handleDevLogin("brand_admin")}
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: "#2d6a45",
}}
>
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> </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>
+3 -56
View File
@@ -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>
);
} }
+1 -1
View File
@@ -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">
+61 -58
View File
@@ -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">
<svg className="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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" />
</svg>
</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>
</div>
<h1 className="text-6xl font-bold text-[#1a1a1a] mb-4 tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}> {/* Large editorial numeral */}
<div
className="atelier-numeral text-[10rem] sm:text-[14rem] leading-none select-none"
aria-hidden="true"
>
404 404
</div>
<h1 className="atelier-title text-3xl sm:text-4xl mt-2">
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&apos;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&apos;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">
Let us know
</a> </a>
</p> </p>
</div> </div>
</div> </main>
</div>
); );
} }
+69 -3
View File
@@ -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 />
</>
);
} }
+3 -58
View File
@@ -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>
);
} }
+6 -1
View File
@@ -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>
+2 -2
View File
@@ -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&apos;d love to hear it. Share your suggestion and vote on others.</p> <p className="text-[#666]">Have an idea? We&apos;d love to hear it. Share your suggestion and vote on others.</p>
+5 -5
View File
@@ -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">
+3 -37
View File
@@ -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>
);
} }
+3 -59
View File
@@ -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>
);
} }
+3 -38
View File
@@ -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
View File
@@ -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>
); );
} }
+6 -75
View File
@@ -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>
);
} }
+3 -67
View File
@@ -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>
);
} }
+4 -4
View File
@@ -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&apos;ll notify you when it&apos;s your turn.</p> <p className="text-[#888] mb-6">No credit card required. We&apos;ll notify you when it&apos;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">
+4 -15
View File
@@ -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>
);
} }
+1 -1
View File
@@ -145,7 +145,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
<div <div
role="alert" role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]" className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{ background: "rgba(220, 38, 38, 0.06)", border: "1px solid rgba(220, 38, 38, 0.15)" }} style={{ background: "var(--admin-danger-soft)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)" }}
> >
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}> <svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
+423 -141
View File
@@ -3,11 +3,31 @@
import { useState, useMemo, useCallback, useEffect } from "react"; import { useState, useMemo, useCallback, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import {
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
MapPin,
Package,
X,
} from "lucide-react";
import { markPickupComplete } from "@/actions/pickup"; import { markPickupComplete } from "@/actions/pickup";
import { createAdminOrder, type AdminCreateOrderItem } from "@/actions/orders/create-admin-order"; import { createAdminOrder, type AdminCreateOrderItem } from "@/actions/orders/create-admin-order";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import AdminBadge from "./design-system/AdminBadge"; import AdminBadge from "./design-system/AdminBadge";
import { AdminButton, AdminSearchInput, AdminFilterTabs, AdminIconButton, useToast, AdminInput, AdminTextInput, AdminSelect } from "./design-system"; import {
AdminButton,
AdminSearchInput,
AdminFilterTabs,
AdminIconButton,
useToast,
AdminInput,
AdminTextInput,
AdminSelect,
EmptyState,
KPIStat,
} from "./design-system";
import { Skeleton } from "./design-system"; import { Skeleton } from "./design-system";
type OrderItem = { type OrderItem = {
@@ -66,55 +86,13 @@ function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount); return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
} }
// Icons // Icon wrappers (lucide-react) — kept compact for inline use.
const Icons = { const MapPinIcon = () => <MapPin className="h-4 w-4" strokeWidth={2} />;
mapPin: (className: string) => ( const CheckIcon = () => <Check className="h-4 w-4" strokeWidth={2} />;
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> const XIcon = () => <X className="h-3 w-3" strokeWidth={2} />;
<path d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/> const ChevronDownIcon = () => <ChevronDown className="h-4 w-4" strokeWidth={2} />;
<path d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0z"/> const ChevronLeftIcon = () => <ChevronLeft className="h-4 w-4" strokeWidth={2} />;
</svg> const ChevronRightIcon = () => <ChevronRight className="h-4 w-4" strokeWidth={2} />;
),
check: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
),
x: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
),
chevronDown: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
),
chevronLeft: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6"/>
</svg>
),
chevronRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
),
package: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4 7.55 4.24"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.29 7 12 12 20.71 7"/>
<line x1="12" y1="22" x2="12" y2="12"/>
</svg>
),
selectAll: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<polyline points="9 11 12 14 22 4"/>
</svg>
),
};
export default function AdminOrdersPanel({ export default function AdminOrdersPanel({
initialOrders, initialOrders,
@@ -355,12 +333,23 @@ export default function AdminOrdersPanel({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)]"> <div
{Icons.package("h-5 w-5 text-white")} className="flex h-10 w-10 items-center justify-center rounded-xl"
style={{ backgroundColor: "var(--admin-accent)" }}
>
<Package className="h-5 w-5 text-white" strokeWidth={2} />
</div> </div>
<div> <div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Orders</h2> <h2
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]"> className="text-base sm:text-lg font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
Orders
</h2>
<p
className="text-[10px] sm:text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
{filteredOrders.length} order{filteredOrders.length !== 1 ? "s" : ""} {filteredOrders.length} order{filteredOrders.length !== 1 ? "s" : ""}
</p> </p>
</div> </div>
@@ -368,7 +357,7 @@ export default function AdminOrdersPanel({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{brandId && ( {brandId && (
<AdminBadge variant="info">Brand scoped</AdminBadge> <AdminBadge tone="info">Brand scoped</AdminBadge>
)} )}
<AdminButton <AdminButton
onClick={openNewOrderModal} onClick={openNewOrderModal}
@@ -382,18 +371,21 @@ export default function AdminOrdersPanel({
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4"> <KPIStat
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p> label="Total"
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{isLoading ? <Skeleton variant="text" className="w-16 h-8" /> : orders.length}</p> value={isLoading ? <Skeleton variant="text" className="w-16 h-8" /> : orders.length}
</div> tone="default"
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4"> />
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Pending</p> <KPIStat
<p className="text-lg sm:text-xl md:text-2xl font-bold text-amber-600 mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pendingCount}</p> label="Pending"
</div> value={isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pendingCount}
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4"> tone="warning"
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Picked Up</p> />
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-accent)] mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pickedUpCount}</p> <KPIStat
</div> label="Picked Up"
value={isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pickedUpCount}
tone="primary"
/>
</div> </div>
{/* Filters */} {/* Filters */}
@@ -423,40 +415,92 @@ export default function AdminOrdersPanel({
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowStopDropdown(!showStopDropdown)} onClick={() => setShowStopDropdown(!showStopDropdown)}
className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors ${ className="flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors"
style={
selectedStops.length > 0 selectedStops.length > 0
? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" ? {
: "border-[var(--admin-border)] bg-white text-stone-600 hover:bg-stone-50" borderColor: "var(--admin-accent)",
}`} backgroundColor: "var(--admin-accent-light)",
color: "var(--admin-accent-text)",
}
: {
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
color: "var(--admin-text-secondary)",
}
}
> >
{Icons.mapPin("h-4 w-4")} <MapPinIcon />
<span>Stops</span> <span>Stops</span>
{selectedStops.length > 0 && ( {selectedStops.length > 0 && (
<span className="rounded-full bg-[var(--admin-accent)] text-white px-1.5 py-0.5 text-xs font-semibold">{selectedStops.length}</span> <span
className="rounded-full text-white px-1.5 py-0.5 text-xs font-semibold"
style={{ backgroundColor: "var(--admin-accent)" }}
>
{selectedStops.length}
</span>
)} )}
{Icons.chevronDown("h-4 w-4")} <ChevronDownIcon />
</button> </button>
{showStopDropdown && ( {showStopDropdown && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setShowStopDropdown(false)} /> <div className="fixed inset-0 z-10" onClick={() => setShowStopDropdown(false)} />
<div className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border border-[var(--admin-border)] bg-white shadow-lg"> <div
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3"> className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border shadow-lg"
<span className="text-sm font-semibold text-stone-700">Filter by Stop</span> style={{
<button onClick={clearStops} className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] font-medium">Clear all</button> borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div
className="flex items-center justify-between border-b px-4 py-3"
style={{ borderColor: "var(--admin-border-light)" }}
>
<span
className="text-sm font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
Filter by Stop
</span>
<button
onClick={clearStops}
className="text-xs font-medium"
style={{ color: "var(--admin-accent)" }}
>
Clear all
</button>
</div> </div>
<div className="max-h-64 overflow-y-auto p-2"> <div className="max-h-64 overflow-y-auto p-2">
{stops.map((stop) => ( {stops.map((stop) => (
<label key={stop.id} className="flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-stone-50 cursor-pointer"> <label
key={stop.id}
className="flex items-center gap-3 rounded-lg px-3 py-2 cursor-pointer transition-colors"
style={{ color: "var(--admin-text-primary)" }}
>
<input <input
type="checkbox" type="checkbox"
checked={selectedStops.includes(stop.id)} checked={selectedStops.includes(stop.id)}
onChange={() => toggleStop(stop.id)} onChange={() => toggleStop(stop.id)}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]" className="h-4 w-4 rounded"
style={{
borderColor: "var(--admin-border-strong)",
accentColor: "var(--admin-accent)",
}}
/> />
<div className="flex-1"> <div className="flex-1">
<span className="text-sm font-medium text-stone-700">{stop.city}, {stop.state}</span> <span
<span className="ml-2 text-xs text-stone-400">{formatDate(stop.date)}</span> className="text-sm font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{stop.city}, {stop.state}
</span>
<span
className="ml-2 text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
{formatDate(stop.date)}
</span>
</div> </div>
</label> </label>
))} ))}
@@ -474,10 +518,23 @@ export default function AdminOrdersPanel({
const stop = stops.find((s) => s.id === stopId); const stop = stops.find((s) => s.id === stopId);
if (!stop) return null; if (!stop) return null;
return ( return (
<span key={stopId} className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)] px-3 py-1 text-xs font-medium text-[var(--admin-accent-text)]"> <span
key={stopId}
className="inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium"
style={{
backgroundColor: "var(--admin-accent-light)",
borderColor: "var(--admin-accent)",
color: "var(--admin-accent-text)",
}}
>
{stop.city}, {stop.state} {stop.city}, {stop.state}
<button onClick={() => toggleStop(stopId)} className="ml-1 hover:text-[var(--admin-accent-hover)]"> <button
{Icons.x("h-3 w-3")} onClick={() => toggleStop(stopId)}
className="ml-1"
style={{ color: "var(--admin-accent-hover)" }}
aria-label={`Remove ${stop.city}, ${stop.state}`}
>
<XIcon />
</button> </button>
</span> </span>
); );
@@ -487,14 +544,24 @@ export default function AdminOrdersPanel({
{/* Bulk actions bar */} {/* Bulk actions bar */}
{selectedOrders.size > 0 && ( {selectedOrders.size > 0 && (
<div className="mb-4 flex items-center justify-between rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-4 py-3"> <div
className="mb-4 flex items-center justify-between rounded-xl border px-4 py-3"
style={{
borderColor: "var(--admin-accent)",
backgroundColor: "var(--admin-accent-light)",
}}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-semibold text-[var(--admin-accent-text)]"> <span
className="text-sm font-semibold"
style={{ color: "var(--admin-accent-text)" }}
>
{selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} selected {selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} selected
</span> </span>
<button <button
onClick={() => setSelectedOrders(new Set())} onClick={() => setSelectedOrders(new Set())}
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]" className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
> >
Clear Clear
</button> </button>
@@ -504,7 +571,7 @@ export default function AdminOrdersPanel({
size="sm" size="sm"
onClick={handleBulkMarkPickup} onClick={handleBulkMarkPickup}
isLoading={bulkMarkingUp} isLoading={bulkMarkingUp}
icon={Icons.check("h-4 w-4")} icon={<CheckIcon />}
> >
Mark All as Picked Up Mark All as Picked Up
</AdminButton> </AdminButton>
@@ -513,7 +580,13 @@ export default function AdminOrdersPanel({
{/* Orders Table */} {/* Orders Table */}
{isLoading ? ( {isLoading ? (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6"> <div
className="rounded-xl border p-6"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div className="space-y-4"> <div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4"> <div key={i} className="flex items-center gap-4">
@@ -526,80 +599,184 @@ export default function AdminOrdersPanel({
</div> </div>
</div> </div>
) : paginatedOrders.length === 0 ? ( ) : paginatedOrders.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-[var(--admin-border)] bg-white"> <div
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4"> className="rounded-xl border"
{Icons.package("h-8 w-8 text-stone-400")} style={{
</div> borderColor: "var(--admin-border)",
<p className="text-sm font-medium text-stone-600">No orders found</p> backgroundColor: "var(--admin-card-bg)",
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p> }}
>
<EmptyState
icon={<Package className="h-9 w-9" strokeWidth={1.25} />}
title={orders.length === 0 ? "No orders yet" : "No orders match your filters"}
description={
orders.length === 0
? "Create your first order to get started."
: "Try adjusting your filters or clearing your search."
}
action={
orders.length === 0
? { label: "Create your first order", href: "/admin/orders?new=true" }
: undefined
}
/>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto rounded-xl border border-[var(--admin-border)] bg-white"> <div
className="overflow-x-auto rounded-xl border"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<table className="w-full text-sm min-w-[700px]"> <table className="w-full text-sm min-w-[700px]">
<thead className="bg-stone-50"> <thead style={{ backgroundColor: "var(--admin-bg)" }}>
<tr className="border-b border-[var(--admin-border)]"> <tr style={{ borderBottom: "1px solid var(--admin-border)" }}>
<th className="w-10 px-4 py-3"> <th className="w-10 px-4 py-3">
<input <input
type="checkbox" type="checkbox"
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0} checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
onChange={toggleSelectAll} onChange={toggleSelectAll}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer" className="h-4 w-4 rounded cursor-pointer"
style={{
borderColor: "var(--admin-border-strong)",
accentColor: "var(--admin-accent)",
}}
/> />
</th> </th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Order</th> <th
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Customer</th> className="text-left px-4 py-3 font-semibold text-xs"
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden md:table-cell">Stop</th> style={{ color: "var(--admin-text-muted)" }}
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden lg:table-cell">Date</th> >
<th className="text-center px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Items</th> Order
<th className="text-right px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Total</th> </th>
<th className="text-center px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th> <th
className="text-left px-4 py-3 font-semibold text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Customer
</th>
<th
className="text-left px-4 py-3 font-semibold text-xs hidden md:table-cell"
style={{ color: "var(--admin-text-muted)" }}
>
Stop
</th>
<th
className="text-left px-4 py-3 font-semibold text-xs hidden lg:table-cell"
style={{ color: "var(--admin-text-muted)" }}
>
Date
</th>
<th
className="text-center px-4 py-3 font-semibold text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Items
</th>
<th
className="text-right px-4 py-3 font-semibold text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Total
</th>
<th
className="text-center px-4 py-3 font-semibold text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Status
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--admin-border)]"> <tbody style={{ borderColor: "var(--admin-border)" }}>
{paginatedOrders.map((order) => ( {paginatedOrders.map((order) => (
<tr key={order.id} className="hover:bg-stone-50 transition-colors"> <tr
key={order.id}
className="transition-colors"
style={{ borderTop: "1px solid var(--admin-border-light)" }}
>
<td className="px-4 py-3"> <td className="px-4 py-3">
<input <input
type="checkbox" type="checkbox"
checked={selectedOrders.has(order.id)} checked={selectedOrders.has(order.id)}
onChange={() => toggleOrderSelection(order.id)} onChange={() => toggleOrderSelection(order.id)}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer" className="h-4 w-4 rounded cursor-pointer"
style={{
borderColor: "var(--admin-border-strong)",
accentColor: "var(--admin-accent)",
}}
/> />
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Link href={`/admin/orders/${order.id}`} className="font-mono text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]"> <Link
href={`/admin/orders/${order.id}`}
className="font-mono text-xs"
style={{ color: "var(--admin-accent)" }}
>
{shortId(order.id)} {shortId(order.id)}
</Link> </Link>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="font-medium text-[var(--admin-text-primary)]">{order.customer_name}</div> <div
{order.customer_phone && <div className="font-mono text-xs text-stone-400">{order.customer_phone}</div>} className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_name}
</div>
{order.customer_phone && (
<div
className="font-mono text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
{order.customer_phone}
</div>
)}
</td> </td>
<td className="px-4 py-3 hidden md:table-cell"> <td className="px-4 py-3 hidden md:table-cell">
{order.stops ? ( {order.stops ? (
<span className="text-sm text-[var(--admin-text-primary)]">{order.stops.city}, {order.stops.state}</span> <span
className="text-sm"
style={{ color: "var(--admin-text-primary)" }}
>
{order.stops.city}, {order.stops.state}
</span>
) : ( ) : (
<span className="text-stone-300"></span> <span style={{ color: "var(--admin-text-muted)" }}></span>
)} )}
</td> </td>
<td className="px-4 py-3 hidden lg:table-cell"> <td className="px-4 py-3 hidden lg:table-cell">
<span className="text-sm text-stone-500">{formatDate(order.created_at)}</span> <span
className="text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{formatDate(order.created_at)}
</span>
</td> </td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
<span className="font-mono text-sm text-stone-600">{order.order_items?.length ?? 0}</span> <span
className="font-mono text-sm"
style={{ color: "var(--admin-text-secondary)" }}
>
{order.order_items?.length ?? 0}
</span>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<span className="font-mono font-semibold text-[var(--admin-text-primary)]">{formatCurrency(order.subtotal)}</span> <span
className="font-mono font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(order.subtotal)}
</span>
</td> </td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{order.pickup_complete ? ( {order.pickup_complete ? (
<AdminBadge variant="success" dot>Picked Up</AdminBadge> <AdminBadge tone="success" dot>Picked Up</AdminBadge>
) : ( ) : (
<AdminBadge variant="warning" dot>Pending</AdminBadge> <AdminBadge tone="warning" dot>Pending</AdminBadge>
)} )}
{order.payment_processor === "square" && ( {order.payment_processor === "square" && (
<AdminBadge variant="info">Square</AdminBadge> <AdminBadge tone="info">Square</AdminBadge>
)} )}
{!order.pickup_complete && ( {!order.pickup_complete && (
<AdminButton <AdminButton
@@ -623,8 +800,14 @@ export default function AdminOrdersPanel({
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]"> <div
<p className="text-xs text-[var(--admin-text-muted)]"> className="flex items-center justify-between mt-4 pt-4 border-t"
style={{ borderColor: "var(--admin-border)" }}
>
<p
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, filteredOrders.length)} of {filteredOrders.length} Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, filteredOrders.length)} of {filteredOrders.length}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -636,9 +819,14 @@ export default function AdminOrdersPanel({
disabled={page === 0} disabled={page === 0}
className="!rounded-lg" className="!rounded-lg"
> >
{Icons.chevronLeft("h-4 w-4")} <ChevronLeftIcon />
</AdminIconButton> </AdminIconButton>
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span> <span
className="px-3 text-sm font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{page + 1} / {totalPages}
</span>
<AdminIconButton <AdminIconButton
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -647,7 +835,7 @@ export default function AdminOrdersPanel({
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}
className="!rounded-lg" className="!rounded-lg"
> >
{Icons.chevronRight("h-4 w-4")} <ChevronRightIcon />
</AdminIconButton> </AdminIconButton>
</div> </div>
</div> </div>
@@ -656,19 +844,54 @@ export default function AdminOrdersPanel({
{/* New Order Modal (admin manual entry) - sibling to the main panel content */} {/* New Order Modal (admin manual entry) - sibling to the main panel content */}
{showNewOrderModal && ( {showNewOrderModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ backgroundColor: "rgba(26, 24, 20, 0.4)" }}>
<div className="w-full max-w-2xl rounded-2xl border border-[var(--admin-border)] bg-white shadow-2xl"> <div
<div className="flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-4"> className="w-full max-w-2xl rounded-2xl border shadow-2xl"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div
className="flex items-center justify-between border-b px-6 py-4"
style={{ borderColor: "var(--admin-border)" }}
>
<div> <div>
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">New Order (Admin)</h3> <h3
<p className="text-xs text-[var(--admin-text-muted)]">Manual entry for testing / phone orders</p> className="text-lg font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
New Order (Admin)
</h3>
<p
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Manual entry for testing / phone orders
</p>
</div> </div>
<button onClick={closeNewOrderModal} className="text-2xl leading-none text-stone-400 hover:text-stone-600">×</button> <button
onClick={closeNewOrderModal}
className="text-2xl leading-none"
style={{ color: "var(--admin-text-muted)" }}
aria-label="Close"
>
×
</button>
</div> </div>
<div className="p-6 space-y-5 max-h-[70vh] overflow-auto"> <div className="p-6 space-y-5 max-h-[70vh] overflow-auto">
{newOrderError && ( {newOrderError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-2 text-sm text-red-700">{newOrderError}</div> <div
className="rounded-lg border px-4 py-2 text-sm"
style={{
backgroundColor: "var(--admin-danger-soft)",
borderColor: "var(--admin-danger-soft)",
color: "var(--admin-danger)",
}}
>
{newOrderError}
</div>
)} )}
{/* Customer */} {/* Customer */}
@@ -715,8 +938,18 @@ export default function AdminOrdersPanel({
{/* Items */} {/* Items */}
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="block text-xs font-semibold text-[var(--admin-text-secondary)]">Items</label> <label
<span className="text-xs text-[var(--admin-text-muted)]">Total: ${newOrderTotal.toFixed(2)}</span> className="block text-xs font-semibold"
style={{ color: "var(--admin-text-secondary)" }}
>
Items
</label>
<span
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
Total: ${newOrderTotal.toFixed(2)}
</span>
</div> </div>
{products.length > 0 ? ( {products.length > 0 ? (
@@ -733,20 +966,41 @@ export default function AdminOrdersPanel({
/> />
</div> </div>
) : ( ) : (
<p className="text-xs text-amber-600 mb-2">No products loaded for this brand. You can still enter custom items if the action supports it.</p> <p
className="text-xs mb-2"
style={{ color: "var(--admin-warning)" }}
>
No products loaded for this brand. You can still enter custom items if the action supports it.
</p>
)} )}
{newOrderItems.length === 0 && ( {newOrderItems.length === 0 && (
<p className="text-xs text-[var(--admin-text-muted)]">No items yet. Use the selector above.</p> <p
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
>
No items yet. Use the selector above.
</p>
)} )}
{newOrderItems.length > 0 && ( {newOrderItems.length > 0 && (
<div className="space-y-2 border border-[var(--admin-border)] rounded-xl p-3 bg-[var(--admin-bg)]"> <div
className="space-y-2 border rounded-xl p-3"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-bg)",
}}
>
{newOrderItems.map((item, idx) => { {newOrderItems.map((item, idx) => {
const prod = products.find((p) => p.id === item.product_id); const prod = products.find((p) => p.id === item.product_id);
return ( return (
<div key={idx} className="grid grid-cols-12 gap-2 items-center text-sm"> <div key={idx} className="grid grid-cols-12 gap-2 items-center text-sm">
<div className="col-span-5 font-medium truncate">{prod?.name ?? item.product_id}</div> <div
className="col-span-5 font-medium truncate"
style={{ color: "var(--admin-text-primary)" }}
>
{prod?.name ?? item.product_id}
</div>
<div className="col-span-2"> <div className="col-span-2">
<input <input
type="number" type="number"
@@ -754,6 +1008,11 @@ export default function AdminOrdersPanel({
value={item.quantity} value={item.quantity}
onChange={(e) => updateNewOrderItem(idx, { quantity: Math.max(1, parseInt(e.target.value) || 1) })} onChange={(e) => updateNewOrderItem(idx, { quantity: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full rounded border px-2 py-1 text-sm" className="w-full rounded border px-2 py-1 text-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
color: "var(--admin-text-primary)",
}}
/> />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
@@ -763,6 +1022,11 @@ export default function AdminOrdersPanel({
value={item.price} value={item.price}
onChange={(e) => updateNewOrderItem(idx, { price: parseFloat(e.target.value) || 0 })} onChange={(e) => updateNewOrderItem(idx, { price: parseFloat(e.target.value) || 0 })}
className="w-full rounded border px-2 py-1 text-sm" className="w-full rounded border px-2 py-1 text-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
color: "var(--admin-text-primary)",
}}
/> />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
@@ -770,13 +1034,25 @@ export default function AdminOrdersPanel({
value={item.fulfillment} value={item.fulfillment}
onChange={(e) => updateNewOrderItem(idx, { fulfillment: e.target.value as "pickup" | "ship" })} onChange={(e) => updateNewOrderItem(idx, { fulfillment: e.target.value as "pickup" | "ship" })}
className="w-full rounded border px-2 py-1 text-sm" className="w-full rounded border px-2 py-1 text-sm"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
color: "var(--admin-text-primary)",
}}
> >
<option value="pickup">pickup</option> <option value="pickup">pickup</option>
<option value="ship">ship</option> <option value="ship">ship</option>
</select> </select>
</div> </div>
<div className="col-span-1 text-right"> <div className="col-span-1 text-right">
<button type="button" onClick={() => removeNewOrderItem(idx)} className="text-red-500 text-xs hover:underline">remove</button> <button
type="button"
onClick={() => removeNewOrderItem(idx)}
className="text-xs hover:underline"
style={{ color: "var(--admin-danger)" }}
>
remove
</button>
</div> </div>
</div> </div>
); );
@@ -786,7 +1062,13 @@ export default function AdminOrdersPanel({
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-3 border-t border-[var(--admin-border)] px-6 py-4 bg-stone-50 rounded-b-2xl"> <div
className="flex items-center justify-end gap-3 border-t px-6 py-4 rounded-b-2xl"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-bg-subtle)",
}}
>
<AdminButton variant="secondary" onClick={closeNewOrderModal} disabled={newOrderSubmitting}> <AdminButton variant="secondary" onClick={closeNewOrderModal} disabled={newOrderSubmitting}>
Cancel Cancel
</AdminButton> </AdminButton>
+320 -308
View File
@@ -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"
: userRole === "brand_admin"
? "Brand Admin"
: userRole === "store_employee"
? "Store Employee"
: null; : null;
const isActive = useCallback((href: string) => { /**
* Build the filtered list of visible nav items for the current viewer.
* Honors role-gating (Command Center) and add-on gating (Water Log, Route Trace).
* If `enabledAddons` is undefined, add-on-gated items are shown (no gating).
*/
const visibleItems: NavItemDef[] = NAV_GROUPS.flatMap((group) => {
return group.items.filter((item) => {
if (item.requiresPlatformAdmin && userRole !== "platform_admin") {
return false;
}
if (item.addonKey && enabledAddons && enabledAddons[item.addonKey] === false) {
return false;
}
return true;
});
});
/**
* Groups rendered, with empty ones removed. The Communications group is
* always rendered today (Harvest Reach is always visible) — but if a
* future iteration hides Harvest Reach for some role, the entire group
* header goes away rather than rendering an empty section.
*/
const visibleGroups: NavGroup[] = NAV_GROUPS
.map((group) => ({
...group,
items: group.items.filter((item) => visibleItems.includes(item)),
}))
.filter((group) => group.items.length > 0);
const isActive = useCallback(
(href: string) => {
if (href === "/admin") return pathname === "/admin"; if (href === "/admin") return pathname === "/admin";
return pathname.startsWith(href); return pathname.startsWith(href);
}, [pathname]); },
[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 (total === 0) return;
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
const nextIndex = (index + 1) % navLinks.length; const nextIndex = (index + 1) % total;
const nextLink = document.querySelector(`[data-nav-index="${nextIndex}"]`) as HTMLAnchorElement; const nextLink = document.querySelector(
`[data-nav-index="${nextIndex}"]`,
) as HTMLAnchorElement | null;
nextLink?.focus(); nextLink?.focus();
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
const prevIndex = index === 0 ? navLinks.length - 1 : index - 1; const prevIndex = index === 0 ? total - 1 : index - 1;
const prevLink = document.querySelector(`[data-nav-index="${prevIndex}"]`) as HTMLAnchorElement; const prevLink = document.querySelector(
`[data-nav-index="${prevIndex}"]`,
) as HTMLAnchorElement | null;
prevLink?.focus(); prevLink?.focus();
} else if (e.key === "Enter" || e.key === " ") { } else if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
router.push(href); router.push(href);
if (mobileOpen) closeMobileMenu(); if (mobileOpen) closeMobileMenu();
} }
}, [router, 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,20 +365,20 @@ export default function AdminSidebar({
/> />
)} )}
{/* Sidebar panel - Elegant warm dark with smooth transitions */} {/* Sidebar panel - dark canvas, grouped nav, amber accent for active */}
<aside <aside
ref={sidebarRef} ref={sidebarRef}
id="admin-sidebar" id="admin-sidebar"
className={[ className={[
"fixed top-0 left-0 h-full w-56 z-50", "fixed top-0 left-0 h-full w-60 z-50",
"border-r flex flex-col", "border-r flex flex-col",
"transition-transform duration-300 ease-out lg:translate-x-0", "transition-transform duration-300 ease-out lg:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full", mobileOpen ? "translate-x-0" : "-translate-x-full",
isClosing ? "opacity-90" : "opacity-100" isClosing ? "opacity-90" : "opacity-100",
].join(" ")} ].join(" ")}
style={{ style={{
backgroundColor: "var(--admin-sidebar-bg)", backgroundColor: "var(--admin-sidebar-bg)",
borderColor: "rgba(208, 203, 180, 0.2)" borderColor: "rgba(208, 203, 180, 0.2)",
}} }}
role="navigation" role="navigation"
aria-label="Admin navigation" aria-label="Admin navigation"
@@ -363,11 +397,9 @@ export default function AdminSidebar({
> >
<div <div
className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm transition-transform hover:scale-105" className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm transition-transform hover:scale-105"
style={{ backgroundColor: "var(--admin-accent)" }} style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
> >
<svg className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"> <ArrowLeft className="h-5 w-5 text-white" aria-hidden="true" />
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div> </div>
<span className="text-sm font-semibold tracking-tight">Admin</span> <span className="text-sm font-semibold tracking-tight">Admin</span>
</Link> </Link>
@@ -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) {
return (
<li key={`divider-${index}`} role="separator" aria-hidden="true">
<div className="flex items-center gap-2 px-3 py-3 mt-2">
<span className="text-[10px] font-semibold uppercase tracking-widest" style={{ color: "rgba(195, 195, 193, 0.5)" }}>
{item.label}
</span>
</div>
</li>
);
}
const active = isActive(item.href!);
const icon = item.icon ? ICON_MAP[item.icon] : null;
const navIndex = NAV_ITEMS.filter(i => !i.divider).findIndex(i => i.href === item.href);
return (
<li key={item.href}>
<Link
href={item.href!}
data-nav-index={navIndex}
onClick={() => closeMobileMenu()}
onKeyDown={(e) => handleNavKeyDown(e, item.href!, navIndex)}
className={[
"group flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs font-medium transition-all duration-200",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--admin-sidebar-bg)]",
active
? "border-l-[3px]"
: "border-l-[3px] hover:border-l-[3px]",
].join(" ")}
style={active ? {
backgroundColor: "rgba(202, 117, 67, 0.15)",
color: "#dea889",
borderColor: "var(--admin-accent)",
borderLeftColor: "var(--admin-accent)"
} : {
color: "var(--admin-sidebar-text)",
borderColor: "transparent"
}}
aria-current={active ? "page" : undefined}
tabIndex={0}
> >
<span {visibleGroups.map((group) => (
className="flex-shrink-0 transition-colors duration-200" <SideNavGroup key={group.label} label={group.label}>
style={{ {group.items.map((item) => {
color: active ? "var(--admin-accent)" : "rgba(208, 203, 180, 0.6)" const idx = visibleItems.findIndex((v) => v.href === item.href);
}} return renderNavLink(item, idx);
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> </SideNavGroup>
))}
</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
in the admin layout; this is just a discoverability nudge. */}
<div <div
className="px-3 py-2.5 mb-3 rounded-xl border" 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={{ 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,7 +508,7 @@ export default function AdminSidebar({
aria-label="Sign out of admin" aria-label="Sign out of admin"
type="button" type="button"
> >
<LogoutIcon /> <LogOut className="w-4 h-4" aria-hidden="true" />
Sign out Sign out
</button> </button>
</div> </div>
-1
View File
@@ -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 }[];
+468
View File
@@ -0,0 +1,468 @@
"use client";
/**
* CommandPalette — Cmd+K / Ctrl+K global quick-launcher for the admin.
*
* Behavior:
* - Toggled with Cmd+K (mac) or Ctrl+K (win/linux) on `document`.
* - Closes on Escape, on backdrop click, or after a navigation.
* - Fuzzy case-insensitive match on `label` / `category` / `keywords`.
* - Top 8 results. Renders null when closed (no DOM noise).
* - Keyboard: ↑/↓ to move, Enter to select, type to filter.
*
* Self-contained: does not read from the sidebar, does not depend on any
* server data. The static list lives in `./command-palette-data.ts`.
*
* The component is intentionally NOT mounted in the admin layout by this
* file. The main thread / sidebar agent wires it in once it's reviewed.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
BarChart3,
Clock,
Command,
CreditCard,
Droplets,
FileText,
LayoutDashboard,
LucideIcon,
Map as MapIcon,
MapPin,
Megaphone,
Package,
Plug,
PlusCircle,
Puzzle,
Receipt,
RefreshCw,
Route,
ScrollText,
Search,
Send,
Settings,
ShoppingCart,
Sparkles,
Store,
Tag,
Truck,
Upload,
UserPlus,
Users,
Wallet,
} from "lucide-react";
import { PALETTE_ENTRIES, type PaletteEntry } from "./command-palette-data";
const MAX_RESULTS = 8;
// ---------------------------------------------------------------------------
// Icon registry
// ---------------------------------------------------------------------------
const ICON_MAP: Record<string, LucideIcon> = {
// Pages
LayoutDashboard,
Command,
ShoppingCart,
MapPin,
Package,
Truck,
Send,
Megaphone,
FileText,
Users,
ScrollText,
Store,
Upload,
Sparkles,
Clock,
Droplets,
Route,
BarChart3,
Receipt,
Settings,
Tag,
CreditCard,
Plug,
Wallet,
Puzzle,
// Quick actions
PlusCircle,
UserPlus,
RefreshCw,
// Misc / fallbacks
Search,
Map: MapIcon,
};
// ---------------------------------------------------------------------------
// Filtering / scoring
// ---------------------------------------------------------------------------
function scoreEntry(entry: PaletteEntry, query: string): number {
if (!query) return 1;
const q = query.toLowerCase();
const label = entry.label.toLowerCase();
const category = entry.category.toLowerCase();
let score = 0;
if (label === q) score += 100;
else if (label.startsWith(q)) score += 50;
else if (label.includes(q)) score += 25;
if (category.toLowerCase().includes(q)) score += 10;
if (entry.keywords?.some((k) => k.toLowerCase().includes(q))) score += 5;
// Quick actions get a small boost when no specific match (so they surface
// on the empty query list, alongside the first batch of pages).
if (!query && entry.type === "action") score = 0.5;
if (!query && entry.type === "page") score = 1 - entry.label.length * 0.001;
return score;
}
function filterEntries(query: string): PaletteEntry[] {
const scored = PALETTE_ENTRIES
.map((e) => ({ entry: e, score: scoreEntry(e, query) }))
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, MAX_RESULTS);
return scored.map((r) => r.entry);
}
// ---------------------------------------------------------------------------
// Highlight helper
// ---------------------------------------------------------------------------
function HighlightedText({ text, query }: { text: string; query: string }) {
if (!query) return <>{text}</>;
const q = query.toLowerCase();
const idx = text.toLowerCase().indexOf(q);
if (idx === -1) return <>{text}</>;
return (
<>
{text.slice(0, idx)}
<mark
style={{
background: "transparent",
color: "var(--admin-primary)",
fontWeight: 600,
padding: 0,
}}
>
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length)}
</>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
// Open/close on Cmd+K / Ctrl+K. Toggle so the same shortcut closes it.
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
// Reset on open, focus the input, and lock body scroll.
useEffect(() => {
if (!open) {
document.body.style.overflow = "";
return;
}
setQuery("");
setSelected(0);
document.body.style.overflow = "hidden";
// Defer focus to next frame so the input is mounted and the modal
// animation has started (keeps the focus ring from flickering in).
const raf = requestAnimationFrame(() => inputRef.current?.focus());
return () => {
cancelAnimationFrame(raf);
document.body.style.overflow = "";
};
}, [open]);
// Global Escape while open (covers the case where focus is not on input).
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
// Filtering
const results = useMemo(() => filterEntries(query), [query]);
// Reset selection on every query change so the top result is always active.
useEffect(() => {
setSelected(0);
}, [query]);
// Clamp selection to results length
useEffect(() => {
if (selected >= results.length && results.length > 0) {
setSelected(results.length - 1);
} else if (results.length === 0) {
setSelected(0);
}
}, [results, selected]);
const navigate = useCallback(
(href: string) => {
setOpen(false);
router.push(href);
},
[router]
);
// In-input keyboard nav. Attached to the input itself so it works whether
// the user is typing or the input just has focus.
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (results.length === 0) return;
setSelected((s) => (s + 1) % results.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (results.length === 0) return;
setSelected((s) => (s - 1 + results.length) % results.length);
} else if (e.key === "Enter") {
e.preventDefault();
const entry = results[selected];
if (entry) navigate(entry.href);
}
};
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Command palette"
onClick={() => setOpen(false)}
style={{
position: "fixed",
inset: 0,
zIndex: 60,
backgroundColor: "rgba(26, 24, 20, 0.4)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
paddingTop: "15vh",
animation: "cp-fade-in 180ms ease-out",
}}
>
{/* Local keyframes — scoped via the dialog's runtime so they don't
leak into the global stylesheet. */}
<style>{`
@keyframes cp-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes cp-scale-in {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: "36rem",
margin: "0 1rem",
backgroundColor: "var(--admin-card-bg)",
border: "1px solid var(--admin-border)",
borderRadius: "var(--admin-radius-lg)",
boxShadow: "var(--admin-shadow-lg)",
overflow: "hidden",
animation: "cp-scale-in 180ms ease-out",
display: "flex",
flexDirection: "column",
}}
>
{/* Search input row */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.875rem 1rem",
borderBottom: "1px solid var(--admin-border)",
}}
>
<Search
size={18}
style={{ color: "var(--admin-text-muted)", flexShrink: 0 }}
aria-hidden="true"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Search pages, actions, anything…"
aria-label="Search command palette"
spellCheck={false}
autoComplete="off"
style={{
flex: 1,
minWidth: 0,
border: "none",
outline: "none",
background: "transparent",
fontSize: "1rem",
lineHeight: 1.4,
color: "var(--admin-text-primary)",
fontFamily: "inherit",
}}
/>
</div>
{/* Results */}
<div
role="listbox"
aria-label="Results"
style={{
maxHeight: "60vh",
overflowY: "auto",
padding: "0.375rem",
}}
>
{/*
TODO (next pass): recent items
- Persist a small ring buffer of last-visited entries in localStorage
under "rc-cmdk-recent" (cap at 5).
- Read on mount, render as a "Recent" section above "Pages" when
present and the query is empty.
- Update on `navigate()` after a successful route push.
Skipped for v1 per the design spec.
*/}
{results.length === 0 ? (
<div
style={{
padding: "2rem 1rem",
textAlign: "center",
color: "var(--admin-text-muted)",
fontSize: "0.875rem",
}}
>
No results for &ldquo;{query}&rdquo;
</div>
) : (
results.map((entry, i) => {
const Icon = ICON_MAP[entry.iconName] ?? Search;
const isSelected = i === selected;
return (
<button
key={entry.id}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => navigate(entry.href)}
onMouseEnter={() => setSelected(i)}
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
width: "100%",
padding: "0.625rem 0.75rem",
borderRadius: "var(--admin-radius-md)",
border: "none",
background: isSelected
? "var(--admin-primary-soft)"
: "transparent",
color: isSelected
? "var(--admin-primary)"
: "var(--admin-text-primary)",
cursor: "pointer",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: 1.3,
fontFamily: "inherit",
transition:
"background-color 80ms ease-out, color 80ms ease-out",
}}
>
<Icon
size={16}
style={{
flexShrink: 0,
opacity: isSelected ? 1 : 0.7,
}}
aria-hidden="true"
/>
<span
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 500,
}}
>
<HighlightedText text={entry.label} query={query} />
</span>
<span
style={{
fontSize: "0.6875rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--admin-text-muted)",
fontWeight: 500,
flexShrink: 0,
}}
>
{entry.category}
</span>
</button>
);
})
)}
</div>
{/* Footer hint */}
<div
style={{
padding: "0.5rem 1rem 0.625rem",
borderTop: "1px solid var(--admin-border)",
fontSize: "0.6875rem",
color: "var(--admin-text-muted)",
textAlign: "center",
letterSpacing: "0.02em",
userSelect: "none",
}}
>
to navigate &nbsp;·&nbsp; to select &nbsp;·&nbsp; esc to close
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+473
View File
@@ -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 };
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { Inbox } from "lucide-react";
type EmptyStateAction = {
/** Visible label for the call-to-action. */
label: string;
/** If provided, renders a Next.js Link. Mutually exclusive with onClick. */
href?: string;
/** If provided, renders a button. */
onClick?: () => void;
};
type EmptyStateProps = {
/** Optional icon element (typically a lucide-react icon). */
icon?: ReactNode;
/** Required heading explaining the empty state. */
title: string;
/** Optional supporting copy shown below the title. */
description?: string;
/** Optional single primary action. */
action?: EmptyStateAction;
/** Extra class names appended to the root. */
className?: string;
};
/**
* EmptyState — generic empty state for admin pages.
*
* Centers an icon + title + description and (optionally) a single primary CTA
* styled with `.ha-btn-primary`. Uses the new admin design tokens for
* colors and Fraunces for the title.
*/
export default function EmptyState({
icon,
title,
description,
action,
className = "",
}: EmptyStateProps) {
return (
<div
className={`flex flex-col items-center justify-center text-center ${className}`.trim()}
style={{ padding: "3rem 1.5rem" }}
>
<div
className="mb-4"
style={{ color: "var(--admin-text-muted)" }}
aria-hidden="true"
>
{icon ?? <DefaultEmptyIcon />}
</div>
<h3
className="ha-display"
style={{
fontSize: "1.125rem",
color: "var(--admin-text-primary)",
}}
>
{title}
</h3>
{description && (
<p
className="mt-2 max-w-sm text-sm leading-relaxed"
style={{ color: "var(--admin-text-muted)" }}
>
{description}
</p>
)}
{action && (
<div className="mt-6">
<EmptyStateActionButton action={action} />
</div>
)}
</div>
);
}
function DefaultEmptyIcon() {
return <Inbox className="h-9 w-9" strokeWidth={1.25} />;
}
function EmptyStateActionButton({ action }: { action: EmptyStateAction }) {
// Pick Link vs button based on whether an href is provided. Both render
// with the same .ha-btn-primary class so the CTA is visually consistent.
if (action.href) {
return (
<Link href={action.href} className="ha-btn-primary">
{action.label}
</Link>
);
}
return (
<button
type="button"
onClick={action.onClick}
className="ha-btn-primary"
>
{action.label}
</button>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import type { ReactNode } from "react";
import { ArrowDown, ArrowRight, ArrowUp } from "lucide-react";
type KPITone = "default" | "primary" | "accent" | "warning" | "danger";
type KPIStatProps = {
/** Eyebrow label above the value (e.g. "Today's Orders") */
label: string;
/** The primary value to display. Accepts a node so callers can pass formatted strings, JSX, etc. */
value: ReactNode;
/** Optional icon element (typically a lucide-react icon) shown in the leading tile. */
icon?: ReactNode;
/** Controls the icon tile's background + icon color. Defaults to "default". */
tone?: KPITone;
/** Optional trend indicator rendered next to the value. */
trend?: { value: string; direction: "up" | "down" | "flat" };
/** Extra class names appended to the card. */
className?: string;
};
// Tone -> icon-tile background / icon color. Pulled from --admin-* tokens
// introduced in the Phase 1 palette unification. No hardcoded hex values.
const toneStyles: Record<KPITone, { bg: string; fg: string }> = {
default: { bg: "var(--admin-bg-subtle)", fg: "var(--admin-text-secondary)" },
primary: { bg: "var(--admin-primary-soft)", fg: "var(--admin-primary)" },
accent: { bg: "var(--admin-accent-soft)", fg: "var(--admin-accent)" },
warning: { bg: "var(--admin-warning-soft)", fg: "var(--admin-warning)" },
danger: { bg: "var(--admin-danger-soft)", fg: "var(--admin-danger)" },
};
// Trend direction -> icon + color. "up" reads as positive (primary),
// "down" as negative (danger), "flat" as neutral.
const trendStyles: Record<"up" | "down" | "flat", { Icon: typeof ArrowUp; color: string }> = {
up: { Icon: ArrowUp, color: "var(--admin-primary)" },
down: { Icon: ArrowDown, color: "var(--admin-danger)" },
flat: { Icon: ArrowRight, color: "var(--admin-text-muted)" },
};
/**
* KPIStat — compact metric card for admin surfaces.
*
* Extracted from the inline pattern previously duplicated four times in
* `DashboardClient.tsx`. Reuses the `.admin-stat-card*` classes from
* `admin-design-system.css` for the shell, then applies tone-aware
* background/foreground via inline `var(--admin-*)` styles.
*/
export default function KPIStat({
label,
value,
icon,
tone = "default",
trend,
className = "",
}: KPIStatProps) {
const { bg, fg } = toneStyles[tone];
return (
<div className={`admin-stat-card ${className}`.trim()}>
<div className="admin-stat-card-inner">
{icon && (
<div
className="admin-stat-icon"
style={{ backgroundColor: bg, color: fg }}
>
{icon}
</div>
)}
<div className="admin-stat-body">
<span className="admin-stat-label">{label}</span>
<div className="flex items-baseline gap-2">
<span className="admin-stat-value">{value}</span>
{trend && <TrendIndicator trend={trend} />}
</div>
</div>
</div>
</div>
);
}
function TrendIndicator({
trend,
}: {
trend: { value: string; direction: "up" | "down" | "flat" };
}) {
const { Icon, color } = trendStyles[trend.direction];
return (
<span
className="inline-flex items-center gap-0.5 text-[0.6875rem] font-semibold"
style={{ color }}
>
<Icon className="h-3 w-3" strokeWidth={2.25} aria-hidden="true" />
{trend.value}
</span>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
type LoadingStateProps = {
/** Optional eyebrow text rendered above the skeleton (e.g. "Loading orders..."). */
label?: string;
/** Number of skeleton rows to show. Defaults to 3. */
rows?: number;
/** Extra class names appended to the wrapper. */
className?: string;
};
/**
* LoadingState — generic skeleton for admin list/card surfaces.
*
* Renders an optional centered "Loading…" eyebrow above `rows` skeleton
* blocks, each composed of a label-sized bar and a value-sized bar. The
* shimmer comes from the shared `.ha-skeleton` class in
* `admin-design-system.css` so motion / reduced-motion stay consistent
* with the rest of the admin shell.
*/
export default function LoadingState({
label,
rows = 3,
className = "",
}: LoadingStateProps) {
const safeRows = Math.max(1, Math.floor(rows));
return (
<div
className={`flex flex-col items-stretch ${className}`.trim()}
style={{ padding: "1.5rem 1rem" }}
role="status"
aria-live="polite"
aria-busy="true"
>
{label && (
<span
className="ha-eyebrow mx-auto mb-4"
aria-label={label}
>
{label}
</span>
)}
<div className="space-y-3">
{Array.from({ length: safeRows }).map((_, i) => (
// Index is fine here — the list shape is stable per render.
// eslint-disable-next-line react/no-array-index-key
<SkeletonRow key={i} />
))}
</div>
</div>
);
}
function SkeletonRow() {
return (
<div
className="flex items-center gap-3 rounded-md border p-3"
style={{
backgroundColor: "var(--admin-card-bg)",
borderColor: "var(--admin-border)",
}}
>
{/* Icon-tile placeholder */}
<span
className="ha-skeleton block flex-shrink-0"
style={{ width: "2.25rem", height: "2.25rem", borderRadius: "0.5rem" }}
/>
{/* Label + value placeholders */}
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<span
className="ha-skeleton block"
style={{ width: "40%", height: "0.5rem", borderRadius: "0.25rem" }}
/>
<span
className="ha-skeleton block"
style={{ width: "65%", height: "0.75rem", borderRadius: "0.25rem" }}
/>
</div>
</div>
);
}
+17 -16
View File
@@ -132,7 +132,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
return ( return (
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6"> <form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
{error && ( {error && (
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm"> <div className="rounded-xl bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] p-4 text-[var(--admin-danger)] text-sm">
{error} {error}
</div> </div>
)} )}
@@ -182,7 +182,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
<AdminInput label="Brand" required> <AdminInput label="Brand" required>
{lockBrand ? ( {lockBrand ? (
// brand_admin / store_employee — brand is fixed by their admin_users record // brand_admin / store_employee — brand is fixed by their admin_users record
<div className="w-full rounded-lg border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm text-[var(--admin-text-primary)]"> <div className="w-full rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)]">
{brandOptions.find((b) => b.id === brandId)?.name ?? brandId} {brandOptions.find((b) => b.id === brandId)?.name ?? brandId}
</div> </div>
) : ( ) : (
@@ -197,7 +197,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
/> />
)} )}
{!lockBrand && brands.length === 0 && ( {!lockBrand && brands.length === 0 && (
<p className="mt-1 text-xs text-stone-500"> <p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Loading available brands if this persists, check the admin Brands settings. Loading available brands if this persists, check the admin Brands settings.
</p> </p>
)} )}
@@ -237,8 +237,8 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
</AdminInput> </AdminInput>
<div> <div>
<label className="block text-sm font-medium text-zinc-300">Product Image</label> <label className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</label>
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p> <p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div <div
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
@@ -250,28 +250,28 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className={` className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${uploadError ? "border-red-400" : "border-zinc-600 hover:border-slate-400 hover:bg-zinc-900"} ${uploadError ? "border-[var(--admin-danger)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
${uploading ? "opacity-50 pointer-events-none" : ""} ${uploading ? "opacity-50 pointer-events-none" : ""}
`} `}
> >
{uploading ? ( {uploading ? (
<> <>
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" /> <div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
<span className="text-sm text-zinc-500">Uploading...</span> <span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
</> </>
) : imagePreview ? ( ) : imagePreview ? (
<> <>
<span className="relative inline-block h-32 w-auto"> <span className="relative inline-block h-32 w-auto">
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" /> <NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</span> </span>
<span className="text-xs text-zinc-500">Click or drop to replace</span> <span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
</> </>
) : ( ) : (
<> <>
<svg className="h-8 w-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg> </svg>
<span className="text-sm text-zinc-500">Drag & drop or click to upload</span> <span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
</> </>
)} )}
<input <input
@@ -286,31 +286,32 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
/> />
</div> </div>
{uploadError && <p className="mt-1 text-xs text-red-400">{uploadError}</p>} {uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
{imagePreview && ( {imagePreview && (
<button <button
type="button" type="button"
onClick={() => { setImagePreview(null); setPendingImageUrl(""); }} onClick={() => { setImagePreview(null); setPendingImageUrl(""); }}
className="mt-2 text-xs text-red-500 hover:underline" className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
> >
Remove image Remove image
</button> </button>
)} )}
</div> </div>
<div className="flex gap-3"> {/* Save button bar — new design tokens */}
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white disabled:opacity-50" className="ha-btn-primary"
> >
{loading ? "Creating..." : "Create Product"} {loading ? "Creating..." : "Create Product"}
</button> </button>
<Link <Link
href="/admin/products" href="/admin/products"
className="rounded-xl border border-zinc-600 px-6 py-3 font-medium text-zinc-300" className="ha-btn-ghost"
> >
Cancel Cancel
</Link> </Link>
+117 -44
View File
@@ -198,8 +198,22 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{error && ( {error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3"> <div
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> className="rounded-xl border p-4 text-sm flex items-start gap-3"
style={{
borderColor: "var(--admin-danger-soft)",
backgroundColor: "var(--admin-danger-soft)",
color: "var(--admin-danger)",
}}
>
<svg
className="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
style={{ color: "var(--admin-danger)" }}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
{error} {error}
@@ -208,11 +222,17 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Order items */} {/* Order items */}
<div> <div>
<p className="mb-3 text-sm font-semibold text-stone-700"> <p className="ha-field-label mb-3">
Order Items ({visibleItems.length}) Order Items ({visibleItems.length})
</p> </p>
{visibleItems.length === 0 ? ( {visibleItems.length === 0 ? (
<p className="rounded-xl border border-dashed border-stone-300 p-4 text-center text-sm text-stone-400"> <p
className="rounded-xl border border-dashed p-4 text-center text-sm"
style={{
borderColor: "var(--admin-border-strong)",
color: "var(--admin-text-muted)",
}}
>
No items No items
</p> </p>
) : ( ) : (
@@ -220,13 +240,23 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{visibleItems.map((item) => ( {visibleItems.map((item) => (
<div <div
key={item.id} key={item.id}
className="rounded-xl border border-stone-200 bg-white p-4" className="rounded-xl border p-4"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<p className="font-medium text-stone-900">{item.productName}</p> <p
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{item.productName}
</p>
<button <button
onClick={() => removeItem(item.id)} onClick={() => removeItem(item.id)}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50" className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium"
style={{ color: "var(--admin-danger)" }}
> >
Remove Remove
</button> </button>
@@ -234,7 +264,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-stone-500 mb-1">Qty</label> <label className="ha-field-label mb-1">Qty</label>
<input <input
type="number" type="number"
min="1" min="1"
@@ -242,13 +272,18 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
onChange={(e) => onChange={(e) =>
updateItem(item.id, "quantity", Number(e.target.value)) updateItem(item.id, "quantity", Number(e.target.value))
} }
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20" className="ha-field-input ha-field-input-mono"
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-stone-500 mb-1">Price</label> <label className="ha-field-label mb-1">Price</label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span> <span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
$
</span>
<input <input
type="number" type="number"
step="0.01" step="0.01"
@@ -257,13 +292,16 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
onChange={(e) => onChange={(e) =>
updateItem(item.id, "price", Number(e.target.value)) updateItem(item.id, "price", Number(e.target.value))
} }
className="w-full rounded-xl border border-stone-200 bg-stone-50 pl-8 pr-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20" className="ha-field-input ha-field-input-mono pl-8 pr-3"
/> />
</div> </div>
</div> </div>
</div> </div>
<p className="mt-2 text-right text-sm font-semibold text-stone-900"> <p
className="mt-2 text-right text-sm font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(Number(item.price) * item.quantity)} {formatCurrency(Number(item.price) * item.quantity)}
</p> </p>
</div> </div>
@@ -280,48 +318,71 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
step="0.01" step="0.01"
value={subtotal} value={subtotal}
readOnly readOnly
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm text-stone-500" className="ha-field-input ha-field-input-mono"
style={{ color: "var(--admin-text-muted)" }}
/> />
</AdminInput> </AdminInput>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-semibold text-stone-500 mb-1.5">Discount Amount</label> <label className="ha-field-label mb-1.5">Discount Amount</label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span> <span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
$
</span>
<input <input
type="number" type="number"
step="0.01" step="0.01"
min="0" min="0"
value={discount_amount} value={discount_amount}
onChange={(e) => setDiscount_amount(Number(e.target.value))} onChange={(e) => setDiscount_amount(Number(e.target.value))}
className={`w-full rounded-xl border px-8 pr-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${ className="ha-field-input ha-field-input-mono pl-8 pr-3"
style={
fieldErrors.discount_amount fieldErrors.discount_amount
? "border-red-400 bg-red-50" ? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
: "border-stone-200 bg-white focus:border-[var(--admin-accent)]" : undefined
}`} }
/> />
</div> </div>
{fieldErrors.discount_amount && ( {fieldErrors.discount_amount && (
<p className="mt-1 text-xs text-red-500">{fieldErrors.discount_amount}</p> <p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
{fieldErrors.discount_amount}
</p>
)} )}
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-stone-500 mb-1.5">Discount Reason</label> <label className="ha-field-label mb-1.5">Discount Reason</label>
<input <input
type="text" type="text"
value={discount_reason} value={discount_reason}
onChange={(e) => setDiscount_reason(e.target.value)} onChange={(e) => setDiscount_reason(e.target.value)}
placeholder="Optional" placeholder="Optional"
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20" className="ha-field-input"
/> />
</div> </div>
</div> </div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4"> <div
className="rounded-xl border p-4"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-bg-subtle)",
}}
>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm font-medium text-stone-600">Total</span> <span
<span className="text-xl font-bold text-stone-900"> className="text-sm font-medium"
style={{ color: "var(--admin-text-secondary)" }}
>
Total
</span>
<span
className="text-xl font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(subtotal + Number(order.tax_amount ?? 0) - discount_amount)} {formatCurrency(subtotal + Number(order.tax_amount ?? 0) - discount_amount)}
</span> </span>
</div> </div>
@@ -331,8 +392,9 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Customer fields */} {/* Customer fields */}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-xs font-semibold text-stone-500 mb-1.5"> <label className="ha-field-label mb-1.5">
Name <span className="text-red-500">*</span> <span>Name</span>
<span className="ha-field-label-required">*</span>
</label> </label>
<input <input
type="text" type="text"
@@ -345,14 +407,17 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
return next; return next;
}); });
}} }}
className={`w-full rounded-xl border px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${ className="ha-field-input"
style={
fieldErrors.customer_name fieldErrors.customer_name
? "border-red-400 bg-red-50" ? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
: "border-stone-200 bg-white focus:border-[var(--admin-accent)]" : undefined
}`} }
/> />
{fieldErrors.customer_name && ( {fieldErrors.customer_name && (
<p className="mt-1 text-xs text-red-500">{fieldErrors.customer_name}</p> <p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
{fieldErrors.customer_name}
</p>
)} )}
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -376,17 +441,15 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Status & pickup */} {/* Status & pickup */}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="mb-2 block text-xs font-semibold text-stone-500">Status</label> <label className="ha-field-label mb-2">Status</label>
<div className="flex gap-2"> <div className="ha-segment">
{["pending", "confirmed", "cancelled"].map((s) => ( {["pending", "confirmed", "cancelled"].map((s) => (
<button <button
key={s} key={s}
type="button" type="button"
onClick={() => setStatus(s)} onClick={() => setStatus(s)}
className={`flex-1 rounded-xl px-4 py-2.5 text-sm font-semibold capitalize transition-colors ${ className={`ha-segment-btn capitalize ${
status === s status === s ? "ha-segment-btn--active" : ""
? "bg-[var(--admin-accent)] text-white"
: "bg-white text-stone-600 border border-stone-200 hover:bg-stone-50"
}`} }`}
> >
{s} {s}
@@ -396,15 +459,24 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
</div> </div>
<div> <div>
<label className="mb-2 block text-xs font-semibold text-stone-500">Pickup</label> <label className="ha-field-label mb-2">Pickup</label>
<button <button
type="button" type="button"
onClick={() => setPickup_complete((v) => !v)} onClick={() => setPickup_complete((v) => !v)}
className={`w-full rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors ${ className="ha-segment-btn w-full justify-center"
style={
pickup_complete pickup_complete
? "bg-green-100 text-green-700 border border-green-200" ? {
: "bg-amber-100 text-amber-700 border border-amber-200" backgroundColor: "var(--admin-primary-soft)",
}`} color: "var(--admin-primary)",
border: "1px solid var(--admin-primary)",
}
: {
backgroundColor: "var(--admin-warning-soft)",
color: "var(--admin-warning)",
border: "1px solid var(--admin-warning)",
}
}
> >
{pickup_complete ? "✓ Picked Up" : "○ Not Picked Up"} {pickup_complete ? "✓ Picked Up" : "○ Not Picked Up"}
</button> </button>
@@ -428,6 +500,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
variant="primary" variant="primary"
fullWidth fullWidth
size="lg" size="lg"
className="ha-btn-primary"
> >
{saving ? "Saving..." : "Save Changes"} {saving ? "Saving..." : "Save Changes"}
</AdminButton> </AdminButton>
+48 -27
View File
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import AdminBadge from "./design-system/AdminBadge";
import { toggleOrderPickupComplete } from "@/actions/orders"; import { toggleOrderPickupComplete } from "@/actions/orders";
type Order = { type Order = {
@@ -15,6 +16,16 @@ type Order = {
created_at: string; created_at: string;
}; };
type OrderStatusTone = "warning" | "info" | "success" | "danger" | "neutral";
const statusToTone: Record<string, OrderStatusTone> = {
pending: "warning",
confirmed: "info",
paid: "success",
cancelled: "danger",
completed: "neutral",
};
export default function OrderTableBody({ orders }: { orders: Order[] }) { export default function OrderTableBody({ orders }: { orders: Order[] }) {
const [pickupToggles, setPickupToggles] = useState<Record<string, boolean>>( const [pickupToggles, setPickupToggles] = useState<Record<string, boolean>>(
() => Object.fromEntries(orders.map((o) => [o.id, o.pickup_complete])) () => Object.fromEntries(orders.map((o) => [o.id, o.pickup_complete]))
@@ -33,68 +44,78 @@ export default function OrderTableBody({ orders }: { orders: Order[] }) {
} }
} }
const statusColors: Record<string, string> = {
pending: "bg-yellow-100 text-yellow-700",
confirmed: "bg-blue-900/40 text-blue-700",
paid: "bg-green-900/40 text-green-400",
cancelled: "bg-red-900/40 text-red-400",
completed: "bg-zinc-950 text-zinc-400",
};
return ( return (
<tbody className="divide-y divide-slate-200"> <tbody style={{ borderColor: "var(--admin-border)" }}>
{error && ( {error && (
<tr> <tr>
<td colSpan={6} className="px-3 py-2 text-sm text-red-400"> <td
colSpan={6}
className="px-3 py-2 text-sm"
style={{ color: "var(--admin-danger)" }}
>
{error} {error}
</td> </td>
</tr> </tr>
)} )}
{orders.map((order) => ( {orders.map((order) => (
<tr key={order.id} className="hover:bg-zinc-800"> <tr
key={order.id}
className="transition-colors"
style={{ borderTop: "1px solid var(--admin-border-light)" }}
>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className="font-mono text-sm text-zinc-500"> <span
className="font-mono text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{order.id.slice(0, 8)} {order.id.slice(0, 8)}
</span> </span>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="font-medium text-zinc-100"> <div
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{order.customer_name} {order.customer_name}
</div> </div>
<div className="text-sm text-zinc-500"> <div
className="text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
{order.customer_email} {order.customer_email}
</div> </div>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span <AdminBadge tone={statusToTone[order.status] ?? "neutral"}>
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
statusColors[order.status] ?? "bg-zinc-950 text-zinc-400"
}`}
>
{order.status ?? "pending"} {order.status ?? "pending"}
</span> </AdminBadge>
</td> </td>
<td className="px-3 py-2 font-semibold text-zinc-100"> <td
className="px-3 py-2 font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
${Number(order.subtotal).toFixed(2)} ${Number(order.subtotal).toFixed(2)}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<button <button
type="button"
onClick={() => togglePickup(order.id, pickupToggles[order.id])} onClick={() => togglePickup(order.id, pickupToggles[order.id])}
className={`rounded-full px-2 py-0.5 text-xs font-medium ${ className="transition-opacity hover:opacity-80"
pickupToggles[order.id]
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-500"
}`}
> >
<AdminBadge tone={pickupToggles[order.id] ? "success" : "neutral"} dot>
{pickupToggles[order.id] ? "Picked up" : "Pending"} {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>
+30 -20
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import NextImage from "next/image"; import NextImage from "next/image";
import Link from "next/link";
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { uploadProductImage, deleteProductImage } from "@/actions/products/upload-image"; import { uploadProductImage, deleteProductImage } from "@/actions/products/upload-image";
@@ -147,13 +148,13 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{error && ( {error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400"> <div className="rounded-xl bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] p-4 text-sm text-[var(--admin-danger)]">
{error} {error}
</div> </div>
)} )}
{saved && ( {saved && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400"> <div className="rounded-xl bg-[var(--admin-primary-soft)] border border-[var(--admin-primary)] p-4 text-sm text-[var(--admin-primary)]">
Product updated successfully. Product updated successfully.
</div> </div>
)} )}
@@ -205,13 +206,13 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
</AdminInput> </AdminInput>
<div> <div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label> <label className="mb-2 block text-sm font-medium text-[var(--admin-text-primary)]">Status</label>
<button <button
onClick={() => setActive((v) => !v)} onClick={() => setActive((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${ className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
active active
? "bg-green-900/40 text-green-400" ? "bg-[var(--admin-primary-soft)] text-[var(--admin-primary)] ring-1 ring-[var(--admin-primary)]"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200" : "bg-[var(--admin-bg)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)]"
}`} }`}
> >
{active ? "Active" : "Inactive"} {active ? "Active" : "Inactive"}
@@ -219,19 +220,19 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
</div> </div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Taxable</label> <label className="mb-2 block text-sm font-medium text-[var(--admin-text-primary)]">Taxable</label>
<button <button
onClick={() => setIs_taxable((v) => !v)} onClick={() => setIs_taxable((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center gap-3 ${ className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center gap-3 ${
is_taxable is_taxable
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200" ? "bg-[var(--admin-primary-soft)] text-[var(--admin-primary)] ring-1 ring-[var(--admin-primary)]"
: "bg-amber-900/30 text-amber-700 ring-1 ring-amber-200" : "bg-[var(--admin-accent-soft)] text-[var(--admin-accent)] ring-1 ring-[var(--admin-accent)]"
}`} }`}
> >
<span className="text-lg">{is_taxable ? "✓" : "✗"}</span> <span className="text-lg">{is_taxable ? "✓" : "✗"}</span>
<span>{is_taxable ? "Taxable — tax applied at checkout for nexus shipments" : "Non-taxable — always exempt from sales tax"}</span> <span>{is_taxable ? "Taxable — tax applied at checkout for nexus shipments" : "Non-taxable — always exempt from sales tax"}</span>
</button> </button>
<p className="mt-1.5 text-xs text-zinc-500">Tax is calculated at checkout for shipping orders in your brand&apos;s nexus states. Non-taxable items (e.g. cooler boxes, apparel) are always exempt.</p> <p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">Tax is calculated at checkout for shipping orders in your brand&apos;s nexus states. Non-taxable items (e.g. cooler boxes, apparel) are always exempt.</p>
</div> </div>
<AdminInput <AdminInput
@@ -249,8 +250,8 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
</AdminInput> </AdminInput>
<div> <div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Product Image</label> <label className="mb-1 block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</label>
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p> <p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div <div
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
@@ -262,28 +263,28 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className={` className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${dragOver ? "border-green-500 bg-green-900/30" : "border-zinc-600 hover:border-slate-400 hover:bg-zinc-900"} ${dragOver ? "border-[var(--admin-primary)] bg-[var(--admin-primary-soft)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
${uploading ? "opacity-50 pointer-events-none" : ""} ${uploading ? "opacity-50 pointer-events-none" : ""}
`} `}
> >
{uploading ? ( {uploading ? (
<> <>
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" /> <div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
<span className="text-sm text-zinc-500">Uploading...</span> <span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
</> </>
) : imagePreview ? ( ) : imagePreview ? (
<> <>
<span className="relative inline-block h-32 w-auto"> <span className="relative inline-block h-32 w-auto">
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" /> <NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</span> </span>
<span className="text-xs text-zinc-500">Click or drop to replace</span> <span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
</> </>
) : ( ) : (
<> <>
<svg className="h-8 w-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg> </svg>
<span className="text-sm text-zinc-500">Drag & drop or click to upload</span> <span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
</> </>
)} )}
<input <input
@@ -298,26 +299,35 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
/> />
</div> </div>
{uploadError && <p className="mt-1 text-xs text-red-400">{uploadError}</p>} {uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
{imagePreview && ( {imagePreview && (
<button <button
type="button" type="button"
onClick={handleRemoveImage} onClick={handleRemoveImage}
className="mt-2 text-xs text-red-500 hover:underline" className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
> >
Remove image Remove image
</button> </button>
)} )}
</div> </div>
{/* Save button bar — new design tokens */}
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50" className="ha-btn-primary"
> >
{saving ? "Saving..." : "Save Changes"} {saving ? "Saving..." : "Save Changes"}
</button> </button>
<Link
href="/admin/products"
className="ha-btn-ghost"
>
Cancel
</Link>
</div>
</div> </div>
); );
} }
+9 -7
View File
@@ -247,7 +247,7 @@ export default function ProductFormModal({
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
style={{ style={{
backgroundColor: "rgba(28, 25, 23, 0.55)", backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 55%, transparent)",
backdropFilter: "blur(8px)", backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)", WebkitBackdropFilter: "blur(8px)",
}} }}
@@ -256,7 +256,8 @@ export default function ProductFormModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={mode === "add" ? "Add product" : "Edit product"} aria-label={mode === "add" ? "Add product" : "Edit product"}
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl shadow-[0_30px_80px_-20px_rgba(28,25,23,0.45)] flex flex-col overflow-hidden border border-stone-200/60" className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl flex flex-col overflow-hidden border border-[var(--admin-border)]"
style={{ boxShadow: "0 30px 80px -20px color-mix(in srgb, var(--admin-text-primary) 45%, transparent)" }}
> >
{/* grain overlay */} {/* grain overlay */}
<div className="atelier-grain" aria-hidden /> <div className="atelier-grain" aria-hidden />
@@ -357,7 +358,7 @@ export default function ProductFormModal({
{uploading ? ( {uploading ? (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div className="h-6 w-6 rounded-full border-2 border-stone-300 border-t-[#224E2F] animate-spin" /> <div className="h-6 w-6 rounded-full border-2 border-stone-300 border-t-[var(--admin-primary)] animate-spin" />
<p className="atelier-drop-title">Pouring into the cellar</p> <p className="atelier-drop-title">Pouring into the cellar</p>
</div> </div>
) : imagePreview ? ( ) : imagePreview ? (
@@ -390,7 +391,7 @@ export default function ProductFormModal({
<> <>
{/* ornamental drop zone content */} {/* ornamental drop zone content */}
<svg <svg
className="h-10 w-10 text-[#224E2F]/70" className="h-10 w-10 text-[var(--admin-primary)]/70"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -425,7 +426,7 @@ export default function ProductFormModal({
</div> </div>
{uploadError && ( {uploadError && (
<p className="mt-3 atelier-hint" style={{ color: "#B91C1C" }}> <p className="mt-3 atelier-hint" style={{ color: "var(--admin-danger)" }}>
{uploadError} {uploadError}
</p> </p>
)} )}
@@ -668,7 +669,7 @@ export default function ProductFormModal({
</form> </form>
{/* FOOTER */} {/* FOOTER */}
<div className="relative shrink-0 px-6 sm:px-10 pt-4 pb-6 sm:pb-7 border-t border-stone-200/60 bg-[#FAF7F0]/80 backdrop-blur-sm"> <div className="relative shrink-0 px-6 sm:px-10 pt-4 pb-6 sm:pb-7 border-t border-[var(--admin-border)] bg-[var(--admin-bg)]/80 backdrop-blur-sm">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<button <button
type="button" type="button"
@@ -685,9 +686,10 @@ export default function ProductFormModal({
className={[ className={[
"h-1.5 w-1.5 rounded-full", "h-1.5 w-1.5 rounded-full",
name.trim() && price name.trim() && price
? "bg-emerald-600 shadow-[0_0_0_3px_rgba(22,163,74,0.15)]" ? "bg-[var(--admin-primary)]"
: "bg-stone-300", : "bg-stone-300",
].join(" ")} ].join(" ")}
style={name.trim() && price ? { boxShadow: "0 0 0 3px var(--admin-primary-soft)" } : undefined}
/> />
{name.trim() && price ? "Ready" : "Required fields pending"} {name.trim() && price ? "Ready" : "Required fields pending"}
</div> </div>
+32 -28
View File
@@ -68,35 +68,35 @@ export default function ProductTableBody({
return ( return (
<> <>
{/* Filter bar — sits outside <table> in the parent */} {/* Filter bar — sits outside <table> in the parent */}
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center"> <div className="border-b border-[var(--admin-border-light)] px-5 py-3 flex gap-3 flex-wrap items-center">
<input <input
type="search" type="search"
placeholder="Search products..." placeholder="Search products..."
value={search} value={search}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
className="flex-1 min-w-40 rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-slate-900" className="flex-1 min-w-40 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-primary)]"
/> />
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1"> <div className="flex gap-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] p-1">
{(["all", "active", "inactive"] as const).map((f) => ( {(["all", "active", "inactive"] as const).map((f) => (
<button <button
key={f} key={f}
onClick={() => onStatusChange(f)} onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${ className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f statusFilter === f
? "bg-slate-900 text-white" ? "bg-[var(--admin-primary)] text-white"
: "text-zinc-400 hover:bg-zinc-950" : "text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
}`} }`}
> >
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"} {f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button> </button>
))} ))}
</div> </div>
<span className="text-xs text-slate-400">{filtered.length}</span> <span className="text-xs text-[var(--admin-text-muted)]">{filtered.length}</span>
</div> </div>
{/* Delete error banner */} {/* Delete error banner */}
{deleteError && ( {deleteError && (
<div className="mx-5 mt-3 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400"> <div className="mx-5 mt-3 rounded-lg bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError} {deleteError}
<button <button
onClick={() => setDeleteError(null)} onClick={() => setDeleteError(null)}
@@ -107,10 +107,10 @@ export default function ProductTableBody({
</div> </div>
)} )}
<tbody className="divide-y divide-slate-200"> <tbody className="divide-y divide-[var(--admin-border-light)]">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="px-5 py-10 text-center text-sm text-zinc-500"> <td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
{search || statusFilter !== "all" {search || statusFilter !== "all"
? "No products match your search." ? "No products match your search."
: "No products found."} : "No products found."}
@@ -120,43 +120,46 @@ export default function ProductTableBody({
filtered.map((product) => ( filtered.map((product) => (
<tr <tr
key={product.id} key={product.id}
className="hover:bg-zinc-800 transition-colors relative" className="hover:bg-[var(--admin-primary-soft)] transition-colors relative"
> >
<td className="px-3 py-2"> <td className="px-3 py-2">
<Link <Link
href={`/admin/products/${product.id}`} href={`/admin/products/${product.id}`}
className="block font-medium text-zinc-100 hover:text-zinc-400" className="block font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-primary)]"
> >
{product.name} {product.name}
</Link> </Link>
<div className="text-zinc-500 line-clamp-1 text-sm"> <div className="text-[var(--admin-text-muted)] line-clamp-1 text-sm">
{product.description || <span className="italic text-slate-400">No description</span>} {product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
</div> </div>
</td> </td>
<td className="px-3 py-2 text-zinc-300"> <td className="px-3 py-2 text-[var(--admin-text-secondary)]">
{Array.isArray(product.brands) {Array.isArray(product.brands)
? product.brands[0]?.name ? product.brands[0]?.name
: product.brands?.name} : product.brands?.name}
</td> </td>
<td className="px-3 py-2 text-zinc-300">{product.type}</td> <td className="px-3 py-2 text-[var(--admin-text-secondary)]">{product.type}</td>
<td className="px-3 py-2 font-semibold text-zinc-100"> <td
className="px-3 py-2 font-semibold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)} ${Number(product.price).toFixed(2)}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<AdminBadge variant={product.active ? "success" : "default"} dot> <AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"} {product.active ? "Active" : "Inactive"}
</AdminBadge> </AdminBadge>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{product.is_taxable === false ? ( {product.is_taxable === false ? (
<AdminBadge variant="warning">Non-taxable</AdminBadge> <AdminBadge tone="warning">Non-taxable</AdminBadge>
) : ( ) : (
<AdminBadge variant="success">Taxable</AdminBadge> <AdminBadge tone="success">Taxable</AdminBadge>
)} )}
</td> </td>
@@ -165,7 +168,7 @@ export default function ProductTableBody({
<div className="relative inline-flex items-center justify-end gap-2"> <div className="relative inline-flex items-center justify-end gap-2">
<Link <Link
href={`/admin/products/${product.id}`} href={`/admin/products/${product.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-950" className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
> >
Edit Edit
</Link> </Link>
@@ -174,7 +177,7 @@ export default function ProductTableBody({
e.preventDefault(); e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id); setOpenMenu(openMenu === product.id ? null : product.id);
}} }}
className="rounded-lg px-2 py-1.5 text-xs text-zinc-500 hover:bg-zinc-950" className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)]"
> >
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/> <circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
@@ -187,10 +190,10 @@ export default function ProductTableBody({
className="fixed inset-0 z-10" className="fixed inset-0 z-10"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }} onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/> />
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-zinc-900 shadow-lg ring-1 ring-zinc-700 overflow-hidden"> <div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-[var(--admin-card-bg)] shadow-lg ring-1 ring-[var(--admin-border)] overflow-hidden">
<button <button
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }} onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-400 hover:bg-red-900/30" className="w-full text-left px-4 py-2.5 text-sm text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)]"
> >
Delete Delete
</button> </button>
@@ -201,21 +204,22 @@ export default function ProductTableBody({
{confirmDelete === product.id && ( {confirmDelete === product.id && (
<> <>
<div <div
className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm" className="fixed inset-0 z-30 backdrop-blur-sm"
style={{ backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 60%, transparent)" }}
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }} onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/> />
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-zinc-900 shadow-xl ring-1 ring-zinc-700 p-6"> <div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-[var(--admin-card-bg)] shadow-xl ring-1 ring-[var(--admin-border)] p-6">
<p className="text-sm font-semibold text-zinc-100"> <p className="text-sm font-semibold text-[var(--admin-text-primary)]">
Delete &quot;{product.name}&quot;? Delete &quot;{product.name}&quot;?
</p> </p>
<p className="mt-2 text-xs text-zinc-500"> <p className="mt-2 text-xs text-[var(--admin-text-muted)]">
This will remove the product. If it is attached to any This will remove the product. If it is attached to any
orders, it will be hidden instead of deleted. orders, it will be hidden instead of deleted.
</p> </p>
<div className="mt-5 flex gap-3"> <div className="mt-5 flex gap-3">
<button <button
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }} onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-zinc-600 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800" className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
> >
Cancel Cancel
</button> </button>
+97 -101
View File
@@ -9,7 +9,8 @@ import { updateProduct } from "@/actions/products/update-product";
import { deleteProduct } from "@/actions/products"; import { deleteProduct } from "@/actions/products";
import { uploadProductImage } from "@/actions/products/upload-image"; import { uploadProductImage } from "@/actions/products/upload-image";
import ProductFormModal, { type ProductFormValues } from "@/components/admin/ProductFormModal"; import ProductFormModal, { type ProductFormValues } from "@/components/admin/ProductFormModal";
import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, useToast, Skeleton } from "@/components/admin/design-system"; import { PageHeader, AdminButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, AdminBadge, EmptyState, useToast, Skeleton } from "@/components/admin/design-system";
import { Package as PackageIconLucide, Inbox as InboxIcon } from "lucide-react";
type Product = { type Product = {
id: string; id: string;
@@ -29,44 +30,13 @@ type ViewMode = "table" | "cards";
// Icons // Icons
const Icons = { const Icons = {
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
plus: (className: string) => ( plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14"/> <path d="M12 5v14M5 12h14"/>
</svg> </svg>
), ),
package: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m7.5 4.27 9 5.15"/>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
<path d="m3.3 7 8.7 5 8.7-5"/>
<path d="M12 22V12"/>
</svg>
),
upload: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
),
}; };
// Page header icon
const PackageIconHeader = () => (
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m7.5 4.27 9 5.15"/>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
<path d="m3.3 7 8.7 5 8.7-5"/>
<path d="M12 22V12"/>
</svg>
);
export default function ProductsClient({ export default function ProductsClient({
products, products,
brandId, brandId,
@@ -253,34 +223,50 @@ export default function ProductsClient({
return ( return (
<div className="p-4 sm:p-6"> <div className="p-4 sm:p-6">
{/* Page Header */} {/* Page Header — eyebrow + title + subtitle + primary CTA */}
<div className="ha-eyebrow mb-2">Operations</div>
<PageHeader <PageHeader
icon={<PackageIconHeader />} icon={<PackageIconLucide className="h-5 w-5" strokeWidth={1.75} />}
title="Products" title="Products"
subtitle={`${filtered.length} product${filtered.length !== 1 ? "s" : ""}`} subtitle="Manage your product catalog, pricing, and availability."
actions={ actions={
<AdminButton <AdminButton
onClick={openAddModal} onClick={openAddModal}
icon={Icons.plus("h-4 w-4")} icon={Icons.plus("h-4 w-4")}
> >
Add Product Add product
</AdminButton> </AdminButton>
} }
/> />
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-3 gap-3 mb-6"> <div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4"> <div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p> <p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Total</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : products.length}</p> <p
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : products.length}
</p>
</div> </div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4"> <div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p> <p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Active</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1">{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : activeCount}</p> <p
className="text-xl sm:text-2xl font-bold text-[var(--admin-primary)] mt-1"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : activeCount}
</p>
</div> </div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4"> <div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Inactive</p> <p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Inactive</p>
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1">{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : inactiveCount}</p> <p
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-muted)] mt-1"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : inactiveCount}
</p>
</div> </div>
</div> </div>
@@ -320,6 +306,7 @@ export default function ProductsClient({
onDeleteConfirm={(id) => handleDelete(id)} onDeleteConfirm={(id) => handleDelete(id)}
onDeleteCancel={() => setDeleteConfirm(null)} onDeleteCancel={() => setDeleteConfirm(null)}
deletingId={deletingId} deletingId={deletingId}
onAdd={openAddModal}
/> />
) : ( ) : (
<CardView <CardView
@@ -330,6 +317,7 @@ export default function ProductsClient({
onDeleteConfirm={(id) => handleDelete(id)} onDeleteConfirm={(id) => handleDelete(id)}
onDeleteCancel={() => setDeleteConfirm(null)} onDeleteCancel={() => setDeleteConfirm(null)}
deletingId={deletingId} deletingId={deletingId}
onAdd={openAddModal}
/> />
)} )}
@@ -375,6 +363,7 @@ function TableView({
onDeleteConfirm, onDeleteConfirm,
onDeleteCancel, onDeleteCancel,
deletingId, deletingId,
onAdd,
}: { }: {
products: Product[]; products: Product[];
onEdit: (p: Product) => void; onEdit: (p: Product) => void;
@@ -383,6 +372,7 @@ function TableView({
onDeleteConfirm: (id: string) => void; onDeleteConfirm: (id: string) => void;
onDeleteCancel: () => void; onDeleteCancel: () => void;
deletingId: string | null; deletingId: string | null;
onAdd: () => void;
}) { }) {
// Track the position of the open row's three-dots button so the popup can be // Track the position of the open row's three-dots button so the popup can be
// rendered via portal at body level (escapes any overflow:hidden ancestors // rendered via portal at body level (escapes any overflow:hidden ancestors
@@ -434,31 +424,32 @@ function TableView({
const openProduct = deleteConfirm ? products.find((p) => p.id === deleteConfirm) : null; const openProduct = deleteConfirm ? products.find((p) => p.id === deleteConfirm) : null;
return ( return (
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-white"> <div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)]">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-stone-50"> <thead className="bg-[var(--admin-bg)]">
<tr> <tr>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Product</th> <th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Product</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Type</th> <th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Type</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Price</th> <th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Price</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th> <th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Status</th>
<th className="px-4 py-3" /> <th className="px-4 py-3" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--admin-border)]"> <tbody className="divide-y divide-[var(--admin-border)]">
{products.length === 0 ? ( {products.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="px-4 py-16 text-center"> <td colSpan={5} className="px-4 py-8">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4"> <EmptyState
{Icons.package("h-8 w-8 text-stone-400")} icon={<InboxIcon className="h-8 w-8" strokeWidth={1.25} />}
</div> title="No products found"
<p className="text-sm font-medium text-stone-600">No products found</p> description="Try adjusting your filters, or add your first product to get started."
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p> action={{ label: "Add your first product", onClick: onAdd }}
/>
</td> </td>
</tr> </tr>
) : ( ) : (
products.map((product) => ( products.map((product) => (
<tr key={product.id} className="hover:bg-stone-50 transition-colors"> <tr key={product.id} className="hover:bg-[var(--admin-primary-soft)] transition-colors">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{product.image_url ? ( {product.image_url ? (
@@ -472,40 +463,41 @@ function TableView({
/> />
</div> </div>
) : ( ) : (
<div className="h-10 w-10 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-[var(--admin-border)]"> <div className="h-10 w-10 rounded-lg bg-[var(--admin-bg-subtle)] flex items-center justify-center shrink-0 border border-[var(--admin-border)]">
<span className="text-stone-400"></span> <span className="text-[var(--admin-text-muted)]"></span>
</div> </div>
)} )}
<div> <div>
<button <button
onClick={() => onEdit(product)} onClick={() => onEdit(product)}
className="font-semibold text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] transition-colors text-left" className="font-semibold text-[var(--admin-text-primary)] hover:text-[var(--admin-primary)] transition-colors text-left"
> >
{product.name} {product.name}
</button> </button>
<div className="text-xs text-stone-500 line-clamp-1"> <div className="text-xs text-[var(--admin-text-muted)] line-clamp-1">
{product.description || <span className="italic text-stone-400">No description</span>} {product.description || <span className="italic text-[var(--admin-text-muted)]">No description</span>}
</div> </div>
</div> </div>
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="rounded-md bg-stone-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-stone-600"> <span className="rounded-md bg-[var(--admin-bg-subtle)] px-2 py-0.5 text-[10px] font-semibold uppercase text-[var(--admin-text-secondary)]">
{product.type} {product.type}
</span> </span>
</td> </td>
<td className="px-4 py-3 font-mono text-sm font-semibold text-[var(--admin-text-primary)]"> <td
className="px-4 py-3 text-sm font-semibold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)} ${Number(product.price).toFixed(2)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${ <AdminBadge tone={product.active ? "success" : "neutral"} dot>
product.active ? "bg-[var(--admin-accent)]/10 text-[var(--admin-accent)]" : "bg-stone-100 text-stone-500"
}`}>
{product.active ? "Active" : "Inactive"} {product.active ? "Active" : "Inactive"}
</span> </AdminBadge>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
@@ -520,7 +512,7 @@ function TableView({
<button <button
ref={(el) => { buttonRefs.current[product.id] = el; }} ref={(el) => { buttonRefs.current[product.id] = el; }}
onClick={() => onDelete(product.id)} onClick={() => onDelete(product.id)}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors" className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)] transition-colors"
aria-label="Product actions" aria-label="Product actions"
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -548,19 +540,19 @@ function TableView({
<div <div
role="dialog" role="dialog"
aria-label="Confirm delete" aria-label="Confirm delete"
className="fixed z-[70] w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4" className="fixed z-[70] w-72 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] shadow-xl p-4"
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }} style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
> >
<p className="text-sm font-semibold text-stone-900"> <p className="text-sm font-semibold text-[var(--admin-text-primary)]">
Delete &quot;{openProduct.name}&quot;? Delete &quot;{openProduct.name}&quot;?
</p> </p>
<p className="mt-1 text-xs text-stone-500"> <p className="mt-1 text-xs text-[var(--admin-text-muted)]">
This will remove the product. If attached to orders, it will be hidden. This will remove the product. If attached to orders, it will be hidden.
</p> </p>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<button <button
onClick={onDeleteCancel} onClick={onDeleteCancel}
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50" className="flex-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg)]"
> >
Cancel Cancel
</button> </button>
@@ -592,6 +584,7 @@ function CardView({
onDeleteConfirm, onDeleteConfirm,
onDeleteCancel, onDeleteCancel,
deletingId, deletingId,
onAdd,
}: { }: {
products: Product[]; products: Product[];
onEdit: (p: Product) => void; onEdit: (p: Product) => void;
@@ -600,25 +593,27 @@ function CardView({
onDeleteConfirm: (id: string) => void; onDeleteConfirm: (id: string) => void;
onDeleteCancel: () => void; onDeleteCancel: () => void;
deletingId: string | null; deletingId: string | null;
onAdd: () => void;
}) { }) {
return ( return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{products.length === 0 ? ( {products.length === 0 ? (
<div className="col-span-full text-center py-16 rounded-xl border border-[var(--admin-border)] bg-white"> <div className="col-span-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)]">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4"> <EmptyState
{Icons.package("h-8 w-8 text-stone-400")} icon={<InboxIcon className="h-8 w-8" strokeWidth={1.25} />}
</div> title="No products found"
<p className="text-sm font-medium text-stone-600">No products found</p> description="Try adjusting your filters, or add your first product to get started."
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p> action={{ label: "Add your first product", onClick: onAdd }}
/>
</div> </div>
) : ( ) : (
products.map((product) => ( products.map((product) => (
<div <div
key={product.id} key={product.id}
className="group relative rounded-xl border border-[var(--admin-border)] bg-white hover:shadow-md hover:border-[var(--admin-accent)]/30 transition-all overflow-hidden" className="group relative rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] hover:shadow-md hover:border-[var(--admin-primary)]/40 transition-all overflow-hidden"
> >
{/* Image */} {/* Image */}
<div className="relative h-40 bg-stone-100"> <div className="relative h-40 bg-[var(--admin-bg-subtle)]">
{product.image_url ? ( {product.image_url ? (
<Image <Image
src={product.image_url} src={product.image_url}
@@ -630,16 +625,14 @@ function CardView({
/> />
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<span className="text-4xl text-stone-300"></span> <span className="text-4xl text-[var(--admin-text-muted)]"></span>
</div> </div>
)} )}
{/* Status badge */} {/* Status badge */}
<div className="absolute top-3 right-3"> <div className="absolute top-3 right-3">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold shadow-sm ${ <AdminBadge tone={product.active ? "success" : "neutral"} dot>
product.active ? "bg-[var(--admin-accent)] text-white" : "bg-stone-500 text-white"
}`}>
{product.active ? "Active" : "Inactive"} {product.active ? "Active" : "Inactive"}
</span> </AdminBadge>
</div> </div>
</div> </div>
@@ -649,27 +642,30 @@ function CardView({
onClick={() => onEdit(product)} onClick={() => onEdit(product)}
className="block w-full text-left" className="block w-full text-left"
> >
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors line-clamp-1"> <h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-primary)] transition-colors line-clamp-1">
{product.name} {product.name}
</h3> </h3>
<p className="text-xs text-stone-500 mt-1 line-clamp-2 h-8"> <p className="text-xs text-[var(--admin-text-muted)] mt-1 line-clamp-2 h-8">
{product.description || <span className="italic">No description</span>} {product.description || <span className="italic">No description</span>}
</p> </p>
</button> </button>
<div className="flex items-center justify-between mt-4 pt-3 border-t border-stone-100"> <div className="flex items-center justify-between mt-4 pt-3 border-t border-[var(--admin-border-light)]">
<div> <div>
<span className="text-xs text-stone-500">Price</span> <span className="text-xs text-[var(--admin-text-muted)]">Price</span>
<p className="text-lg font-bold text-[var(--admin-text-primary)] font-mono"> <p
className="text-lg font-bold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)} ${Number(product.price).toFixed(2)}
</p> </p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="rounded-md bg-stone-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-stone-600"> <span className="rounded-md bg-[var(--admin-bg-subtle)] px-2 py-0.5 text-[10px] font-semibold uppercase text-[var(--admin-text-secondary)]">
{product.type} {product.type}
</span> </span>
{product.is_taxable && ( {product.is_taxable && (
<span className="rounded-md bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700 border border-amber-200"> <span className="rounded-md bg-[var(--admin-accent-soft)] px-2 py-0.5 text-[10px] font-semibold text-[var(--admin-accent)] border border-[var(--admin-accent)]">
Tax Tax
</span> </span>
)} )}
@@ -687,7 +683,7 @@ function CardView({
</AdminButton> </AdminButton>
<button <button
onClick={() => onDelete(product.id)} onClick={() => onDelete(product.id)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors" className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-[var(--admin-danger-soft)] hover:text-[var(--admin-danger)] hover:border-[var(--admin-danger)] transition-colors"
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
@@ -698,25 +694,25 @@ function CardView({
{/* Delete confirm */} {/* Delete confirm */}
{deleteConfirm === product.id && ( {deleteConfirm === product.id && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"> <div className="absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm" style={{ backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 50%, transparent)" }}>
<div className="bg-white rounded-xl shadow-xl p-4 max-w-[280px] mx-4"> <div className="bg-[var(--admin-card-bg)] rounded-xl shadow-xl p-4 max-w-[280px] mx-4">
<p className="text-sm font-semibold text-stone-900"> <p className="text-sm font-semibold text-[var(--admin-text-primary)]">
Delete &quot;{product.name}&quot;? Delete &quot;{product.name}&quot;?
</p> </p>
<p className="mt-1 text-xs text-stone-500"> <p className="mt-1 text-xs text-[var(--admin-text-muted)]">
This will remove the product. If attached to orders, it will be hidden. This will remove the product. If attached to orders, it will be hidden.
</p> </p>
<div className="flex gap-2 mt-3"> <div className="flex gap-2 mt-3">
<button <button
onClick={onDeleteCancel} onClick={onDeleteCancel}
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50" className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg)]"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={() => onDeleteConfirm(product.id)} onClick={() => onDeleteConfirm(product.id)}
disabled={deletingId === product.id} disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500" className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-[var(--admin-danger-hover)]"
> >
{deletingId === product.id ? "..." : "Delete"} {deletingId === product.id ? "..." : "Delete"}
</button> </button>
+49
View File
@@ -0,0 +1,49 @@
import React from "react";
/**
* SideNavGroup a presentational wrapper that renders an optional group
* label (10px uppercase, tracking-widest) and a hairline divider, then
* passes the nav item children through unchanged.
*
* Used by AdminSidebar to chunk the long list of admin links into the
* 7 IA groups defined in `docs/superpowers/specs/2026-06-17-admin-redesign.md`
* (Workspace · Operations · Communications · Growth · Tracking · Insights · Settings).
*
* `collapsed` is a future-proofing prop for a per-group collapse toggle;
* it visually hides the children but keeps the label + divider in place.
*/
type SideNavGroupProps = {
label?: string;
children: React.ReactNode;
collapsed?: boolean;
};
export default function SideNavGroup({
label,
children,
collapsed = false,
}: SideNavGroupProps) {
return (
<section
className="px-3 pb-3"
aria-label={label ? `${label} section` : undefined}
>
{label && (
<div
className="flex items-center gap-2 px-3 pt-3 pb-2 mb-1 border-b"
style={{ borderColor: "rgba(208, 203, 180, 0.12)" }}
>
<span
className="text-[10px] font-semibold uppercase tracking-widest select-none"
style={{ color: "rgba(184, 180, 168, 0.5)" }}
>
{label}
</span>
</div>
)}
<ul className="space-y-1" role="list" hidden={collapsed}>
{children}
</ul>
</section>
);
}
+15 -9
View File
@@ -93,19 +93,25 @@ export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props)
> >
{loading ? ( {loading ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="h-4 w-1/3 animate-pulse rounded bg-stone-200" /> <div className="h-4 w-1/3 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
<div className="h-4 w-2/3 animate-pulse rounded bg-stone-200" /> <div className="h-4 w-2/3 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
<div className="h-4 w-1/2 animate-pulse rounded bg-stone-200" /> <div className="h-4 w-1/2 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
</div> </div>
) : loadError ? ( ) : loadError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div
className="rounded-xl border px-4 py-3 text-sm text-[var(--admin-danger)]"
style={{
background: "var(--admin-danger-soft)",
borderColor: "color-mix(in srgb, var(--admin-danger) 25%, transparent)",
}}
>
{loadError} {loadError}
</div> </div>
) : stop ? ( ) : stop ? (
<div className="space-y-5"> <div className="space-y-5">
{/* Tabs */} {/* Tabs */}
<div <div
className="flex items-center gap-1 rounded-xl border border-[var(--admin-border)] bg-stone-50 p-1" className="flex items-center gap-1 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-1"
role="tablist" role="tablist"
> >
<TabButton active={tab === "details"} onClick={() => setTab("details")}> <TabButton active={tab === "details"} onClick={() => setTab("details")}>
@@ -223,8 +229,8 @@ function DetailsPanel({
<span <span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${ className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${
stop.active stop.active
? "bg-emerald-100 text-emerald-700" ? "bg-[var(--admin-success-soft)] text-[var(--admin-success)]"
: "bg-stone-200 text-stone-500" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
}`} }`}
> >
{stop.active ? "Active" : "Inactive"} {stop.active ? "Active" : "Inactive"}
@@ -265,14 +271,14 @@ function DetailsPanel({
<button <button
type="button" type="button"
onClick={() => onDuplicate(stop.id)} onClick={() => onDuplicate(stop.id)}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50" className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
> >
Duplicate Stop Duplicate Stop
</button> </button>
) : ( ) : (
<a <a
href={`/admin/stops/new?duplicate=${stop.id}`} href={`/admin/stops/new?duplicate=${stop.id}`}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50" className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
> >
Duplicate Stop Duplicate Stop
</a> </a>
+42 -28
View File
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { getStopPendingCustomers, type StopCustomer } from "@/actions/stops/get-stop-customers"; import { getStopPendingCustomers, type StopCustomer } from "@/actions/stops/get-stop-customers";
import { sendStopBlast } from "@/actions/communications/stop-blast"; import { sendStopBlast } from "@/actions/communications/stop-blast";
import { AdminButton } from "@/components/admin/design-system";
const quickMessages = [ const quickMessages = [
{ label: "Truck running late", value: "Heads up — the truck is running about 15 minutes behind schedule. Thanks for your patience!" }, { label: "Truck running late", value: "Heads up — the truck is running about 15 minutes behind schedule. Thanks for your patience!" },
@@ -84,10 +85,10 @@ export default function StopMessagingForm({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-bold text-zinc-100"> <h2 className="ha-display text-2xl text-[var(--admin-text-primary)]">
Send Message Send Message
</h2> </h2>
<p className="mt-1 text-zinc-400"> <p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Notify all customers with pending pickups at this stop. Notify all customers with pending pickups at this stop.
</p> </p>
</div> </div>
@@ -96,18 +97,21 @@ export default function StopMessagingForm({
<button <button
onClick={loadCustomers} onClick={loadCustomers}
disabled={loading} disabled={loading}
className="w-full rounded-xl border-2 border-dashed border-zinc-600 px-6 py-4 text-lg font-medium text-zinc-500 disabled:opacity-50" className="w-full rounded-xl border-2 border-dashed border-[var(--admin-border)] px-6 py-4 text-lg font-medium text-[var(--admin-text-secondary)] hover:border-[var(--admin-primary)] hover:text-[var(--admin-primary)] transition-colors disabled:opacity-50"
> >
{loading ? "Loading customers..." : "Load Pending Customers"} {loading ? "Loading customers..." : "Load Pending Customers"}
</button> </button>
) : ( ) : (
<> <>
{customers.length === 0 ? ( {customers.length === 0 ? (
<div className="rounded-xl bg-slate-50 p-6 text-center text-zinc-500"> <div className="rounded-xl bg-[var(--admin-bg-subtle)] p-6 text-center text-[var(--admin-text-muted)]">
No pending orders for this stop yet. No pending orders for this stop yet.
</div> </div>
) : ( ) : (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400"> <div
className="rounded-xl p-4 text-sm text-[var(--admin-success)]"
style={{ background: "var(--admin-success-soft)" }}
>
{customers.length} pending order{customers.length !== 1 ? "s" : ""} found {customers.length} pending order{customers.length !== 1 ? "s" : ""} found
{recipients.length !== customers.length && ( {recipients.length !== customers.length && (
<span> {recipients.length} with contact info</span> <span> {recipients.length} with contact info</span>
@@ -117,21 +121,21 @@ export default function StopMessagingForm({
{/* Channel */} {/* Channel */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-zinc-300"> <label className="ha-field-label mb-2">
Send via <span>Send via</span>
</label> </label>
<div className="flex gap-3"> <div className="flex gap-2">
{(["sms", "email", "both"] as const).map((ch) => ( {(["sms", "email", "both"] as const).map((ch) => (
<button <button
key={ch} key={ch}
onClick={() => setChannel(ch)} onClick={() => setChannel(ch)}
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${ className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
channel === ch channel === ch
? "bg-slate-900 text-white" ? "bg-[var(--admin-primary)] text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
}`} }`}
> >
{ch === "sms" ? "📱 SMS" : ch === "email" ? "✉️ Email" : "📱+✉️ Both"} {ch === "sms" ? "SMS" : ch === "email" ? "Email" : "Both"}
</button> </button>
))} ))}
</div> </div>
@@ -139,8 +143,8 @@ export default function StopMessagingForm({
{/* Quick messages */} {/* Quick messages */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-zinc-300"> <label className="ha-field-label mb-2">
Quick messages <span>Quick messages</span>
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{quickMessages.map((qm) => ( {quickMessages.map((qm) => (
@@ -149,8 +153,8 @@ export default function StopMessagingForm({
onClick={() => applyQuickMessage(qm.value)} onClick={() => applyQuickMessage(qm.value)}
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${ className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
message === qm.value message === qm.value
? "bg-slate-900 text-white" ? "bg-[var(--admin-primary)] text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-primary-soft)] hover:text-[var(--admin-primary)]"
}`} }`}
> >
{qm.label} {qm.label}
@@ -161,8 +165,8 @@ export default function StopMessagingForm({
{/* Message preview */} {/* Message preview */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-zinc-300"> <label className="ha-field-label mb-2">
Message <span>Message</span>
</label> </label>
<textarea <textarea
value={message} value={message}
@@ -171,37 +175,44 @@ export default function StopMessagingForm({
setCustomMessage(e.target.value); setCustomMessage(e.target.value);
}} }}
rows={4} rows={4}
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-slate-900" className="ha-field-textarea"
placeholder="Type your message..." placeholder="Type your message..."
/> />
<p className="mt-1 text-xs text-slate-400"> <p className="mt-1 text-xs text-[var(--admin-text-muted)] tabular-nums">
{message.length} characters {message.length} characters
</p> </p>
</div> </div>
{error && ( {error && (
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm"> <div
role="alert"
className="rounded-xl p-4 text-sm text-[var(--admin-danger)]"
style={{ background: "var(--admin-danger-soft)" }}
>
{error} {error}
</div> </div>
)} )}
{sent > 0 && ( {sent > 0 && (
<div className="rounded-xl bg-green-900/30 p-4 text-green-400 text-sm"> <div
className="rounded-xl p-4 text-sm text-[var(--admin-success)]"
style={{ background: "var(--admin-success-soft)" }}
>
Message sent to {sent} customer{sent !== 1 ? "s" : ""}! Message sent to {sent} customer{sent !== 1 ? "s" : ""}!
</div> </div>
)} )}
{/* Recipients */} {/* Recipients */}
{recipients.length > 0 && message && ( {recipients.length > 0 && message && (
<div className="rounded-xl border border-zinc-800 p-4"> <div className="rounded-xl border border-[var(--admin-border)] bg-white p-4">
<p className="mb-3 text-sm font-medium text-zinc-300"> <p className="mb-3 text-sm font-medium text-[var(--admin-text-secondary)]">
Recipients ({recipients.length}): Recipients ({recipients.length}):
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{recipients.map((c) => ( {recipients.map((c) => (
<div key={c.id} className="flex justify-between text-sm"> <div key={c.id} className="flex justify-between text-sm">
<span className="text-zinc-300">{c.customer_name}</span> <span className="text-[var(--admin-text-primary)]">{c.customer_name}</span>
<span className="text-slate-400"> <span className="text-[var(--admin-text-muted)] tabular-nums">
{c.customer_phone ?? c.customer_email ?? "no contact"} {c.customer_phone ?? c.customer_email ?? "no contact"}
</span> </span>
</div> </div>
@@ -210,15 +221,18 @@ export default function StopMessagingForm({
</div> </div>
)} )}
<button <AdminButton
variant="primary"
size="lg"
onClick={handleSend} onClick={handleSend}
disabled={!message || recipients.length === 0 || sending} disabled={!message || recipients.length === 0 || sending}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50" isLoading={sending}
className="w-full"
> >
{sending {sending
? "Sending..." ? "Sending..."
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`} : `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
</button> </AdminButton>
</> </>
)} )}
</div> </div>
@@ -203,8 +203,8 @@ export default function StopProductAssignment({
role="alert" role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]" className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{ style={{
background: "rgba(220, 38, 38, 0.06)", background: "var(--admin-danger-soft)",
border: "1px solid rgba(220, 38, 38, 0.15)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)",
}} }}
> >
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}> <svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -403,7 +403,7 @@ export default function StopsCalendarClient({ stops }: Props) {
Active Active
</span> </span>
<span className="ha-calendar-legend-item"> <span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "#f59e0b" }} /> <span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-warning)" }} />
Draft Draft
</span> </span>
<span className="ha-calendar-legend-item"> <span className="ha-calendar-legend-item">
@@ -513,7 +513,7 @@ function EventPopover({
className="inline-block w-1.5 h-1.5 rounded-full" className="inline-block w-1.5 h-1.5 rounded-full"
style={{ style={{
background: background:
status === "active" ? "var(--admin-accent)" : status === "draft" ? "#f59e0b" : "var(--admin-text-muted)", status === "active" ? "var(--admin-accent)" : status === "draft" ? "var(--admin-warning)" : "var(--admin-text-muted)",
}} }}
/> />
{statusLabel(status)} · {brandName(stop)} {statusLabel(status)} · {brandName(stop)}
@@ -682,19 +682,19 @@ function DayRouteDrawer({
status === "active" status === "active"
? "var(--admin-accent-light)" ? "var(--admin-accent-light)"
: status === "draft" : status === "draft"
? "rgba(245, 158, 11, 0.1)" ? "var(--admin-warning-soft)"
: "var(--admin-bg-subtle)", : "var(--admin-bg-subtle)",
color: color:
status === "active" status === "active"
? "var(--admin-accent-text)" ? "var(--admin-accent-text)"
: status === "draft" : status === "draft"
? "#92400e" ? "var(--admin-warning)"
: "var(--admin-text-secondary)", : "var(--admin-text-secondary)",
borderColor: borderColor:
status === "active" status === "active"
? "var(--admin-accent)" ? "var(--admin-accent)"
: status === "draft" : status === "draft"
? "rgba(245, 158, 11, 0.3)" ? "var(--admin-warning)"
: "var(--admin-border-light)", : "var(--admin-border-light)",
}} }}
> >
+6 -6
View File
@@ -68,13 +68,13 @@ export default function StopsLocationsTabs({
group relative flex items-center gap-2.5 rounded-xl px-3.5 py-2 group relative flex items-center gap-2.5 rounded-xl px-3.5 py-2
text-sm font-semibold transition-all duration-200 text-sm font-semibold transition-all duration-200
${isActive ${isActive
? "bg-white text-emerald-700 border border-emerald-200 shadow-sm" ? "bg-white text-[var(--admin-primary)] border border-[var(--admin-primary)]/30 shadow-sm"
: "text-stone-600 border border-transparent hover:text-emerald-700 hover:bg-emerald-50/40" : "text-[var(--admin-text-secondary)] border border-transparent hover:text-[var(--admin-primary)] hover:bg-[var(--admin-primary-soft)]/40"
} }
`} `}
> >
<span <span
className={`flex-shrink-0 transition-colors ${isActive ? "text-emerald-600" : "text-stone-400 group-hover:text-emerald-500"}`} className={`flex-shrink-0 transition-colors ${isActive ? "text-[var(--admin-primary)]" : "text-[var(--admin-text-muted)] group-hover:text-[var(--admin-primary)]"}`}
aria-hidden aria-hidden
> >
{t.icon} {t.icon}
@@ -85,8 +85,8 @@ export default function StopsLocationsTabs({
inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5 inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5
text-[11px] font-bold tabular-nums text-[11px] font-bold tabular-nums
${isActive ${isActive
? "bg-emerald-600 text-white" ? "bg-[var(--admin-primary)] text-white"
: "bg-stone-200/70 text-stone-600 group-hover:bg-emerald-100 group-hover:text-emerald-700" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] group-hover:bg-[var(--admin-primary-soft)] group-hover:text-[var(--admin-primary)]"
} }
`} `}
> >
@@ -94,7 +94,7 @@ export default function StopsLocationsTabs({
</span> </span>
{t.sublabel && ( {t.sublabel && (
<span <span
className={`hidden sm:inline text-[11px] font-medium tabular-nums ${isActive ? "text-stone-500" : "text-stone-400"}`} className={`hidden sm:inline text-[11px] font-medium tabular-nums ${isActive ? "text-[var(--admin-text-muted)]" : "text-[var(--admin-text-muted)]/80"}`}
> >
· {t.sublabel} · {t.sublabel}
</span> </span>
-1
View File
@@ -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;
}, []);
+141 -22
View File
@@ -1,33 +1,139 @@
"use client"; "use client";
import type { CSSProperties, ReactNode } from "react";
/**
* Visual variants for `AdminBadge`. Kept for backward compatibility
* existing callers (status pills, add-on flags, etc.) continue to pass
* one of these values and are routed onto the new tone palette below.
*/
type BadgeVariant = "default" | "success" | "warning" | "danger" | "info"; type BadgeVariant = "default" | "success" | "warning" | "danger" | "info";
/**
* New tone palette. Matches the spec in
* `docs/superpowers/specs/2026-06-17-admin-redesign.md` §3. The previous
* `--admin-warning-light` / `--admin-danger-light` references have been
* replaced with the new `--admin-warning-soft` / `--admin-danger-soft`
* tokens. No hardcoded hex values remain in this file.
*/
type BadgeTone =
| "neutral"
| "primary"
| "accent"
| "warning"
| "danger"
| "success"
| "info";
type AdminBadgeProps = { type AdminBadgeProps = {
children: React.ReactNode; children: ReactNode;
/** Legacy variant name. Falls back to "default". Mapped onto `tone` internally. */
variant?: BadgeVariant; variant?: BadgeVariant;
/** New tone name. Takes precedence over `variant` when both are provided. */
tone?: BadgeTone;
/** Optional leading dot indicator. */
dot?: boolean; dot?: boolean;
/** Extra classes — typically size overrides like `"text-xs px-2 py-0.5"`. */
className?: string; className?: string;
}; };
const variantClasses: Record<BadgeVariant, { bg: string; text: string; dot: string }> = { type ToneStyle = {
default: { bg: "bg-[var(--admin-border-light)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-text-muted)]" }, bg: string;
success: { bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", dot: "bg-[var(--admin-accent)]" }, text: string;
warning: { bg: "bg-[var(--admin-warning-light)]", text: "text-[var(--admin-warning)]", dot: "bg-[var(--admin-warning)]" }, /** Optional border color; falls back to the background when unset. */
danger: { bg: "bg-[var(--admin-danger-light)]", text: "text-[var(--admin-danger)]", dot: "bg-[var(--admin-danger)]" }, border?: string;
info: { bg: "bg-[var(--admin-border)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-info)]" }, dot: string;
};
// New tone palette. Every value references a `--admin-*` design token
// declared in `admin-design-system.css`.
const toneStyles: Record<BadgeTone, ToneStyle> = {
neutral: {
bg: "var(--admin-bg-subtle)",
text: "var(--admin-text-secondary)",
border: "var(--admin-border)",
dot: "var(--admin-text-muted)",
},
primary: {
bg: "var(--admin-primary-soft)",
text: "var(--admin-primary)",
border: "var(--admin-primary)",
dot: "var(--admin-primary)",
},
accent: {
bg: "var(--admin-accent-soft)",
text: "var(--admin-accent)",
border: "var(--admin-accent)",
dot: "var(--admin-accent)",
},
success: {
// "success" shares the primary botanical green per the unified palette.
bg: "var(--admin-primary-soft)",
text: "var(--admin-primary)",
border: "var(--admin-primary)",
dot: "var(--admin-primary)",
},
warning: {
bg: "var(--admin-warning-soft)",
text: "var(--admin-warning)",
border: "var(--admin-warning)",
dot: "var(--admin-warning)",
},
danger: {
bg: "var(--admin-danger-soft)",
text: "var(--admin-danger)",
border: "var(--admin-danger)",
dot: "var(--admin-danger)",
},
info: {
bg: "var(--admin-bg-subtle)",
text: "var(--admin-text-secondary)",
border: "var(--admin-border)",
dot: "var(--admin-text-secondary)",
},
};
// Map the legacy `variant` prop onto the new `tone` palette. The mapping
// preserves the visual intent of pre-refactor call sites.
const variantToTone: Record<BadgeVariant, BadgeTone> = {
default: "neutral",
success: "success",
warning: "warning",
danger: "danger",
info: "info",
}; };
export default function AdminBadge({ export default function AdminBadge({
children, children,
variant = "default", variant = "default",
tone,
dot = false, dot = false,
className = "" className = "",
}: AdminBadgeProps) { }: AdminBadgeProps) {
const { bg, text, dot: dotColor } = variantClasses[variant]; // `tone` wins when provided; otherwise translate the legacy variant.
const resolvedTone: BadgeTone = tone ?? variantToTone[variant];
const { bg, text, border, dot: dotColor } = toneStyles[resolvedTone];
// Border color tracks tone when the tone is one of the chromatic ones;
// otherwise it falls back to the neutral hairline so the pill keeps a
// subtle outline against the cream canvas.
const style: CSSProperties = {
backgroundColor: bg,
color: text,
borderColor: border ?? "var(--admin-border)",
};
return ( return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${bg} ${text} ${className}`}> <span
{dot && <span className={`h-1.5 w-1.5 rounded-full ${dotColor}`} />} className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-bold ${className}`}
style={style}
>
{dot && (
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: dotColor }}
/>
)}
{children} {children}
</span> </span>
); );
@@ -39,19 +145,21 @@ type AdminStatusBadgeProps = {
className?: string; className?: string;
}; };
const statusConfig: Record<string, { variant: BadgeVariant; dot: boolean; label: string }> = { // Reuse the new `tone` prop directly so the status pill text style always
active: { variant: "success", dot: true, label: "Active" }, // matches the active AdminBadge palette.
inactive: { variant: "default", dot: true, label: "Inactive" }, const statusConfig: Record<string, { tone: BadgeTone; dot: boolean; label: string }> = {
pending: { variant: "warning", dot: true, label: "Pending" }, active: { tone: "success", dot: true, label: "Active" },
draft: { variant: "default", dot: true, label: "Draft" }, inactive: { tone: "neutral", dot: true, label: "Inactive" },
completed: { variant: "success", dot: true, label: "Completed" }, pending: { tone: "warning", dot: true, label: "Pending" },
cancelled: { variant: "danger", dot: true, label: "Cancelled" }, draft: { tone: "neutral", dot: true, label: "Draft" },
completed: { tone: "success", dot: true, label: "Completed" },
cancelled: { tone: "danger", dot: true, label: "Cancelled" },
}; };
export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgeProps) { export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgeProps) {
const config = statusConfig[status] || statusConfig.inactive; const config = statusConfig[status] || statusConfig.inactive;
return ( return (
<AdminBadge variant={config.variant} dot={config.dot} className={className}> <AdminBadge tone={config.tone} dot={config.dot} className={className}>
{config.label} {config.label}
</AdminBadge> </AdminBadge>
); );
@@ -60,14 +168,25 @@ export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgePro
// Count badge (circular) // Count badge (circular)
type AdminCountBadgeProps = { type AdminCountBadgeProps = {
count: number; count: number;
tone?: BadgeTone;
/** @deprecated Use `tone` instead. */
variant?: BadgeVariant; variant?: BadgeVariant;
className?: string; className?: string;
}; };
export function AdminCountBadge({ count, variant = "default", className = "" }: AdminCountBadgeProps) { export function AdminCountBadge({
const { bg, text } = variantClasses[variant]; count,
tone,
variant = "default",
className = "",
}: AdminCountBadgeProps) {
const resolvedTone: BadgeTone = tone ?? variantToTone[variant];
const { bg, text } = toneStyles[resolvedTone];
return ( return (
<span className={`inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5 text-xs font-bold ${bg} ${text} ${className}`}> <span
className={`inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5 text-xs font-bold ${className}`}
style={{ backgroundColor: bg, color: text }}
>
{count} {count}
</span> </span>
); );
@@ -12,6 +12,12 @@ export { default as AdminActionMenu, AdminActionButton } from "./AdminActionMenu
export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination"; export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination";
export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge"; export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge";
// Phase 2 pattern primitives (admin-level, not in design-system/ folder)
// Re-exported here so callers can `import { KPIStat, EmptyState, LoadingState } from "@/components/admin/design-system"`.
export { default as KPIStat } from "../KPIStat";
export { default as EmptyState } from "../EmptyState";
export { default as LoadingState } from "../LoadingState";
// Design system components // Design system components
export { default as AdminButton, AdminIconButton } from "./AdminButton"; export { default as AdminButton, AdminIconButton } from "./AdminButton";
export { default as PageHeader } from "./PageHeader"; export { default as PageHeader } from "./PageHeader";
@@ -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 */}
+1 -1
View File
@@ -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 }[];
}; };
+10 -12
View File
@@ -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",
+35 -16
View File
@@ -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; }
} }
+14 -18
View File
@@ -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;
+6 -11
View File
@@ -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",
}} }}
+1 -1
View File
@@ -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&apos;re on the list! You&apos;re on the list!
</h3> </h3>
<p className="text-[#6b8f71]"> <p className="text-[#6b8f71]">
+15 -2
View File
@@ -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,10 +478,20 @@ 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>
);
}
-9
View File
@@ -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