51 Commits

Author SHA1 Message Date
Tyler 7e079e0186 Show success banner after sending a password-reset email
Deploy to route.crispygoat.com / deploy (push) Successful in 4m8s
The 'Send Reset Email' button in /admin/users previously just
cleared the error banner on success, with no actual 'yes it
worked' feedback. Added a green success banner that mirrors the
error banner's style and auto-dismisses after 6 seconds.

The 'Reset Password' button already shows confirmation in the
modal (temp password to copy, or 'reset email sent' message), so
it doesn't need the banner.

Also tightened the type narrowing in the resetAdminPassword unit
tests — the discriminated union needed a two-step
narrow (`r.success` then `r.method`) before TypeScript would
allow access to variant-specific fields like `tempPassword`.
2026-06-17 12:31:38 -06:00
Tyler eb37df347e Fix admin password reset (Send Reset Email + Reset Password)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m14s
Both buttons in /admin/users were broken:

- "Send Reset Email" called a no-op stub in
  src/actions/admin/users.ts that always returned an error.

- "Reset Password" called resetAdminPassword with a hard-coded
  'Tuxedo2026!' password, and the function itself queried a
  non-existent `users` table and called a non-existent
  `update_user_password` RPC (leftover Supabase-era code).

Rewritten against Neon Auth:

- sendPasswordResetEmail(email) — platform_admin-only action that
  calls auth.requestPasswordReset with the configured
  NEXT_PUBLIC_SITE_URL + '/reset-password' redirect. Always returns
  a clear success/error.

- resetAdminPassword(email) — platform_admin-only action that:
  1. Looks up neon_auth.user by email
  2. Generates a strong server-side random temp password
  3. Tries auth.admin.setUserPassword first (instant credential,
     returned to the UI for the platform admin to share)
  4. On FORBIDDEN / UNAUTHORIZED / INTERNAL_SERVER_ERROR (the common
     case — provision-admin.ts does not promote callers to
     role='admin' in Neon Auth), falls back to
     auth.requestPasswordReset, which sends a reset link the user
     can click to set their own password.
  5. On the privileged path, flips admin_users.must_change_password
     so the user is forced to pick a real password on next sign-in.

UI updated to handle the new response shape (success-with-temp-
password vs success-with-reset-email-sent) with a fourth modal
state.

Tests: 19 new unit tests across both paths cover authz, input
handling, the privileged happy path, the FORBIDDEN/UNAUTHORIZED/
network-throw fallback, USER_NOT_FOUND surfacing, and the
'both paths fail' case. Full suite: 110/113 (3 pre-existing
getAdminUser failures unchanged).
2026-06-17 12:22:34 -06:00
Tyler 7e665ea43e Email service surfaces real Resend errors instead of silent false
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
The sendEmail / sendCampaignEmail / sendWelcomeEmail /
sendOrderReceiptEmail / sendPasswordResetEmail / sendOperationalAlert
helpers all returned Promise<boolean> and silently returned false on
any failure (missing API key, 401 invalid key, 422 unverified sender
domain, network error, etc.). The caller had no way to know which
problem it was.

Switch the public return type to a structured result:

  export type EmailSendResult = { ok: true } | { ok: false; error: string };

- Missing RESEND_API_KEY: error reads 'RESEND_API_KEY is not set.
  Add it to .env.local (or your hosting dashboard) and restart the
  server.'
- 4xx/5xx: error reads 'Resend 422 — validation_error: The gmail.com
  domain is not verified' (extracts Resend's own name + message).
- Network failure: error reads the thrown message verbatim.
- Non-JSON error body: falls back to 'Resend <status>'.

Callers updated:
  - src/actions/admin/users.ts (createAdminUser): now propagates the
    real emailError into the modal UI — no more generic 'sendWelcome
    Email returned false'.
  - src/actions/checkout.ts: logs the real error.
  - src/app/api/cron/send-scheduled/route.ts: per-recipient error
    logged for the scheduled-campaigns cron.

Tests:
  - tests/unit/email-service.test.ts (new, 5 tests): covers happy
    path, missing key, 4xx/5xx with JSON body, 4xx/5xx with non-JSON
    body, and network failure.
  - tests/unit/create-admin-user.test.ts: updated to use the new
    { ok, error? } return shape; new assertion that the action
    surfaces the real emailError string into the result.
2026-06-17 12:10:04 -06:00
Tyler 9d9bc5d257 Add 'Sign in with Google' button to /login and /wholesale/login
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
Wires up Better Auth's signIn.social({ provider: 'google' }) so users
can authenticate via Google OAuth. The flow is:

  1. User clicks the Google button.
  2. Client calls the signInWithGoogleAction server action.
  3. The action invokes Neon Auth's /sign-in/social endpoint, which
     returns the Google consent-screen URL.
  4. Client navigates the browser to that URL.
  5. Google redirects back through Neon Auth to the callbackURL.

Files:
  - src/actions/auth-actions.ts: new signInWithGoogleAction server
    action that wraps signIn.social and returns the redirect URL
    to the client. Structured { url, error } return — never throws.
  - src/app/login/LoginClient.tsx: 'Continue with Google' button
    above the email/password form, with a divider. Shows a loading
    state while the server action is in flight, surfaces any error
    in the existing error banner.
  - src/app/wholesale/login/page.tsx: same button for wholesale
    buyers. Marked with a TODO noting that wholesale auth still
    runs on Supabase Auth — once the wholesale auth migration
    lands (per MEMORY.md 'What's left'), the button will start
    working for them. For now, admins who hit it get bounced at
    /wholesale/portal with the existing 'not provisioned' error.
  - tests/unit/sign-in-with-google.test.ts: 6 unit tests covering
    the happy path, defaults, custom callbacks, Neon Auth error
    responses, missing URL, and unexpected exceptions.

Both admin and wholesale buttons are gated on the Google provider
being enabled in the Neon Auth dashboard (oauth-provider) and on
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET being set in the runtime
env — both are already wired through .gitea/workflows/deploy.yml.
2026-06-17 11:54:06 -06:00
Tyler d75380eb9a Create-user flow now provisions the Neon Auth account
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
Previously createAdminUser only inserted a local admin_users row and
emailed the password as plaintext — the user could not sign in because
the password was never set on a Neon Auth account.

This change makes the create flow:
  1. Authorize: only platform_admin can mint new admin users.
  2. Create the Neon Auth user via auth.admin.createUser, falling back
     to the public /sign-up/email + emailVerified=true pattern when
     the caller's Neon Auth session isn't an admin (the common case
     in dev — provision-admin.ts does not set neon_auth.user.role).
  3. Wrap the admin_users INSERT + admin_user_brands link in a
     transaction, returning an error if the local insert fails (the
     Neon Auth user is left orphaned and surfaced in the message).
  4. Send the welcome email best-effort, returning success/failure
     info to the UI.

The CreateUserModal now shows a success state with the temp password
(copy-to-clipboard), the welcome email status, and the auth path
used. The slide-in edit panel surfaces the password via window.alert
as a defense against the dead-code new-user path.

10 new unit tests cover authorization, the admin + signup paths, the
DB-failure orphan case, and the email best-effort behavior.
2026-06-17 11:46:39 -06:00
Tyler 11cd2fd01a Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Completes the Tuxedo Water Log feature end-to-end. Builds on the existing
water_* tables in 0001_init.sql; extends them with 0090 and rewrites the
server actions, settings page, export route, and admin panel to actually
function.

Schema (db/migrations/0090_water_log_completion.sql, db/schema/water-log.ts)
- headgate_token + status + max_flow_gpm + thresholds + notes on water_headgates
- role + phone + notes on water_irrigators
- method + total_gallons + photo_url + logged_date + lat/lon on water_log_entries
- new tables: water_admin_settings, water_admin_sessions, water_audit_log, water_alert_log

Auth (src/lib/water-log-pin.ts, src/lib/water-log-audit.ts)
- scrypt N=16384 per-PIN random salt, self-describing hash format
- weak-PIN detection (repeats, monotonic, palindromes)
- 200ms delay + timingSafeEqual on failed verify
- 4-8 digit PINs, generatePin() rejects weak patterns

Server actions (src/actions/water-log/{admin,field,settings}.ts)
- full Drizzle CRUD: headgates, irrigators, entries (with audit hooks)
- verifyPin + 8h field sessions, admin PIN + configurable 1-168h sessions
- regenerateAdminPin invalidates existing admin sessions
- threshold breach detection + alert log writes
- getWaterAdminSession() helper for admin pages

API routes
- /api/water-admin-auth: PIN-protected, generic error (no enum leak)
- /api/water-logs/export: admin-gated JSON/CSV, correct headers

UI (Field Almanac — cream + forest, Fraunces display, § section markers)
- src/components/admin/WaterLogAdminPanel.tsx: full rewrite
- src/components/water/WaterFieldClient.tsx: matches palette
- src/components/water/icons/{WaterGauge,SeasonMark}.tsx: custom SVGs
- src/app/admin/water-log/settings/page.tsx: full rewrite, Field Almanac

Tests
- tests/unit/water-log-pin.test.ts (21 cases): validate, hash, verify, generate
- tests/unit/water-log-reporting.test.ts (31 cases): season, filter, CSV, report
- tests/water-log.spec.ts (Playwright E2E): PIN form, auth gates, API gates

Docs
- docs/water-log.md: full admin guide, security model, data dictionary, checklist
- README: link to docs/water-log.md in admin modules list
- .env.example: Water Log section comment

Type-check: clean. Tests: 70 pass, 3 fail (3 pre-existing getAdminUser
failures on main, unrelated to this work).
2026-06-17 11:36:00 -06:00
Tyler 52c8a71cd0 MEMORY: note admin_users columns added in 0043 + vestigial flags
Deploy to route.crispygoat.com / deploy (push) Successful in 4m0s
2026-06-17 11:23:13 -06:00
Tyler e5db66e74a Add admin_users columns needed by create-user flow
Deploy to route.crispygoat.com / deploy (push) Successful in 4m27s
The create-user action in src/actions/admin/users.ts was written
against a richer admin_users schema than the one that landed in
0001_init.sql. The 0001 schema has just `name` plus the role-derived
can_manage_<X> columns; the action also references display_name,
phone_number, brand_id, can_manage_pickup/messages/refunds/users,
active, must_change_password, auth_provider, auth_subject, and
last_login. Result: 'column "display_name" of relation
"admin_users" does not exist' on any create-user submit.

Migration 0043 adds the missing columns with sensible defaults
(ADD COLUMN IF NOT EXISTS, so re-runnable). The four extra
can_manage_* flags are vestigial — getAdminUser() in
lib/admin-permissions.ts derives per-user permissions from the role
via permissionsForRole() and never reads them — but they exist so
the create-user form's per-user toggles persist, and the migration
header documents the cleanup target.

The db/schema/brands.ts Drizzle adminUsers definition is extended
in lockstep so the inferred TS types stay in sync with the table.
brand_id is left as a denormalized column (the link table
admin_user_brands is the source of truth and is what
getAdminUser() reads); getAdminUsers() in the action joins on
au.brand_id so keeping the column avoids rewriting the SELECT.

Gitea CI runs scripts/migrate.js before the build, so this lands
on prod automatically on the next push.
2026-06-17 11:22:43 -06:00
Tyler 365609d518 Restyle /admin/shipping to match the admin design system
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
- page.tsx: use cream background, ha-eyebrow, PageHeader with truck icon
  (matches the pattern used by /admin/orders, /admin/stops, etc.)
- ShippingFulfillmentPanel.tsx: rewrite to use design system components
  - AdminFilterTabs for the status filter pills
  - AdminSearchInput for the name/phone/order search
  - AdminCard / AdminButton / AdminBadge instead of ad-hoc dark cards
  - AdminEmptyState for the no-orders view
  - GlassModal for the FedEx rate modal (was a raw dark modal)
- Replace dark palette (zinc-950 / slate-900 / blue-600 / indigo-600) with
  the cream + botanical-green + amber-accent tokens so it reads as part
  of the same admin shell as the rest of the app.
2026-06-17 11:11:08 -06:00
Tyler a6df4c2dd9 Fix shipping + dashboard + analytics queries against new schema
Deploy to route.crispygoat.com / deploy (push) Successful in 4m48s
The /admin/shipping page was 500-ing with 'column c.name does not
exist' because the getShippingOrders query in src/actions/shipping.ts
was still using legacy column names on the new-schema customers
table (name, email, phone) — the actual columns are first_name,
last_name, primary_email, primary_phone.

The dashboard and analytics pages were logging the same kind of
errors but catching them, so the UI just showed zeroed stats instead
of crashing. Same fix:

- customers: name → first_name || ' ' || last_name
- customers: email → primary_email
- customers: phone → primary_phone
- orders: subtotal → total_cents / 100
- orders: created_at → placed_at
- orders: customer_name → join customers and concat first/last

These are the same kind of fixes that landed in migration 0041 for
the command-center RPCs. The application-layer queries just hadn't
been updated.
2026-06-17 11:00:34 -06:00
Tyler 4b781d3c76 Disable named view transition on admin page-content wrapper
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
The <ViewTransition name="page-content"> wrapper around the admin
main content was triggering a 'shared element' morph on every page
load and navigation: the browser tries to animate the bounding box
of the named element across the old and new pages, and since each
admin page has a different height, the browser was scaling the
snapshot from the top-left corner. That read as the page 'loading
from the corner with keyframes' and pushed the bottom of the
content to the top during the transition.

Setting update="none" opts out of the named transition entirely.
The children just swap on navigation — no fade, no scale, no morph.
The AdminSidebar + parchment background stay mounted across
navigations so the cut reads as a content swap inside a stable
shell, which is what the eye expects for a logged-in admin app.

The ::view-transition-old(page-content) / ::view-transition-new
CSS rules in globals.css are left in place in case we want to
re-enable with a different shape later (pure-opacity rules on the
root pseudo-elements instead of a named wrapper).
2026-06-17 10:40:46 -06:00
Tyler 0a534222e8 Remove command-center page and dependencies
Deploy to route.crispygoat.com / deploy (push) Successful in 4m20s
The /admin/command-center route was platform_admin-only, used a heavy
custom theme (Major Mono / JetBrains / Inter Tight + ~700 lines of
schematic CSS), called three RPCs, and overlapped with the per-brand
dashboards reachable at /admin, /admin/orders, /admin/stops, and
/admin/reports. The page is being removed; it didn't justify the
build complexity, font payload, or schema surface area.

Changes:
- Drop page, dashboard component, CSS, and server actions
- Remove the platform_admin nav entry from header + sidebar
- Remove the cmd+K palette entry
- Add migration 0042 to drop the 3 RPCs, founder_pain_log table,
  and founder_pain_log_platform view it owned
- Decrement route count in docs/pricing-assessment.md (88 -> 87)

Verification: tsc clean, eslint clean, npm run build clean, command-
center absent from the route list.
2026-06-17 10:18:33 -06:00
Tyler 3d4b98d703 fix command-center RPCs against prod schema and setof call
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
Migration 0040 was written against an assumed schema and referenced
columns that don't exist in the prod orders table:
  - orders.created_at  → use orders.placed_at
  - orders.subtotal    → use orders.total_cents / 100
  - stops.date regex   → column is already DATE, not TEXT, drop the
                         ~ '^\d{4}-...$' check

Migration 0041 fixes both broken functions in place via
CREATE OR REPLACE FUNCTION.

The action layer at src/actions/platform/command-center.ts used
SELECT fn() AS "fn" for all platform RPCs, but two of the three
return TABLE(...) (setof) — the correct syntax is SELECT * FROM fn().
The platformRPC<T> helper now takes a kind: 'scalar' | 'setof' flag
and emits the right SQL.

Verified on prod:
  METRICS:    { orders_today:0, active_brands:1, active_routes:538, ... }
  BRAND_HEALTH: 1 row (Tuxedo Corn), 538 active_stops, 0 failed, healthy schema
  ACTIVITY:   0 rows (operational_events has no brand_id in payload, no error)
2026-06-17 10:02:46 -06:00
Tyler 4f22da65d8 redesign command center as operations schematic
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
Aesthetic shift from generic dark glassmorphism to an industrial
mission-control look: phosphor amber on near-black, Major Mono Display
for the wordmark, JetBrains Mono for all labels/numbers/codes, Inter
Tight for body copy.

- Top status bar with live clock, pulsing LIVE LED, and scrolling brand
  ticker (plan tiers + slugs)
- Serial-numbered sections (// 01-07) with CAD-style corner crosshairs
  and scale-rule dividers
- 6 KPIs in a flush 1px-separated grid with tabular numerals, sparklines,
  and per-cell M.0N serials
- AI briefing as a numbered readout with model metadata sidebar
- Brand health panels with severity LEDs, vertical dividers between
  metrics, and open-pain indicators
- Activity feed as terminal-style rows with color-coded event codes
- Pain log items with severity LEDs and P.NNN serials
- Quick Access links with L.NN prefixes and slide-on-hover
- Loading state replaced with terminal-style blinking bar + 'BOOT
  SEQUENCE' eyebrow
- 'Connection Lost' replaced with 'SIGNAL LOST · Platform Disconnected'
  panel that surfaces the actual error, reconnect action, and a
  diagnostic hint footer
- prefers-reduced-motion respected across ticker, LED pulse, and
  boot-reveal animations
- TV Mode scales up the title, KPIs, and briefing for wall display
2026-06-17 09:28:44 -06:00
Tyler 9da9c8b6e0 ci(deploy): add workflow_dispatch trigger for manual runs
Deploy to route.crispygoat.com / deploy (push) Successful in 3m47s
Lets the migration (and the rest of the deploy) be triggered on demand
from the Gitea UI instead of waiting for a push to main.
2026-06-17 09:05:06 -06:00
Tyler 5902d2b360 fix(admin): restore command center — add missing founder_pain_log table/view + 3 RPCs
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
The /admin/command-center page calls 3 RPCs and a table that only existed in
the archived Supabase migrations (126/127). The active db/migrations/ directory
had no replacement, so prod was missing these objects and the page threw
'Connection Lost' on every load (fetchAll() Promise.all rejects on missing RPCs).

Un-archives the original SQL into a single new migration (0040_command_center.sql)
with idempotent CREATE OR NOT EXISTS / CREATE OR REPLACE so it's safe to apply
to DBs that may already have some of the objects.
2026-06-17 09:03:49 -06:00
Tyler 36e13ba7fa motion: calm admin tab/drawer/popover/toast transitions
Deploy to route.crispygoat.com / deploy (push) Successful in 4m15s
Follow-up to 9fcc514. The global motion pass hit the public site;
this targets the admin design system, which had its own animation
budget that was still producing positional movement on every
interaction.

Killed:
- TabSwitcher y:4 slide (every tab change in /admin)
- CommandPalette scale(0.98) panel pop
- ha-drawer-in: 100% translateX (full-screen sweep)
- ha-popover-in: translateY(-4px) + scale(0.98)
- ha-check-pop: cubic-bezier(0.34, 1.56, 0.64, 1) 1.15x overshoot
- slide-in-from-right: 100% translateX (toasts)
- dashboard-fade-up: 10px Y slide on every card
- tailwindcss-animate keyframes for slide-in-from-{left,right,top,bottom} and zoom-in-{90,95} (used by ToastContainer, LotDetailPanel)

Calmed:
- All durations cut to 120-200ms range with ease-out
- All keyframes that survived are opacity-only

Added a @media (prefers-reduced-motion: reduce) block scoped to
the admin selectors so users with that OS setting see instant
transitions across toasts/drawers/popovers/dashboard cards.
2026-06-17 08:48:17 -06:00
Tyler 9fcc514045 motion: calm screen transitions to reduce motion sickness
Deploy to route.crispygoat.com / deploy (push) Successful in 4m30s
The site had a lot of aggressive motion that compounded into a
vertigo-inducing experience. Visual design (colors, type, layout) is
unchanged — only the movement has been calmed.

Single-commit overview:

- Route transitions: 90/140ms pure-opacity crossfade (was 220ms with
  4-6px Y-shift on enter/exit).
- atelier-* modal animations: 180ms opacity-only, 4px max (was 420ms
  with 20px slide + scale(0.96→1) and 80-440ms cumulative stagger).
- Hover transforms: -1px lift or no lift (was -2px + scale(0.98)).
- CTA shimmer: 400ms (was 700ms).
- Toggle thumb: ease-out (was cubic-bezier(0.34, 1.56, 0.64, 1) bouncy
  overshoot).
- GSAP ScrollAnimations: capped at 12-16px translation, 320ms duration,
  power1.out, reduced-motion guards at the top of every effect.
  ParallaxLayer no longer scrolls content; only the data-parallax
  attribute can opt in, and only to 24px.
- TuxedoVideoHero: killed 80px scroll-driven Y-shift on hero, killed
  video 1.15 scale-on-scroll, killed parallax-float scroll effect, cut
  hero-reveal to 8px/320ms (was 40px/1s/power3.out), removed the
  motion.scale on the logo and CTA buttons, slowed the bouncing
  scroll indicator from 1.5s to 2.4s.
- CinematicShowcase: killed morphing product cards (rotateY ±5°,
  scale 0.95→1.02), killed parallax background HARVEST text (-100px),
  killed translateX carousel, killed scale(0.9→1) back.out(1.4) reveal
  in favor of opacity-only 8px/320ms entrance, removed progress-dot
  scale, removed progress-bar transition lag.
- OnboardingFlow: removed scale(0.9→1) and y:20→0 entrance animations.
- Global MotionConfig: caps every framer-motion animation in the tree
  to 0.2s easeOut, and sets reducedMotion='user' so framer-motion
  automatically strips x/y/scale/rotate from all 71 motion.div reveals
  across the public site when the OS prefers-reduced-motion is set.
- globals.css prefers-reduced-motion block: comprehensive kill switch
  that disables animation/transition duration app-wide, wipes the
  route view-transition, and clears the .parallax-float / .hero-reveal
  transforms.

How to test:
- Default: motion is calmer, ~10x faster, with no parallax
- OS-level 'reduce motion' on: zero positional movement, opacity fades
  only.

Files changed: 7 (no new files)
2026-06-17 08:35:29 -06:00
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
159 changed files with 16064 additions and 6111 deletions
+11
View File
@@ -68,3 +68,14 @@ MINIO_BUCKET_WATER_LOGS=route-water-logs
# ── Cron / automation ─────────────────────────────────────────────────────── # ── Cron / automation ───────────────────────────────────────────────────────
CRON_SECRET=replace-me-with-a-random-string CRON_SECRET=replace-me-with-a-random-string
# ── Water Log ───────────────────────────────────────────────────────────────
# The Water Log module reuses the existing `DATABASE_URL` and (for photo
# uploads) the `MINIO_BUCKET_WATER_LOGS` bucket above. It also reuses the
# brand's Twilio / SMS config for high/low threshold alerts. The
# Tuxedo brand UUID is a public default used as a fallback for cold-start
# paths when the calling admin user is a platform_admin with brand_id=null.
# It does NOT need to be set as an env var — the value is hardcoded in
# the server actions — but you can override it with:
# TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de
# See docs/water-log.md for the full module guide.
+2 -1
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- main - main
workflow_dispatch:
jobs: jobs:
deploy: deploy:
@@ -176,7 +177,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
+13 -1
View File
@@ -2,7 +2,19 @@
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations. This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
**Last updated:** 2026-06 (migration reliability + Google sign-in work) **Last updated:** 2026-06 (admin_users schema fix + migration reliability + Google sign-in work)
## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043)
The 0001 schema's `admin_users` table has just `name` plus the role-derived `can_manage_*` columns (`can_manage_orders`, `can_manage_products`, `can_manage_stops`, `can_manage_customers`, `can_manage_wholesale`, `can_manage_billing`, `can_manage_settings`, `can_manage_water_log`, `can_manage_time_tracking`, `can_manage_route_trace`, `can_manage_reports`, `can_manage_communications`).
But `src/actions/admin/users.ts` was written against a richer schema: it INSERTs and SELECTs `display_name`, `phone_number`, `brand_id`, `can_manage_pickup/messages/refunds/users`, `active`, `must_change_password`, `auth_provider`, `auth_subject`, `last_login`. Submitting the create-user form produced `column "display_name" of relation "admin_users" does not exist`.
Migration `db/migrations/0043_admin_users_extra_columns.sql` adds all twelve missing columns (`ADD COLUMN IF NOT EXISTS`, re-runnable). `db/schema/brands.ts`'s Drizzle `adminUsers` definition was extended in lockstep.
**Important**: the four extra `can_manage_*` flags (pickup / messages / refunds / users) are **vestigial**`getAdminUser()` in `lib/admin-permissions.ts` derives per-user permissions from the role via `permissionsForRole()`, never reads those columns. They exist only so the create-user form's per-user permission toggles persist; they have no runtime authorization effect. Cleanup target: either (a) read them in the role-derived lookup to support real per-user overrides, or (b) drop them from the form.
The `brand_id` column on `admin_users` is a **denormalization** of the `admin_user_brands` link table (which is the source of truth, and is what `getAdminUser()` reads). The action's `getAdminUsers` joins on `au.brand_id` so keeping the column avoids rewriting that SELECT. Future cleanup can drop the denormalized column and migrate the SELECT to join through the link table.
## 2026-06: CI migration failures on re-deploy (0001_init.sql "already exists") ## 2026-06: CI migration failures on re-deploy (0001_init.sql "already exists")
+1 -1
View File
@@ -171,7 +171,7 @@ The admin dashboard lives at `/admin`:
- **Communications** — Harvest Reach campaign manager - **Communications** — Harvest Reach campaign manager
- **Wholesale** — Wholesale portal settings - **Wholesale** — Wholesale portal settings
- **Billing** — Plan and subscription management - **Billing** — Plan and subscription management
- **Water Log** — Irrigation tracking (add-on) - **Water Log** — Irrigation tracking (add-on) — see [docs/water-log.md](docs/water-log.md)
- **Settings** — Brand settings, payments, apps - **Settings** — Brand settings, payments, apps
## Email Automations (Harvest Reach) ## Email Automations (Harvest Reach)
+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;
+196
View File
@@ -0,0 +1,196 @@
-- Migration 0040: Platform command center — founder_pain_log table/view + RPCs
-- Restores objects that the /admin/command-center page depends on.
-- Original definitions were in archived Supabase migrations 126 + 127.
-- ── founder_pain_log table ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS founder_pain_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
severity TEXT NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
category TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ,
resolved_by UUID REFERENCES admin_users(id) ON DELETE SET NULL
);
-- ── founder_pain_log_platform view (joins brand name/slug for the UI) ────────
CREATE OR REPLACE VIEW founder_pain_log_platform AS
SELECT
fpl.id,
fpl.brand_id,
fpl.severity,
fpl.category,
fpl.title,
fpl.description,
fpl.status,
fpl.created_at,
fpl.resolved_at,
fpl.resolved_by,
b.name AS brand_name,
b.slug AS brand_slug
FROM founder_pain_log fpl
LEFT JOIN brands b ON b.id = fpl.brand_id
ORDER BY
CASE fpl.severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
END,
fpl.created_at DESC;
-- ── Platform-wide metrics ────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION get_platform_command_center_metrics()
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
result JSONB;
BEGIN
result := jsonb_build_object(
'active_brands', (
SELECT COUNT(*) FROM brands WHERE slug IN ('tuxedo', 'indian-river-direct')
),
'orders_today', (
SELECT COUNT(*) FROM orders
WHERE DATE(created_at) = CURRENT_DATE
),
'revenue_today', (
SELECT COALESCE(SUM(subtotal), 0)
FROM orders
WHERE DATE(created_at) = CURRENT_DATE
AND status NOT IN ('cancelled', 'refunded')
),
'active_routes', (
SELECT COUNT(DISTINCT s.id)
FROM stops s
WHERE s.date::DATE >= CURRENT_DATE
AND s.status = 'active'
AND s.date ~ '^\d{4}-\d{2}-\d{2}$'
),
'failed_orders_today', (
SELECT COUNT(*)
FROM orders
WHERE DATE(created_at) = CURRENT_DATE
AND status IN ('payment_failed', 'failed')
),
'pending_orders_today', (
SELECT COUNT(*)
FROM orders
WHERE DATE(created_at) = CURRENT_DATE
AND status = 'pending'
)
);
RETURN result;
END;
$$;
-- ── Recent activity feed (last 50 operational events across all brands) ──────
CREATE OR REPLACE FUNCTION get_platform_activity_feed()
RETURNS TABLE (
id UUID,
event_type TEXT,
entity_type TEXT,
entity_id UUID,
payload JSONB,
actor_type TEXT,
source TEXT,
created_at TIMESTAMPTZ,
brand_id UUID,
brand_name TEXT
)
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY
SELECT
oe.id,
oe.event_type,
oe.entity_type,
oe.entity_id,
oe.payload,
oe.actor_type::TEXT,
oe.source::TEXT,
oe.created_at,
(oe.payload->>'brand_id')::UUID AS brand_id,
b.name AS brand_name
FROM operational_events oe
LEFT JOIN brands b ON b.id = (oe.payload->>'brand_id')::UUID
ORDER BY oe.created_at DESC
LIMIT 50;
END;
$$;
-- ── Per-brand health snapshot ────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION get_brand_health_snapshot()
RETURNS TABLE (
brand_id UUID,
brand_name TEXT,
brand_slug TEXT,
plan_tier TEXT,
orders_today INT,
revenue_today NUMERIC,
active_stops INT,
failed_orders INT,
pending_orders INT,
last_activity TIMESTAMPTZ,
open_pain_items INT,
health_status TEXT
)
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY
WITH bm AS (
SELECT
b.id AS bid,
b.name AS bname,
b.slug AS bslug,
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE) AS ords_today,
SUM(o.subtotal) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status NOT IN ('cancelled', 'refunded')) AS rev_today,
COUNT(DISTINCT s.id) FILTER (
WHERE (s.date ~ '^\d{4}-\d{2}-\d{2}$') AND (s.date::DATE >= CURRENT_DATE) AND s.status = 'active'
) AS act_stops,
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status IN ('payment_failed', 'failed')) AS fail_ords,
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status = 'pending') AS pend_ords,
MAX(oe.created_at) AS last_act
FROM brands b
LEFT JOIN orders o ON o.brand_id = b.id
LEFT JOIN stops s ON s.brand_id = b.id
LEFT JOIN operational_events oe ON oe.payload->>'brand_id' = b.id::TEXT
WHERE b.slug IN ('tuxedo', 'indian-river-direct')
GROUP BY b.id, b.name, b.slug
),
pc AS (
SELECT f.brand_id AS pc_bid, COUNT(*) AS open_cnt
FROM founder_pain_log f
WHERE f.status = 'open'
GROUP BY f.brand_id
)
SELECT
bm.bid,
bm.bname,
bm.bslug,
NULL::TEXT,
COALESCE(bm.ords_today, 0)::INT,
COALESCE(bm.rev_today, 0)::NUMERIC,
COALESCE(bm.act_stops, 0)::INT,
COALESCE(bm.fail_ords, 0)::INT,
COALESCE(bm.pend_ords, 0)::INT,
bm.last_act,
COALESCE(pc.open_cnt, 0)::INT,
CASE
WHEN COALESCE(bm.fail_ords, 0) > 0 THEN 'critical'
WHEN COALESCE(bm.pend_ords, 0) > 5 THEN 'warning'
WHEN COALESCE(pc.open_cnt, 0) > 0 THEN 'warning'
WHEN bm.last_act IS NULL OR bm.last_act < (NOW() - INTERVAL '24 hours') THEN 'warning'
ELSE 'healthy'
END
FROM bm
LEFT JOIN pc ON pc.pc_bid = bm.bid;
END;
$$;
@@ -0,0 +1,147 @@
-- Migration 0041: Fix command-center RPCs against actual prod schema
--
-- Migration 0040 was written against an assumed schema and references columns
-- that don't exist in the prod `orders` table:
-- - `orders.created_at` does not exist → use `orders.placed_at`
-- - `orders.subtotal` does not exist → use `orders.total_cents / 100`
-- - `stops.date` is already DATE, not TEXT → drop the `~ '^\d{4}-...' ` regex
--
-- All three functions are fixed in place via CREATE OR REPLACE FUNCTION.
-- The action layer (src/actions/platform/command-center.ts) is updated
-- separately to use `SELECT * FROM fn()` for the two setof-returning functions.
--
-- Reference (prod schema as of 2026-06-17):
-- orders: id, brand_id, customer_id, stop_id, total_cents INT,
-- status TEXT, fulfillment TEXT, customer_*,
-- idempotency_key, notes, placed_at TIMESTAMPTZ, updated_at
-- stops: id, brand_id, name, location, address, city, state, zip,
-- date DATE, time, cutoff_date, status, is_public, notes,
-- created_at, updated_at
-- operational_events: id, brand_id, event_type, entity_type, entity_id,
-- actor_type, actor_id, source, payload JSONB, created_at
-- brands: (assumed present per CLAUDE.md)
-- ── Platform-wide metrics ──────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION get_platform_command_center_metrics()
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
result JSONB;
BEGIN
result := jsonb_build_object(
'active_brands', (
SELECT COUNT(*) FROM brands WHERE slug IN ('tuxedo', 'indian-river-direct')
),
'orders_today', (
SELECT COUNT(*) FROM orders
WHERE DATE(placed_at) = CURRENT_DATE
),
'revenue_today', (
SELECT COALESCE(SUM(total_cents), 0)::NUMERIC / 100
FROM orders
WHERE DATE(placed_at) = CURRENT_DATE
AND status NOT IN ('cancelled', 'refunded')
),
'active_routes', (
SELECT COUNT(DISTINCT s.id)
FROM stops s
WHERE s.date >= CURRENT_DATE
AND s.status = 'active'
),
'failed_orders_today', (
SELECT COUNT(*)
FROM orders
WHERE DATE(placed_at) = CURRENT_DATE
AND status IN ('payment_failed', 'failed')
),
'pending_orders_today', (
SELECT COUNT(*)
FROM orders
WHERE DATE(placed_at) = CURRENT_DATE
AND status = 'pending'
)
);
RETURN result;
END;
$$;
-- ── Per-brand health snapshot ──────────────────────────────────────────────
CREATE OR REPLACE FUNCTION get_brand_health_snapshot()
RETURNS TABLE (
brand_id UUID,
brand_name TEXT,
brand_slug TEXT,
plan_tier TEXT,
orders_today INT,
revenue_today NUMERIC,
active_stops INT,
failed_orders INT,
pending_orders INT,
last_activity TIMESTAMPTZ,
open_pain_items INT,
health_status TEXT
)
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY
WITH bm AS (
SELECT
b.id AS bid,
b.name AS bname,
b.slug AS bslug,
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE) AS ords_today,
(COALESCE(SUM(o.total_cents) FILTER (
WHERE DATE(o.placed_at) = CURRENT_DATE
AND o.status NOT IN ('cancelled', 'refunded')
), 0)::NUMERIC / 100) AS rev_today,
COUNT(DISTINCT s.id) FILTER (
WHERE s.date >= CURRENT_DATE AND s.status = 'active'
) AS act_stops,
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE AND o.status IN ('payment_failed', 'failed')) AS fail_ords,
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE AND o.status = 'pending') AS pend_ords,
MAX(oe.created_at) AS last_act
FROM brands b
LEFT JOIN orders o ON o.brand_id = b.id
LEFT JOIN stops s ON s.brand_id = b.id
LEFT JOIN operational_events oe
ON oe.payload->>'brand_id' = b.id::TEXT
AND oe.payload->>'brand_id' ~ '^[0-9a-fA-F-]{36}$'
WHERE b.slug IN ('tuxedo', 'indian-river-direct')
GROUP BY b.id, b.name, b.slug
),
pc AS (
SELECT f.brand_id AS pc_bid, COUNT(*) AS open_cnt
FROM founder_pain_log f
WHERE f.status = 'open'
GROUP BY f.brand_id
)
SELECT
bm.bid,
bm.bname,
bm.bslug,
NULL::TEXT,
COALESCE(bm.ords_today, 0)::INT,
COALESCE(bm.rev_today, 0)::NUMERIC,
COALESCE(bm.act_stops, 0)::INT,
COALESCE(bm.fail_ords, 0)::INT,
COALESCE(bm.pend_ords, 0)::INT,
bm.last_act,
COALESCE(pc.open_cnt, 0)::INT,
CASE
WHEN COALESCE(bm.fail_ords, 0) > 0 THEN 'critical'
WHEN COALESCE(bm.pend_ords, 0) > 5 THEN 'warning'
WHEN COALESCE(pc.open_cnt, 0) > 0 THEN 'warning'
WHEN bm.last_act IS NULL OR bm.last_act < (NOW() - INTERVAL '24 hours') THEN 'warning'
ELSE 'healthy'
END
FROM bm
LEFT JOIN pc ON pc.pc_bid = bm.bid;
END;
$$;
-- get_platform_activity_feed from 0040 is already correct against the prod
-- schema (operational_events.payload->>'brand_id' is a text→UUID cast that
-- nulls out on missing keys). No body change required.
@@ -0,0 +1,19 @@
-- Migration 0042: Drop command-center RPCs and founder_pain_log
--
-- The /admin/command-center page is being removed (see commit message).
-- All objects it depended on are also dropped:
-- - get_platform_command_center_metrics (JSONB-returning RPC)
-- - get_platform_activity_feed (TABLE-returning RPC)
-- - get_brand_health_snapshot (TABLE-returning RPC)
-- - founder_pain_log (table)
-- - founder_pain_log_platform (view, joins brand name onto founder_pain_log)
--
-- The platform_admin role has no other consumer of these objects. The
-- cross-brand KPIs and activity feed that the page showed are reachable
-- per-brand via /admin, /admin/orders, /admin/stops, and /admin/reports.
DROP VIEW IF EXISTS founder_pain_log_platform;
DROP FUNCTION IF EXISTS get_brand_health_snapshot();
DROP FUNCTION IF EXISTS get_platform_activity_feed();
DROP FUNCTION IF EXISTS get_platform_command_center_metrics();
DROP TABLE IF EXISTS founder_pain_log;
@@ -0,0 +1,70 @@
-- 0043_admin_users_extra_columns.sql
--
-- The application-layer admin user CRUD in `src/actions/admin/users.ts`
-- was written for a richer `admin_users` schema than the one that
-- landed in 0001_init.sql. The 0001 schema has just `name` plus the
-- role-derived flag columns (`can_manage_orders`, `can_manage_products`,
-- `can_manage_stops`, `can_manage_customers`, `can_manage_wholesale`,
-- `can_manage_billing`, `can_manage_settings`, `can_manage_water_log`,
-- `can_manage_time_tracking`, `can_manage_route_trace`,
-- `can_manage_reports`, `can_manage_communications`).
--
-- The action also references these columns, which are not in 0001:
--
-- display_name, phone_number, brand_id,
-- can_manage_pickup, can_manage_messages,
-- can_manage_refunds, can_manage_users,
-- active, must_change_password,
-- auth_provider, auth_subject, last_login
--
-- This migration adds all of them so the create-user, list-users,
-- update-user, and read-user flows work against the actual table.
-- Re-runnable: each ALTER uses ADD COLUMN IF NOT EXISTS.
--
-- Notes on the new columns:
--
-- - `display_name` is what the UI shows in the user list / "logged in
-- as" labels. The original `name` column from 0001 is left in place
-- (Drizzle `adminUsers.name` still maps to it; `getAdminUser()` reads
-- it) — `display_name` is the column the action's SQL touches so it
-- stays the writable surface for the create-user form. Both can
-- coexist; the next cleanup pass can collapse them.
--
-- - The four extra `can_manage_*` flags (pickup / messages / refunds /
-- users) are not consulted by `getAdminUser()` — that lookup uses
-- `permissionsForRole(role)` from `lib/admin-permissions.ts`, which
-- derives flags from the user's role rather than from these
-- columns. They are kept on the row so the create-user form's
-- per-user permission toggles can persist; they simply do not yet
-- affect runtime authorization. Cleanup target: either move them into
-- a per-user permission table that the role-derived lookup merges
-- in, or drop them from the form.
--
-- - `brand_id` is a denormalization of `admin_user_brands` (the link
-- table is the source of truth for brand assignment, and
-- `getAdminUser()` reads from it). The action writes both, which is
-- redundant but not incorrect — keeping it makes the action's
-- read-after-write (`getAdminUsers` joins on `au.brand_id`) work
-- without rewriting the SELECT.
--
-- - `must_change_password` defaults to FALSE at the column level; the
-- action sets it to TRUE on every create so the user is forced to
-- set a new password on first sign-in.
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS display_name TEXT,
ADD COLUMN IF NOT EXISTS phone_number TEXT,
ADD COLUMN IF NOT EXISTS brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS can_manage_pickup BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS can_manage_messages BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS can_manage_refunds BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS can_manage_users BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS auth_provider TEXT,
ADD COLUMN IF NOT EXISTS auth_subject TEXT,
ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ;
-- Helpful index for the brand-name lookup the user list does
-- (LEFT JOIN brands b ON b.id = au.brand_id).
CREATE INDEX IF NOT EXISTS admin_users_brand_id_idx ON admin_users (brand_id);
+172
View File
@@ -0,0 +1,172 @@
-- 0090_water_log_completion.sql
--
-- Water Log feature completion. The original `0001_init.sql` shipped the
-- core five tables (headgates, irrigators, sessions, log entries, alerts)
-- with RLS, but the SaaS rebuild's actions and UIs depend on a wider
-- surface that this migration adds:
--
-- - `water_headgates.headgate_token` — opaque per-gate token for QR
-- - `water_headgates.status` — open / closed / maintenance
-- - `water_headgates.max_flow_gpm` — optional high-water marker
-- - `water_headgates.notes` — free-form location notes
-- - `water_headgates.last_used_at` — denormalized for display
-- - `water_headgates.high_threshold` — optional alert ceiling
-- - `water_headgates.low_threshold` — optional alert floor
-- - `water_headgates.unit` — display unit (CFS/GPM/etc.)
--
-- - `water_irrigators.role` — "irrigator" | "water_admin"
-- - `water_irrigators.phone` — optional contact
-- - `water_irrigators.notes` — free-form
--
-- - `water_log_entries.method` — "manual" | "meter" | "estimate" | "qr"
-- - `water_log_entries.total_gallons` — derived when computable
-- - `water_log_entries.photo_url` — link to storage object
-- - `water_log_entries.logged_date` — date-only (YYYY-MM-DD) for fast grouping
-- - `water_log_entries.latitude`/`longitude` — optional GPS pin
-- - `water_log_entries.brand_id` index upgrade
--
-- - NEW `water_admin_settings` (per-brand admin PIN + alert config)
-- - NEW `water_admin_sessions` (admin sign-in sessions, separate cookie)
-- - NEW `water_audit_log` (who changed what, when)
--
-- All new tables follow project conventions:
-- - TIMESTAMPTZ for timestamps
-- - UUID PKs via gen_random_uuid()
-- - brand_id scoped, RLS enabled, FORCE'd
-- - CREATE TABLE IF NOT EXISTS for re-runnability
--
-- ============================================================================
-- ─── 1. Extend water_headgates ──────────────────────────────────────────────
ALTER TABLE water_headgates
ADD COLUMN IF NOT EXISTS headgate_token TEXT UNIQUE
DEFAULT encode(gen_random_bytes(12), 'hex'),
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'closed', 'maintenance')),
ADD COLUMN IF NOT EXISTS max_flow_gpm NUMERIC,
ADD COLUMN IF NOT EXISTS notes TEXT,
ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS high_threshold NUMERIC,
ADD COLUMN IF NOT EXISTS low_threshold NUMERIC,
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS';
-- Backfill any existing rows that pre-date the default expression
UPDATE water_headgates
SET headgate_token = encode(gen_random_bytes(12), 'hex')
WHERE headgate_token IS NULL;
-- ─── 2. Extend water_irrigators ─────────────────────────────────────────────
ALTER TABLE water_irrigators
ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'irrigator'
CHECK (role IN ('irrigator', 'water_admin')),
ADD COLUMN IF NOT EXISTS phone TEXT,
ADD COLUMN IF NOT EXISTS notes TEXT;
-- ─── 3. Extend water_log_entries ────────────────────────────────────────────
ALTER TABLE water_log_entries
ADD COLUMN IF NOT EXISTS method TEXT NOT NULL DEFAULT 'manual'
CHECK (method IN ('manual', 'meter', 'estimate', 'qr')),
ADD COLUMN IF NOT EXISTS total_gallons NUMERIC,
ADD COLUMN IF NOT EXISTS photo_url TEXT,
ADD COLUMN IF NOT EXISTS logged_date DATE,
ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION;
-- Backfill logged_date for any rows that pre-date the column
UPDATE water_log_entries
SET logged_date = (logged_at AT TIME ZONE 'UTC')::date
WHERE logged_date IS NULL AND logged_at IS NOT NULL;
-- Index for fast "today's entries" + "this week's entries" queries
CREATE INDEX IF NOT EXISTS water_log_entries_brand_date_idx
ON water_log_entries (brand_id, logged_date DESC);
CREATE INDEX IF NOT EXISTS water_log_entries_irrigator_idx
ON water_log_entries (irrigator_id, logged_at DESC);
-- ─── 4. water_admin_settings (per brand) ───────────────────────────────────
CREATE TABLE IF NOT EXISTS water_admin_settings (
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT true,
session_duration_hours INTEGER NOT NULL DEFAULT 4
CHECK (session_duration_hours BETWEEN 1 AND 168),
can_edit_entries BOOLEAN NOT NULL DEFAULT true,
can_delete_entries BOOLEAN NOT NULL DEFAULT true,
can_export_csv BOOLEAN NOT NULL DEFAULT true,
alert_phone TEXT,
alerts_enabled BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by UUID REFERENCES admin_users(id)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'water_admin_settings_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER water_admin_settings_updated_at BEFORE UPDATE ON water_admin_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
-- ─── 5. water_admin_sessions (separate from irrigator sessions) ───────────
CREATE TABLE IF NOT EXISTS water_admin_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
pin_hash_used TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS water_admin_sessions_admin_idx
ON water_admin_sessions (admin_user_id, expires_at DESC);
-- ─── 6. water_audit_log (who changed what, when) ───────────────────────────
CREATE TABLE IF NOT EXISTS water_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
actor_id UUID REFERENCES admin_users(id),
actor_label TEXT NOT NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID,
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS water_audit_log_brand_recent_idx
ON water_audit_log (brand_id, created_at DESC);
-- ─── 7. RLS for new tables ────────────────────────────────────────────────
ALTER TABLE water_admin_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_admin_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_audit_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_admin_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE water_admin_sessions FORCE ROW LEVEL SECURITY;
ALTER TABLE water_audit_log FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON water_admin_settings;
CREATE POLICY tenant_isolation ON water_admin_settings FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON water_admin_sessions;
CREATE POLICY tenant_isolation ON water_admin_sessions FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON water_audit_log;
CREATE POLICY tenant_isolation ON water_audit_log FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
+17
View File
@@ -60,6 +60,23 @@ export const adminUsers = pgTable(
userId: uuid("user_id"), userId: uuid("user_id"),
email: text("email").notNull().unique(), email: text("email").notNull().unique(),
name: text("name"), name: text("name"),
// The columns below were added in migration 0043. They back the
// create-user / list-users / edit-user flows in
// `src/actions/admin/users.ts`. See that migration's header for
// notes on which of these are vestigial (the four can_manage_*
// toggles) vs. read by the UI.
displayName: text("display_name"),
phoneNumber: text("phone_number"),
brandId: uuid("brand_id"),
canManagePickup: boolean("can_manage_pickup").notNull().default(true),
canManageMessages: boolean("can_manage_messages").notNull().default(true),
canManageRefunds: boolean("can_manage_refunds").notNull().default(false),
canManageUsers: boolean("can_manage_users").notNull().default(false),
active: boolean("active").notNull().default(true),
mustChangePassword: boolean("must_change_password").notNull().default(false),
authProvider: text("auth_provider"),
authSubject: text("auth_subject"),
lastLogin: timestamp("last_login", { withTimezone: true }),
role: text("role", { role: text("role", {
enum: ["platform_admin", "brand_admin", "store_employee"], enum: ["platform_admin", "brand_admin", "store_employee"],
}).notNull().default("brand_admin"), }).notNull().default("brand_admin"),
+144 -2
View File
@@ -1,5 +1,15 @@
/** /**
* Water Log. Source: `db/migrations/0001_init.sql`. * Water Log. Source: `db/migrations/0001_init.sql` + `0090_water_log_completion.sql`.
*
* Six tables, all brand-scoped with RLS:
* - water_headgates — physical gates a measurement is tied to
* - water_irrigators — PIN-authenticated field workers
* - water_sessions — short-lived PIN sessions for irrigators
* - water_log_entries — the actual reading logs
* - water_alert_log — high/low threshold alert history
* - water_admin_settings — per-brand admin PIN + alert config
* - water_admin_sessions — admin sign-in sessions (separate cookie)
* - water_audit_log — who changed what, when
*/ */
import { import {
pgTable, pgTable,
@@ -8,7 +18,13 @@ import {
numeric, numeric,
boolean, boolean,
timestamp, timestamp,
date,
jsonb,
doublePrecision,
index, index,
uniqueIndex,
integer,
check,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { brands } from "./brands"; import { brands } from "./brands";
import { adminUsers } from "./brands"; import { adminUsers } from "./brands";
@@ -21,11 +37,29 @@ export const waterHeadgates = pgTable(
.notNull() .notNull()
.references(() => brands.id, { onDelete: "cascade" }), .references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(), name: text("name").notNull(),
/** Per-headgate opaque token used in the QR code. */
headgateToken: text("headgate_token").notNull().unique(),
/** Open / Closed / Maintenance. */
status: text("status").notNull().default("open"),
/** Display unit: CFS, GPM, Inches, AF/Day, etc. */
unit: text("unit").notNull().default("CFS"),
/** Optional max-flow marker in GPM. */
maxFlowGpm: numeric("max_flow_gpm"),
/** High-water alert threshold (units match `unit`). */
highThreshold: numeric("high_threshold"),
/** Low-water alert threshold (units match `unit`). */
lowThreshold: numeric("low_threshold"),
notes: text("notes"),
active: boolean("active").notNull().default(true), active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
}, },
(t) => ({
brandIdx: index("water_headgates_brand_idx").on(t.brandId),
tokenIdx: uniqueIndex("water_headgates_token_idx").on(t.headgateToken),
}),
); );
export const waterIrrigators = pgTable( export const waterIrrigators = pgTable(
@@ -40,12 +74,19 @@ export const waterIrrigators = pgTable(
languagePreference: text("language_preference") languagePreference: text("language_preference")
.notNull() .notNull()
.default("en"), .default("en"),
/** "irrigator" submits entries only, "water_admin" can manage the brand. */
role: text("role").notNull().default("irrigator"),
phone: text("phone"),
notes: text("notes"),
active: boolean("active").notNull().default(true), active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }), lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
}, },
(t) => ({
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
}),
); );
export const waterSessions = pgTable( export const waterSessions = pgTable(
@@ -75,10 +116,20 @@ export const waterLogEntries = pgTable(
irrigatorId: uuid("irrigator_id") irrigatorId: uuid("irrigator_id")
.notNull() .notNull()
.references(() => waterIrrigators.id, { onDelete: "cascade" }), .references(() => waterIrrigators.id, { onDelete: "cascade" }),
/** Raw measurement value, in the entry's `unit`. */
measurement: numeric("measurement").notNull(), measurement: numeric("measurement").notNull(),
unit: text("unit").notNull(), unit: text("unit").notNull(),
/** "manual" | "meter" | "estimate" | "qr" */
method: text("method").notNull().default("manual"),
/** Optional auto-computed total (in gallons) when CFS × duration is known. */
totalGallons: numeric("total_gallons"),
notes: text("notes"), notes: text("notes"),
submittedVia: text("submitted_via").notNull().default("app"), submittedVia: text("submitted_via").notNull().default("app"),
photoUrl: text("photo_url"),
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
/** Date-only mirror of loggedAt for fast grouping / dashboard queries. */
loggedDate: date("logged_date"),
loggedAt: timestamp("logged_at", { withTimezone: true }) loggedAt: timestamp("logged_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
@@ -87,6 +138,14 @@ export const waterLogEntries = pgTable(
(t) => ({ (t) => ({
brandIdx: index("water_log_entries_brand_idx").on(t.brandId), brandIdx: index("water_log_entries_brand_idx").on(t.brandId),
headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId), headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId),
brandDateIdx: index("water_log_entries_brand_date_idx").on(
t.brandId,
t.loggedDate,
),
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
t.irrigatorId,
t.loggedAt,
),
}), }),
); );
@@ -98,7 +157,9 @@ export const waterAlertLog = pgTable(
.notNull() .notNull()
.references(() => brands.id, { onDelete: "cascade" }), .references(() => brands.id, { onDelete: "cascade" }),
alertType: text("alert_type").notNull(), alertType: text("alert_type").notNull(),
headgateId: uuid("headgate_id").references(() => waterHeadgates.id), headgateId: uuid("headgate_id").references(() => waterHeadgates.id, {
onDelete: "set null",
}),
message: text("message").notNull(), message: text("message").notNull(),
sentTo: text("sent_to"), sentTo: text("sent_to"),
sentAt: timestamp("sent_at", { withTimezone: true }), sentAt: timestamp("sent_at", { withTimezone: true }),
@@ -108,8 +169,89 @@ export const waterAlertLog = pgTable(
}, },
); );
export const waterAdminSettings = pgTable("water_admin_settings", {
brandId: uuid("brand_id")
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
/** Hashed admin PIN (scrypt $ N$r$p$salt$hash format). */
pinHash: text("pin_hash"),
enabled: boolean("enabled").notNull().default(true),
sessionDurationHours: integer("session_duration_hours")
.notNull()
.default(4),
canEditEntries: boolean("can_edit_entries").notNull().default(true),
canDeleteEntries: boolean("can_delete_entries").notNull().default(true),
canExportCsv: boolean("can_export_csv").notNull().default(true),
alertPhone: text("alert_phone"),
alertsEnabled: boolean("alerts_enabled").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedBy: uuid("updated_by").references(() => adminUsers.id),
});
export const waterAdminSessions = pgTable(
"water_admin_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
adminUserId: uuid("admin_user_id")
.notNull()
.references(() => adminUsers.id, { onDelete: "cascade" }),
pinHashUsed: text("pin_hash_used").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
adminIdx: index("water_admin_sessions_admin_idx").on(
t.adminUserId,
t.expiresAt,
),
}),
);
export const waterAuditLog = pgTable(
"water_audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
actorId: uuid("actor_id").references(() => adminUsers.id),
actorLabel: text("actor_label").notNull(),
action: text("action").notNull(),
entityType: text("entity_type").notNull(),
entityId: uuid("entity_id"),
details: jsonb("details"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandRecentIdx: index("water_audit_log_brand_recent_idx").on(
t.brandId,
t.createdAt,
),
}),
);
// ── Inferred types (used by every action and client component) ────────────
export type WaterHeadgate = typeof waterHeadgates.$inferSelect; export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
export type WaterIrrigator = typeof waterIrrigators.$inferSelect; export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
export type WaterSession = typeof waterSessions.$inferSelect; export type WaterSession = typeof waterSessions.$inferSelect;
export type WaterLogEntry = typeof waterLogEntries.$inferSelect; export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
export type WaterLogEntryInsert = typeof waterLogEntries.$inferInsert;
export type WaterAlertLog = typeof waterAlertLog.$inferSelect; export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;
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.
+1 -1
View File
@@ -12,7 +12,7 @@ A **multi-tenant B2B produce distribution platform**, not a SaaS starter. Build
|---|---| |---|---|
| Migrations | **135 SQL files** (production schema, RLS, SECURITY DEFINER RPCs, audit logs) | | Migrations | **135 SQL files** (production schema, RLS, SECURITY DEFINER RPCs, audit logs) |
| Source | **544 files** (362 .tsx + 179 .ts) | | Source | **544 files** (362 .tsx + 179 .ts) |
| Admin pages | **88 routes** across orders, products, stops, wholesale, communications, time-tracking, water-log, route-trace, reports, advanced, command-center | | Admin pages | **87 routes** across orders, products, stops, wholesale, communications, time-tracking, water-log, route-trace, reports, advanced |
| API routes | **60+ endpoints** (Stripe + Square + FedEx + Resend + Twilio + 8 AI endpoints) | | API routes | **60+ endpoints** (Stripe + Square + FedEx + Resend + Twilio + 8 AI endpoints) |
| Public surfaces | 2 brand storefronts (Tuxedo, Indian River Direct) with brand theming, hero, color customization | | Public surfaces | 2 brand storefronts (Tuxedo, Indian River Direct) with brand theming, hero, color customization |
| Integrations | Stripe (Connect + subscriptions), Square (POS sync), FedEx (rates + labels), Resend (email), Twilio (SMS), OpenAI (8 AI tools) | | Integrations | Stripe (Connect + subscriptions), Square (POS sync), FedEx (rates + labels), Resend (email), Twilio (SMS), OpenAI (8 AI tools) |
@@ -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
+337
View File
@@ -0,0 +1,337 @@
# Water Log
A standalone irrigation / water-usage tracker for ditch riders, water
admins, and farm operators. Tracks flow measurements against physical
headgates, attributes them to named water users, and rolls them up for
reporting.
The module is **PIN-based** and lives entirely outside the platform
admin auth: an irrigator with a 4-digit PIN can submit entries from a
phone in the field without an account on the platform. Site admins
manage headgates, users, and settings from `/admin/water-log`.
## When to use this
- You run irrigation ditches / headgates and need to track daily
flow with named operators.
- You need a mobile-friendly entry surface that works in low
connectivity (no login, just a PIN).
- You want a per-brand, brand-scoped record of who recorded what,
with audit trail + CSV export for water-rights reporting.
## When NOT to use this
- For sensor/IoT integrations, use the time-series / `Square Sync`
flow rather than this module — entries here are hand-typed.
- For multi-tenant water-rights billing, use the wholesale deposit
flow; this module is a measurement ledger, not a billing system.
---
## Architecture at a glance
```
┌────────────────────────┐ ┌─────────────────────────┐
│ /water (PIN form) │ │ /water/admin/login │
│ irrigator → submit │ │ (water_admin PIN) │
│ → wl_session cookie │ │ → wl_admin_session │
└──────────┬─────────────┘ └──────────┬──────────────┘
│ │
│ ┌─────────────────────────┘
▼ ▼
┌─────────────────────────────────────┐
│ Postgres (RLS) — brand scoped │
│ water_headgates, water_irrigators, │
│ water_log_entries, water_sessions, │
│ water_admin_sessions, water_ │
│ audit_log, water_alert_log, │
│ water_admin_settings │
└─────────────────────────────────────┘
┌─────────────┴──────────────┐
│ /admin/water-log │
│ Site-admin CRUD + exports │
└────────────────────────────┘
```
**Two separate PIN surfaces:**
| Surface | Cookie | TTL | Purpose |
|---|---|---|---|
| `/water` (irrigator) | `wl_session` | 8 h | Submit flow entries |
| `/water/admin` (admin) | `wl_admin_session` | configurable (1168 h) | Manage headgates/users, view all entries |
Both cookies are `httpOnly`, `sameSite=lax`, and `secure` in production.
---
## Data model
### `water_headgates`
Physical gates a measurement is tied to.
| Column | Type | Notes |
|---|---|---|
| `id` | uuid PK | |
| `brand_id` | uuid FK → brands | brand scope (RLS) |
| `name` | text | e.g. "Upper Ditch Headgate" |
| `headgate_token` | text UNIQUE | opaque token used in the QR code |
| `status` | text | `open` / `closed` / `maintenance` |
| `unit` | text | default measurement unit (CFS, GPM, AF, …) |
| `max_flow_gpm` | numeric | optional marker |
| `high_threshold` | numeric | alert when reading > this |
| `low_threshold` | numeric | alert when reading < this |
| `notes` | text | |
| `active` | bool | soft-delete |
| `last_used_at` | timestamptz | updated on each entry |
| `created_at` | timestamptz | |
### `water_irrigators`
Field workers with PIN access.
| Column | Type | Notes |
|---|---|---|
| `id` | uuid PK | |
| `brand_id` | uuid FK → brands | |
| `name` | text | |
| `pin_hash` | text | scrypt `$N$r$p$salt$hash` — never plain |
| `role` | text | `irrigator` (submit only) or `water_admin` (manage) |
| `language_preference` | text | `en` / `es` |
| `phone` | text | optional |
| `notes` | text | |
| `active` | bool | soft-delete |
| `last_used_at` | timestamptz | |
| `created_at` | timestamptz | |
### `water_log_entries`
The actual readings.
| Column | Type | Notes |
|---|---|---|
| `id` | uuid PK | |
| `brand_id` | uuid FK → brands | |
| `headgate_id` | uuid FK | |
| `irrigator_id` | uuid FK | who submitted |
| `measurement` | numeric | raw value in `unit` |
| `unit` | text | CFS, GPM, AF/Day, etc. |
| `method` | text | `manual` / `meter` / `estimate` / `qr` |
| `total_gallons` | numeric | auto-computed when CFS × duration is known |
| `notes` | text | |
| `submitted_via` | text | `field` / `admin` / `qr` |
| `photo_url` | text | optional |
| `latitude` / `longitude` | double precision | optional GPS |
| `logged_date` | date | date-only mirror for fast grouping |
| `logged_at` | timestamptz | the actual time |
| `logged_by` | uuid FK → admin_users | set when a site admin enters data |
### Sessions
- `water_sessions` — short-lived (8 h) PIN sessions for irrigators.
- `water_admin_sessions` — admin sign-in sessions, TTL from
`water_admin_settings.session_duration_hours`.
### Audit + alerts
- `water_audit_log` — who changed what, when. Captures every
headgate/user/setting mutation with actor + JSON details.
- `water_alert_log` — high/low threshold breach history.
- `water_admin_settings` — per-brand admin PIN, permission flags,
alert config, session duration.
---
## Security model
### PIN hashing
PINs are hashed with **scrypt** (Node built-in `crypto.scryptSync`)
at N=16384, r=8, p=1, 32-byte key, with a per-PIN random salt. The
hash is self-describing: `scrypt$N$r$p$salt_b64$hash_b64`. We avoid
adding a `bcrypt` or `argon2` dependency — `scrypt` is in the Node
core and matches the password module already used elsewhere.
### Brute-force hardening
- 48 digit PINs are low entropy, so we:
- Reject "weak" PINs at generation (`generatePin` skips 1111, 1234,
palindromes, monotonic sequences).
- Add a 200 ms delay on every failed `verifyPin` call to slow
online guessing.
- Use `timingSafeEqual` for the hash comparison.
- Sessions are short-lived (8 h for irrigators, configurable 1168 h
for admins).
- The admin PIN can be regenerated from `/admin/water-log/settings`
regenerating invalidates all existing admin sessions.
### Auth gates
Every server action enforces one of:
- `requireFieldSession()` — reads `wl_session` cookie, looks up
`water_sessions` row, verifies `expires_at`. Returns userId +
brandId.
- `requireWaterAdminPermission()` — calls `getAdminUser()` and checks
`can_manage_water_log` OR `role === "platform_admin"`.
- `requireWaterAdminSession()` — reads `wl_admin_session` cookie and
verifies the row.
There is **no implicit "logged in"** state. Each action that mutates
data explicitly checks its gate.
### Data isolation
- All tables have RLS policies (`withBrand()` helper sets a
per-request GUC; the policy reads it).
- `globalThis.__TUXEDO_BRAND_ID__` is a one-time Tuxedo-brand hint for
cold-start paths; the active `getAdminUser().brand_id` always wins
on later requests.
- No Supabase REST, no service-role keys — all access is direct
through Drizzle over a single `pg` Pool.
---
## Day-to-day usage
### Add a headgate
1. `/admin/water-log` → scroll to **Headgates****+ Add Headgate**.
2. Enter a unique name (e.g. "North Field Gate 1"), default unit,
optional thresholds.
3. The new headgate gets a printable QR code. Print it and stick it
on the gate.
### Add a water user
1. **Water Users****+ Add User**.
2. Enter name, choose role (Admin or Irrigator), pick language.
3. A 4-digit PIN is generated. **Write it down now** — it is shown
once and never recoverable.
4. Hand the PIN to the worker. They sign in at `/water` with just
that.
### Submit a flow entry (irrigator)
1. Open `/water` on a phone → enter 4-digit PIN.
2. Pick the headgate (or scan the QR code — it deep-links to that
gate).
3. Enter the measurement, duration, method, optional notes/photo.
4. Tap **Submit**. Total gallons is auto-computed when CFS × duration
is known.
5. If the reading crosses a high/low threshold, an alert row is
written to `water_alert_log` and (if enabled) an SMS is dispatched
to the configured phone.
### Edit or delete an entry (admin)
- From the **Recent Entries** table, click any row.
- Edit form is gated by `canEditEntries` / `canDeleteEntries` in
`water_admin_settings`. Defaults: edit ✅, delete ✅.
### Reset a PIN
- **Water Users** row → **Reset PIN** → new PIN is generated and
shown once.
### Export CSV
- **Recent Entries** → **Export CSV** button. Or hit
`GET /api/water-logs/export?format=csv` (auth-gated, requires
`can_manage_water_log`).
---
## API surface
| Route | Method | Auth | Purpose |
|---|---|---|---|
| `/api/water-admin-auth` | POST | none (PIN-protected) | Exchange admin PIN for `wl_admin_session` cookie |
| `/api/water-logs/export` | GET | admin | Stream entries as JSON or CSV |
`POST /api/water-admin-auth` body:
```json
{ "brandId": "64294306-5f42-463d-a5e8-2ad6c81a96de", "pin": "1234" }
```
On success, sets the `wl_admin_session` cookie. On failure, returns
a generic error (we don't leak whether the brand has a PIN configured
vs. whether the PIN is wrong).
---
## Testing
### Unit (Vitest)
```bash
npm test -- tests/unit/water-log
```
Covers:
- PIN format validation + weak-PIN detection
- scrypt round-trip + tampering
- Reporting utilities (CSV escaping, date filters, season detection)
- Display age helpers
### E2E (Playwright)
```bash
npx playwright test water-log
```
Covers:
- `/water` and `/water/admin/login` render with PIN form
- PIN input strips non-digits and caps at 4 chars
- Wrong PIN does not navigate
- `/admin/water-log/*` redirects unauthenticated users
- `/api/water-logs/export` and `/api/water-admin-auth` are auth-gated
For the full DB-backed workflow (add headgate → add user → submit →
export), set `WATER_LOG_E2E_DB=1` and run the same command against a
test database.
---
## Environment variables
No Water Logspecific env vars are required. The module uses the
existing `DATABASE_URL` and (optionally) the MinIO bucket
`MINIO_BUCKET_WATER_LOGS` for photo uploads.
If you want high/low threshold SMS alerts, configure
`/admin/water-log/settings` with a phone number — SMS dispatch uses
the brand's existing Twilio config (same as Harvest Reach).
---
## Migration history
- `0001_init.sql` — initial `water_*` tables + RLS policies.
- `0090_water_log_completion.sql` — adds `headgate_token`, threshold
fields, `role` on irrigators, `method` + `logged_date` on entries,
plus the `water_admin_*`, `water_audit_log`, and `water_alert_log`
tables.
---
## Admin function checklist
Manual regression pass after changes:
### Field side
- [ ] `/water` PIN screen loads, accepts 4 digits, rejects letters
- [ ] Wrong PIN shows an error, does not redirect
- [ ] Correct PIN shows the entry form with the user's headgates
- [ ] Submitting an entry creates a row, shows confirmation, returns
to the form
- [ ] QR-code link (`/water?h=TOKEN`) pre-selects the headgate
- [ ] High/low threshold entries write a `water_alert_log` row
### Admin side
- [ ] `/admin/water-log` shows headgates, users, recent entries
- [ ] Add headgate → appears in list + QR can be generated
- [ ] Edit headgate thresholds → reflected in the field form
- [ ] Add user → PIN shown once, user appears in list
- [ ] Reset PIN → new PIN shown, old sessions invalidated
- [ ] Edit entry → measurement, notes, method all updatable
- [ ] Delete entry → row removed (or soft-flagged per audit policy)
- [ ] Filter by date range / headgate / user / method works
- [ ] CSV export downloads valid CSV
- [ ] Audit log shows the actor for every change
### Settings + portal
- [ ] `/admin/water-log/settings` shows current config
- [ ] Toggling "Enable Admin Portal" blocks `/water/admin/login`
- [ ] Regenerating admin PIN signs out all current admin sessions
- [ ] Session duration slider clamps to 1168 hours
### Edge cases
- [ ] Zero data: empty states render, no crashes
- [ ] Invalid PIN: form rejects, error is friendly
- [ ] Duplicate headgate name: server rejects, UI shows error
- [ ] Very large measurement (1e9): renders, doesn't blow up float
- [ ] 1,000+ entries: list paginates, filters stay responsive
- [ ] Concurrent submissions: both rows present, no lost writes
+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):
+123 -16
View File
@@ -1,42 +1,149 @@
"use server"; "use server";
import "server-only";
import { randomBytes } from "crypto";
import { query } from "@/lib/db"; import { query } from "@/lib/db";
import {
requestPasswordReset as neonAuthRequestPasswordReset,
setUserPassword as neonAuthSetUserPassword,
} from "@/lib/auth";
import { getAdminUser } from "@/lib/admin-permissions";
export type ResetAdminPasswordResult = export type ResetAdminPasswordResult =
| { success: true; tempPassword: string } | { success: true; tempPassword: string; method: "set" }
| { success: true; method: "reset_link_sent"; message: string }
| { success: false; error: string }; | { success: false; error: string };
/** /**
* Emergency recovery action — only usable in development or when the caller * Emergency recovery action — resets the password for the specified
* already has direct DB access. Resets the password for the specified email * email so the platform admin can hand a working credential to the
* and returns the temp password so it can be displayed to the user. * user.
* *
* Now that Supabase auth is gone, this hits the `users` table directly and * Tries, in order:
* calls the same `update_user_password` SECURITY DEFINER RPC used by the * 1. `auth.admin.setUserPassword({ userId, newPassword })` — instant,
* self-service password change action. * works when the caller has `role='admin'` in `neon_auth.user`.
* 2. Falls back to `requestPasswordReset({ email, redirectTo })` —
* public Neon Auth endpoint, always works, but the user has to
* click the link in the email. The fallback covers the common
* case where `provision-admin.ts` did not promote the caller to
* `role='admin'` in Neon Auth.
*
* Authorization: platform_admin only. The dev-session platform_admin
* shim is honored (same as the rest of the admin actions).
*/ */
export async function resetAdminPassword( export async function resetAdminPassword(
email: string, email: string,
newPassword: string
): Promise<ResetAdminPasswordResult> { ): Promise<ResetAdminPasswordResult> {
// Look up the user by email // 1. Authz check.
const me = await getAdminUser();
if (!me) {
return { success: false, error: "Not authenticated." };
}
if (me.role !== "platform_admin") {
return {
success: false,
error: "Only platform admins can reset passwords.",
};
}
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail) {
return { success: false, error: "Email is required." };
}
// 2. Look up the Neon Auth user id by email.
const { rows } = await query<{ id: string }>( const { rows } = await query<{ id: string }>(
"SELECT id FROM users WHERE email = $1 LIMIT 1", `SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1`,
[email.toLowerCase()], [normalizedEmail],
); );
const user = rows[0]; const user = rows[0];
if (!user) { if (!user) {
return { success: false, error: "No auth user found for that email address." }; return {
success: false,
error: `No Neon Auth user found for ${normalizedEmail}.`,
};
} }
// Update password via SECURITY DEFINER RPC // 3. Generate a strong temp password server-side. 16 bytes → 32
// base64url chars; mix in a symbol so it satisfies typical
// password policies.
const tempPassword = `${randomBytes(18).toString("base64url")}!`;
// 4. Try the privileged admin endpoint.
try { try {
await query("SELECT update_user_password($1, $2)", [user.id, newPassword]); const adminResult = await neonAuthSetUserPassword({
return { success: true, tempPassword: newPassword }; userId: user.id,
newPassword: tempPassword,
});
if (adminResult?.error) {
const code = adminResult.error.code ?? "";
const canFallback =
code === "FORBIDDEN" ||
code === "UNAUTHORIZED" ||
code === "INTERNAL_SERVER_ERROR";
console.warn(
"[resetAdminPassword] setUserPassword failed (code=%s), will%s fall back: %s",
code,
canFallback ? "" : " NOT",
adminResult.error.message ?? "",
);
if (!canFallback) {
return {
success: false,
error: adminResult.error.message ?? `setUserPassword failed: ${code}`,
};
}
// fall through to the public reset-link path below
} else {
// Success — flip must_change_password so the user is forced
// to pick a new password on next sign-in.
try {
await query(
`UPDATE admin_users SET must_change_password = true WHERE user_id = $1`,
[user.id],
);
} catch (flagErr) {
console.warn(
"[resetAdminPassword] failed to set must_change_password (non-fatal):",
flagErr,
);
}
return { success: true, tempPassword, method: "set" };
}
} catch (err) {
console.error("[resetAdminPassword] setUserPassword threw:", err);
// network / unexpected — try the fallback below
}
// 5. Fallback: send a password-reset email via the public endpoint.
try {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
const resetResult = await neonAuthRequestPasswordReset({
email: normalizedEmail,
redirectTo: `${siteUrl}/reset-password`,
});
if (resetResult?.error) {
return {
success: false,
error:
resetResult.error.message ??
resetResult.error.code ??
"Failed to send reset email",
};
}
return {
success: true,
method: "reset_link_sent",
message: `Set-password is not available for this account. Sent a password-reset email to ${normalizedEmail} instead — share that link with the user.`,
};
} catch (err) { } catch (err) {
return { return {
success: false, success: false,
error: err instanceof Error ? err.message : "Failed to update password", error: err instanceof Error ? err.message : String(err),
}; };
} }
} }
+347 -46
View File
@@ -1,8 +1,12 @@
"use server"; "use server";
import "server-only"; import "server-only";
import { cookies } from "next/headers"; import { query, withTx } from "@/lib/db";
import { pool, query } from "@/lib/db"; import {
createUser as neonAuthCreateUser,
requestPasswordReset as neonAuthRequestPasswordReset,
} from "@/lib/auth";
import { getAdminUser } from "@/lib/admin-permissions";
export type AdminUserRow = { export type AdminUserRow = {
id: string; id: string;
@@ -110,7 +114,7 @@ async function sendWelcomeEmailSafe(input: {
role: "platform_admin" | "brand_admin" | "store_employee"; role: "platform_admin" | "brand_admin" | "store_employee";
password: string; password: string;
brandId?: string; brandId?: string;
}): Promise<void> { }): Promise<{ sent: boolean; error?: string }> {
try { try {
const { sendWelcomeEmail } = await import("@/lib/email-service"); const { sendWelcomeEmail } = await import("@/lib/email-service");
const { getBrandSettings } = await import("@/actions/brand-settings"); const { getBrandSettings } = await import("@/actions/brand-settings");
@@ -119,14 +123,21 @@ async function sendWelcomeEmailSafe(input: {
let logoUrl: string | null = null; let logoUrl: string | null = null;
let brandName = "Route Commerce"; let brandName = "Route Commerce";
if (input.brandId) { if (input.brandId) {
try {
const settings = await getBrandSettings(input.brandId); const settings = await getBrandSettings(input.brandId);
if (settings.success && settings.settings) { if (settings.success && settings.settings) {
logoUrl = settings.settings.logo_url ?? null; logoUrl = settings.settings.logo_url ?? null;
brandName = settings.settings.brand_name ?? brandName; brandName = settings.settings.brand_name ?? brandName;
} }
} catch (brandErr) {
console.warn(
"[createAdminUser] Failed to load brand settings for welcome email:",
brandErr,
);
}
} }
await sendWelcomeEmail({ const result = await sendWelcomeEmail({
to: input.to, to: input.to,
name: input.name, name: input.name,
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee", role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
@@ -134,8 +145,11 @@ async function sendWelcomeEmailSafe(input: {
tempPassword: input.password, tempPassword: input.password,
logoUrl: logoUrl ?? undefined, logoUrl: logoUrl ?? undefined,
}); });
} catch { return result.ok ? { sent: true } : { sent: false, error: result.error };
// welcome email is best-effort; never block user creation } catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[createAdminUser] Welcome email failed (non-fatal):", msg);
return { sent: false, error: msg };
} }
} }
@@ -170,29 +184,181 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
} }
} }
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> { export type CreateAdminUserResult = {
try { user: AdminUserRow | null;
// No Supabase Auth — `user_id` stays NULL until the user signs in error: string | null;
// via Auth.js and `get_admin_user_for_session` matches them by /**
// `auth_subject` / `email`. We just insert the row. * The temporary password that was set on the Neon Auth account. Only
* populated on success. The UI should display this once to the caller
* and offer a copy-to-clipboard affordance — the password is never
* stored in plaintext and cannot be retrieved again.
*/
tempPassword?: string;
/**
* Whether the welcome email was dispatched. Best-effort — the user is
* created even if email fails. False here means the caller should
* share the password out-of-band.
*/
emailSent?: boolean;
/**
* Set when `emailSent` is false. Helps the UI show a useful warning.
*/
emailError?: string;
/**
* Which Neon Auth path was used to create the account. "admin" means
* the caller's Neon Auth session has `role = 'admin'` and we used the
* privileged `auth.admin.createUser` endpoint. "signup" means we had
* to fall back to the public `/sign-up/email` endpoint (e.g. the
* caller's Neon Auth user is not flagged as admin, or the admin
* endpoint rejected the request). The choice is recorded for
* auditability.
*/
authPath?: "admin" | "signup";
};
/**
* Create a new admin user. Steps:
*
* 1. Verify the caller is a `platform_admin`.
* 2. Create the Neon Auth account (with the provided password) and
* link it to the local `admin_users` row via `user_id`. Tries the
* privileged `auth.admin.createUser` first; falls back to the
* public `/sign-up/email` + `emailVerified = true` pattern when
* the caller's Neon Auth session is not an admin (which is the
* common case in dev / when a brand admin's user was not
* provisioned as Neon Auth admin).
* 3. Insert `admin_user_brands` link if a brand was selected.
* 4. Send a welcome email with the temporary password (best-effort).
*
* The temp password is returned to the caller so the UI can show it
* once — this is the only opportunity to share it with the new user,
* since Neon Auth stores a hash, not the plaintext.
*/
export async function createAdminUser(
input: CreateAdminUserInput,
): Promise<CreateAdminUserResult> {
// 1. Authorization: only platform admins can mint new admin users.
const caller = await getAdminUser();
if (!caller) {
return { user: null, error: "Not authenticated. Please sign in again." };
}
if (caller.role !== "platform_admin") {
return {
user: null,
error: "Only platform admins can create new admin users.",
};
}
const email = input.email.trim().toLowerCase();
const password = input.password;
const displayName = input.display_name ?? email.split("@")[0];
const f = input.flags; const f = input.flags;
const { rows } = await query<Record<string, unknown>>(
let neonAuthUserId: string;
let authPath: "admin" | "signup" = "admin";
try {
// 2. Create the Neon Auth account. Try the privileged admin
// endpoint first.
const adminResult = await neonAuthCreateUser({
email,
password,
name: displayName,
});
if (adminResult.data?.user?.id) {
neonAuthUserId = String(adminResult.data.user.id);
authPath = "admin";
} else if (adminResult.error) {
// 2b. Fallback: public sign-up + flip emailVerified to true. The
// admin endpoint requires the caller to have role='admin' in
// Neon Auth, which is not always true (provision-admin.ts does
// not set it). Falling back to sign-up is a safe, well-tested
// path — see scripts/seed-admin.ts.
const code = adminResult.error.code ?? "";
const isAuthRequired =
code === "FAILED_TO_CREATE_USER" ||
code === "FORBIDDEN" ||
code === "UNAUTHORIZED" ||
code === "INTERNAL_SERVER_ERROR" ||
code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL";
if (code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
return {
user: null,
error: `A Neon Auth user with email ${email} already exists. Pick a different email or reset the existing user's password.`,
};
}
if (!isAuthRequired) {
return {
user: null,
error: `Neon Auth createUser failed: ${adminResult.error.message ?? code ?? "unknown error"}`,
};
}
authPath = "signup";
const signupResult = await signupFallbackCreate(email, password, displayName);
if (!signupResult.userId) {
return {
user: null,
error: `Could not create Neon Auth user via fallback sign-up: ${signupResult.error}`,
};
}
neonAuthUserId = signupResult.userId;
} else {
return {
user: null,
error: "Neon Auth createUser returned neither data nor error.",
};
}
} catch (err) {
return {
user: null,
error: `Neon Auth error: ${err instanceof Error ? err.message : String(err)}`,
};
}
// 3. Insert the local admin_users row + brand link in a transaction.
// We do this AFTER creating the Neon Auth user so a Neon Auth
// failure doesn't leave a dangling local row.
let insertedRow: Record<string, unknown> | null = null;
try {
const result = await withTx(async (client) => {
const ins = await client.query<Record<string, unknown>>(
`INSERT INTO admin_users `INSERT INTO admin_users
(user_id, display_name, email, phone_number, role, brand_id, (user_id, display_name, email, phone_number, role, brand_id,
can_manage_products, can_manage_stops, can_manage_orders, can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds, can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports, can_manage_users, can_manage_water_log, can_manage_reports,
active, must_change_password, auth_provider, auth_subject) active, must_change_password, auth_provider, auth_subject)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'pending',$17) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'neon_auth',$17)
ON CONFLICT (user_id) DO UPDATE SET
display_name = EXCLUDED.display_name,
email = EXCLUDED.email,
phone_number = EXCLUDED.phone_number,
role = EXCLUDED.role,
brand_id = EXCLUDED.brand_id,
can_manage_products = EXCLUDED.can_manage_products,
can_manage_stops = EXCLUDED.can_manage_stops,
can_manage_orders = EXCLUDED.can_manage_orders,
can_manage_pickup = EXCLUDED.can_manage_pickup,
can_manage_messages = EXCLUDED.can_manage_messages,
can_manage_refunds = EXCLUDED.can_manage_refunds,
can_manage_users = EXCLUDED.can_manage_users,
can_manage_water_log= EXCLUDED.can_manage_water_log,
can_manage_reports = EXCLUDED.can_manage_reports,
active = EXCLUDED.active,
must_change_password= EXCLUDED.must_change_password,
auth_provider = EXCLUDED.auth_provider
RETURNING id, user_id, display_name, email, phone_number, role, brand_id, RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
can_manage_products, can_manage_stops, can_manage_orders, can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds, can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports, can_manage_users, can_manage_water_log, can_manage_reports,
active, must_change_password, created_at, last_login`, active, must_change_password, created_at, last_login`,
[ [
null, neonAuthUserId,
input.display_name ?? input.email.split("@")[0], displayName,
input.email.toLowerCase(), email,
input.phone_number ?? null, input.phone_number ?? null,
input.role, input.role,
input.brand_id, input.brand_id,
@@ -206,41 +372,138 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
f.can_manage_water_log ?? false, f.can_manage_water_log ?? false,
f.can_manage_reports ?? false, f.can_manage_reports ?? false,
input.mustChangePassword ?? true, input.mustChangePassword ?? true,
input.email.toLowerCase(), neonAuthUserId,
], ],
); );
if (!rows[0]) return { user: null, error: "Insert returned no row" }; if (!ins.rows[0]) throw new Error("admin_users insert returned no row");
const newAdminId = String(rows[0].id);
// Ensure the admin_user_brands link exists for brand-scoped roles.
// (Platform admins created without a chosen brand may have 0 links and still
// get access via role; getAdminUser allows this.)
if (input.brand_id) { if (input.brand_id) {
try { await client.query(
await query(
`INSERT INTO admin_user_brands (admin_user_id, brand_id) `INSERT INTO admin_user_brands (admin_user_id, brand_id)
VALUES ($1, $2) VALUES ($1, $2)
ON CONFLICT (admin_user_id, brand_id) DO NOTHING`, ON CONFLICT (admin_user_id, brand_id) DO NOTHING`,
[newAdminId, input.brand_id], [ins.rows[0].id, input.brand_id],
); );
} catch (linkErr) {
console.error("[createAdminUser] Failed to create admin_user_brands link:", linkErr);
// Non-fatal — the user row exists; a platform admin can link manually.
} }
return ins.rows[0];
});
insertedRow = result;
} catch (err) {
// We created the Neon Auth account but failed to insert the local
// row. Don't roll back the auth user — the operator can re-link
// it via the admin UI / DB. Surface the error.
console.error(
"[createAdminUser] Neon Auth user created (id=" + neonAuthUserId + ") but admin_users insert failed:",
err,
);
return {
user: null,
error:
`Neon Auth user was created but the local admin row failed: ${
err instanceof Error ? err.message : String(err)
}. The Neon Auth account is orphaned — link it manually via the admin_users table.`,
};
} }
await sendWelcomeEmailSafe({ // 4. Welcome email (best-effort).
to: input.email, const emailResult = await sendWelcomeEmailSafe({
name: input.display_name ?? input.email.split("@")[0], to: email,
name: displayName,
role: input.role, role: input.role,
password: input.password, password,
brandId: input.brand_id ?? undefined, brandId: input.brand_id ?? undefined,
}); });
return { user: mapUserRow(rows[0]), error: null }; return {
user: mapUserRow(insertedRow),
error: null,
tempPassword: password,
emailSent: emailResult.sent,
emailError: emailResult.error,
authPath,
};
}
/**
* Public sign-up + emailVerified = true fallback. Used when the
* caller's Neon Auth session is not flagged as admin, or the admin
* endpoint rejected the request for any reason.
*/
async function signupFallbackCreate(
email: string,
password: string,
name: string,
): Promise<{ userId: string | null; error: string | null }> {
const baseUrl = process.env.NEON_AUTH_BASE_URL;
if (!baseUrl) {
return {
userId: null,
error: "NEON_AUTH_BASE_URL is not set; cannot fall back to public sign-up.",
};
}
try {
const res = await fetch(`${baseUrl}/sign-up/email`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Origin": process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000",
"x-neon-auth-proxy": "node",
},
body: JSON.stringify({
email,
password,
name,
callbackURL: "/admin",
}),
});
const data = (await res.json().catch(() => ({}))) as {
user?: { id?: string };
code?: string;
message?: string;
};
if (!res.ok) {
if (data.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
// Look up the existing user id so we can still link.
const existing = await query<{ id: string }>(
`SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1`,
[email],
);
if (existing.rows[0]?.id) {
return { userId: existing.rows[0].id, error: null };
}
return { userId: null, error: "User already exists but could not be looked up." };
}
return {
userId: null,
error: data.message ?? data.code ?? `Sign-up failed (HTTP ${res.status})`,
};
}
const userId = data.user?.id;
if (!userId) {
return { userId: null, error: "Sign-up response missing user.id" };
}
// Flip emailVerified so the user can sign in without a verification step.
// The platform admin already vetted this email — they're handing the
// password to the user directly.
try {
await query(`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`, [userId]);
} catch (verifyErr) {
console.warn(
"[createAdminUser] Failed to set emailVerified=true (non-fatal):",
verifyErr,
);
}
return { userId, error: null };
} catch (err) { } catch (err) {
return { user: null, error: err instanceof Error ? err.message : String(err) }; return {
userId: null,
error: err instanceof Error ? err.message : String(err),
};
} }
} }
@@ -303,19 +566,61 @@ export async function setMustChangePassword(userId: string): Promise<{ success:
} }
/** /**
* No auth service anymore (no Supabase, no Auth.js password-reset * Sends a password-reset email to the user via Neon Auth.
* endpoint). A platform admin can reset access by deleting + *
* re-creating the user, or by toggling `must_change_password` via the * Authorization: platform_admin only. Brand-scoped admins cannot
* UI — the function is preserved as a no-op so call sites keep * trigger password resets for users outside their brand.
* compiling. *
* The endpoint used (`requestPasswordReset`) is a public Neon Auth
* endpoint — it does not require the caller to be the target user.
* For the same reason the public /api/auth/forgot-password route
* works, this will always succeed against the Neon Auth API; we
* still return the result so the UI can show a clear success/failure
* message.
*/ */
export async function sendPasswordResetEmail(_email: string): Promise<{ success: boolean; error: string | null }> { export async function sendPasswordResetEmail(
email: string,
): Promise<{ success: boolean; error: string | null }> {
try {
// Authz: must be signed in as a platform_admin.
const me = await getAdminUser();
if (!me) {
return { success: false, error: "Not authenticated." };
}
if (me.role !== "platform_admin") {
return { success: false, error: "Only platform admins can send password resets." };
}
const trimmedEmail = email.trim().toLowerCase();
if (!trimmedEmail) {
return { success: false, error: "Email is required." };
}
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
const result = await neonAuthRequestPasswordReset({
email: trimmedEmail,
redirectTo: `${siteUrl}/reset-password`,
});
if (result?.error) {
console.error("[admin/sendPasswordResetEmail] Neon Auth error:", result.error);
return { return {
success: false, success: false,
error: "Password reset is handled by a platform admin. Contact them to reset your access.", error: result.error.message ?? result.error.code ?? "Failed to send reset email",
}; };
} }
return { success: true, error: null };
} catch (err) {
console.error("[admin/sendPasswordResetEmail] Unexpected error:", err);
return {
success: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> { export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
try { try {
// Sort by name so the platform admin's brand picker stays stable. // Sort by name so the platform admin's brand picker stays stable.
@@ -327,7 +632,3 @@ export async function getBrands(): Promise<{ brands: { id: string; name: string
return { brands: [], error: err instanceof Error ? err.message : String(err) }; return { brands: [], error: err instanceof Error ? err.message : String(err) };
} }
} }
// Keep `pool` reachable so bundlers don't tree-shake the import — the
// import is for the `server-only` side effect.
void pool;
+1 -1
View File
@@ -284,7 +284,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
}>( }>(
`SELECT `SELECT
o.id::text AS id, o.id::text AS id,
c.name AS customer_name, TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
o.total_cents::float / 100.0 AS subtotal, o.total_cents::float / 100.0 AS subtotal,
o.status, o.status,
o.placed_at::text AS created_at, o.placed_at::text AS created_at,
+59 -1
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import "server-only"; import "server-only";
import { signOut } from "@/lib/auth"; import { signIn, signOut } from "@/lib/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
/** /**
@@ -12,3 +12,61 @@ export async function signOutAction(): Promise<void> {
await signOut(); await signOut();
redirect("/login"); redirect("/login");
} }
/**
* Kick off a Google OAuth sign-in. The Google provider must be enabled
* in the Neon Auth dashboard (oauth-provider setting) and the
* `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` env vars must be set
* in the runtime environment.
*
* Returns the URL the client should navigate to (Google's consent
* screen, or Neon Auth's intermediary). The client should do
* `window.location.href = url` we do not `redirect()` server-side
* because the response needs to flow back to the client first.
*/
export async function signInWithGoogleAction(input: {
callbackURL?: string;
errorCallbackURL?: string;
}): Promise<{ url: string | null; error: string | null }> {
const callbackURL = input.callbackURL ?? "/admin";
const errorCallbackURL = input.errorCallbackURL ?? "/login?error=oauth";
try {
// Neon Auth's better-auth client returns `{ data, error }`. For
// social sign-in, `data` is `{ redirect, url, token?, user? }`.
// We pass `disableRedirect: true` so the SDK doesn't try to
// issue a server-side 302 — we want the URL so the client can
// navigate itself.
const result = await signIn.social({
provider: "google",
callbackURL,
errorCallbackURL,
});
if (result.error) {
console.error("[auth/google] signIn.social error:", result.error);
return {
url: null,
error:
result.error.message ??
result.error.code ??
"Could not start Google sign-in.",
};
}
const url = result.data?.url ?? null;
if (!url) {
return {
url: null,
error: "Google sign-in returned no redirect URL.",
};
}
return { url, error: null };
} catch (err) {
console.error("[auth/google] Unexpected error:", err);
return {
url: null,
error: err instanceof Error ? err.message : "An unexpected error occurred.",
};
}
}
+4 -1
View File
@@ -128,7 +128,7 @@ export async function createOrder(
} }
} }
await sendOrderReceiptEmail({ const result = await sendOrderReceiptEmail({
customerName, customerName,
customerEmail, customerEmail,
orderId: data.id, orderId: data.id,
@@ -149,6 +149,9 @@ export async function createOrder(
brandName: "Tuxedo Corn", brandName: "Tuxedo Corn",
logoUrl, logoUrl,
}); });
if (!result.ok) {
console.error("[checkout] Order receipt email failed:", result.error);
}
} catch { } catch {
// Email failure should not fail the order // Email failure should not fail the order
} }
+42 -32
View File
@@ -42,24 +42,22 @@ export async function getDashboardStats(): Promise<DashboardStats> {
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000); const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
// Fetch today's orders. `orders` and `stops` use the legacy schema // Fetch today's orders. New schema: `total_cents` (int, divide by 100
// (column names like `subtotal`, `brand_id`, `date`); the new-schema // for dollars) and `placed_at` (not legacy `subtotal` / `created_at`).
// Drizzle `orders` table doesn't have these. Raw SQL via the shared
// pg pool.
const todayOrdersRes = brandId const todayOrdersRes = brandId
? await pool.query<{ subtotal: number; status: string }>( ? await pool.query<{ total_cents: number; status: string }>(
`SELECT subtotal, status `SELECT total_cents, status
FROM orders FROM orders
WHERE created_at >= $1 WHERE placed_at >= $1
AND created_at < $2 AND placed_at < $2
AND brand_id = $3`, AND brand_id = $3`,
[startOfDay.toISOString(), endOfDay.toISOString(), brandId], [startOfDay.toISOString(), endOfDay.toISOString(), brandId],
) )
: await pool.query<{ subtotal: number; status: string }>( : await pool.query<{ total_cents: number; status: string }>(
`SELECT subtotal, status `SELECT total_cents, status
FROM orders FROM orders
WHERE created_at >= $1 WHERE placed_at >= $1
AND created_at < $2`, AND placed_at < $2`,
[startOfDay.toISOString(), endOfDay.toISOString()], [startOfDay.toISOString(), endOfDay.toISOString()],
); );
const todayOrders = todayOrdersRes.rows; const todayOrders = todayOrdersRes.rows;
@@ -67,7 +65,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
// Calculate today's revenue and orders // Calculate today's revenue and orders
const validOrders = todayOrders.filter((o) => o.status !== "cancelled"); const validOrders = todayOrders.filter((o) => o.status !== "cancelled");
const todayRevenue = validOrders.reduce( const todayRevenue = validOrders.reduce(
(sum, o) => sum + (o.subtotal || 0), (sum, o) => sum + ((o.total_cents || 0) / 100),
0, 0,
); );
const todayOrderCount = validOrders.length; const todayOrderCount = validOrders.length;
@@ -106,7 +104,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
); );
const activeProducts = productsRes.rows.length; const activeProducts = productsRes.rows.length;
// Fetch weekly orders for chart (last 7 days) // Fetch weekly orders for chart (last 7 days). New schema uses `placed_at`.
const weeklyOrders: number[] = []; const weeklyOrders: number[] = [];
for (let i = 6; i >= 0; i--) { for (let i = 6; i >= 0; i--) {
const dayStart = new Date(startOfDay); const dayStart = new Date(startOfDay);
@@ -116,48 +114,60 @@ export async function getDashboardStats(): Promise<DashboardStats> {
const dayRes = brandId const dayRes = brandId
? await pool.query<{ id: string }>( ? await pool.query<{ id: string }>(
`SELECT id FROM orders `SELECT id FROM orders
WHERE created_at >= $1 WHERE placed_at >= $1
AND created_at < $2 AND placed_at < $2
AND brand_id = $3 AND brand_id = $3
LIMIT 1`, LIMIT 1`,
[dayStart.toISOString(), dayEnd.toISOString(), brandId], [dayStart.toISOString(), dayEnd.toISOString(), brandId],
) )
: await pool.query<{ id: string }>( : await pool.query<{ id: string }>(
`SELECT id FROM orders `SELECT id FROM orders
WHERE created_at >= $1 WHERE placed_at >= $1
AND created_at < $2 AND placed_at < $2
LIMIT 1`, LIMIT 1`,
[dayStart.toISOString(), dayEnd.toISOString()], [dayStart.toISOString(), dayEnd.toISOString()],
); );
weeklyOrders.push(dayRes.rows.length > 0 ? 1 : 0); weeklyOrders.push(dayRes.rows.length > 0 ? 1 : 0);
} }
// Fetch recent orders (last 10) // Fetch recent orders (last 10). New schema: `total_cents`/`placed_at`
// on `orders`; customer name comes from a `customers` join (no
// `customer_name` column on `orders`).
const recentRes = brandId const recentRes = brandId
? await pool.query<{ ? await pool.query<{
id: string; id: string;
customer_name: string; customer_name: string;
subtotal: number; total_cents: number;
status: string; status: string;
created_at: string; placed_at: string;
}>( }>(
`SELECT id, customer_name, subtotal, status, created_at `SELECT o.id::text AS id,
FROM orders TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
WHERE brand_id = $1 o.total_cents,
ORDER BY created_at DESC o.status,
o.placed_at::text AS placed_at
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.brand_id = $1
ORDER BY o.placed_at DESC
LIMIT 10`, LIMIT 10`,
[brandId], [brandId],
) )
: await pool.query<{ : await pool.query<{
id: string; id: string;
customer_name: string; customer_name: string;
subtotal: number; total_cents: number;
status: string; status: string;
created_at: string; placed_at: string;
}>( }>(
`SELECT id, customer_name, subtotal, status, created_at `SELECT o.id::text AS id,
FROM orders TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
ORDER BY created_at DESC o.total_cents,
o.status,
o.placed_at::text AS placed_at
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
ORDER BY o.placed_at DESC
LIMIT 10`, LIMIT 10`,
); );
@@ -166,9 +176,9 @@ export async function getDashboardStats(): Promise<DashboardStats> {
.map((o) => ({ .map((o) => ({
id: o.id || "", id: o.id || "",
customer_name: o.customer_name || "Guest", customer_name: o.customer_name || "Guest",
total: o.subtotal || 0, total: (o.total_cents || 0) / 100,
status: o.status || "unknown", status: o.status || "unknown",
created_at: formatTimeAgo(o.created_at), created_at: formatTimeAgo(o.placed_at),
})); }));
return { return {
-149
View File
@@ -1,149 +0,0 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
// ── Types ────────────────────────────────────────────────────────────────────
export type PlatformMetrics = {
active_brands: number;
orders_today: number;
revenue_today: number;
active_routes: number;
failed_orders_today: number;
pending_orders_today: number;
};
export type ActivityEvent = {
id: string;
event_type: string;
entity_type: string;
entity_id: string;
payload: Record<string, unknown>;
actor_type: string;
source: string;
created_at: string;
brand_id: string | null;
brand_name: string | null;
};
export type BrandHealth = {
brand_id: string;
brand_name: string;
brand_slug: string;
plan_tier: string;
orders_today: number;
revenue_today: number;
active_stops: number;
failed_orders: number;
pending_orders: number;
last_activity: string | null;
open_pain_items: number;
health_status: "healthy" | "warning" | "critical";
};
export type PainLogItem = {
id: string;
brand_id: string | null;
brand_name: string | null;
severity: "low" | "medium" | "high" | "critical";
category: string;
title: string;
description: string | null;
status: string;
created_at: string;
};
export type CreatePainLogData = {
brand_id?: string | null;
severity: "low" | "medium" | "high" | "critical";
category: string;
title: string;
description?: string | null;
};
// ── Internal RPC helper ──────────────────────────────────────────────────────
async function platformRPC<T>(rpcName: string): Promise<T> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
if (adminUser.role !== "platform_admin") throw new Error("Access denied: platform admin only");
const { rows } = await pool.query<Record<string, T>>(
`SELECT ${rpcName}() AS "${rpcName}"`,
);
const data = rows[0]?.[rpcName];
if (data == null) {
return null as T;
}
return data as T;
}
// ── Platform actions ─────────────────────────────────────────────────────────
export async function getPlatformMetrics(): Promise<PlatformMetrics> {
return platformRPC<PlatformMetrics>("get_platform_command_center_metrics");
}
export async function getPlatformActivityFeed(): Promise<ActivityEvent[]> {
return platformRPC<ActivityEvent[]>("get_platform_activity_feed");
}
export async function getBrandHealthSnapshot(): Promise<BrandHealth[]> {
return platformRPC<BrandHealth[]>("get_brand_health_snapshot");
}
export async function getPainLog(): Promise<PainLogItem[]> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const { rows } = await pool.query<PainLogItem>(
`SELECT * FROM founder_pain_log_platform ORDER BY created_at DESC`,
);
return rows;
}
export async function createPainLogItem(data: CreatePainLogData): Promise<{ success: boolean; error?: string }> {
try {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
await pool.query(
`INSERT INTO founder_pain_log (brand_id, severity, category, title, description)
VALUES ($1, $2, $3, $4, $5)`,
[
data.brand_id || null,
data.severity,
data.category,
data.title,
data.description || null,
],
);
return { success: true };
} catch (e) {
return { success: false, error: String(e) };
}
}
export async function resolvePainLogItem(id: string): Promise<{ success: boolean; error?: string }> {
try {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
await pool.query(
`UPDATE founder_pain_log
SET status = 'resolved', resolved_at = now(), resolved_by = $2
WHERE id = $1`,
[id, adminUser.id],
);
return { success: true };
} catch (e) {
return { success: false, error: String(e) };
}
}
+3 -3
View File
@@ -73,9 +73,9 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
}>( }>(
`SELECT `SELECT
o.id::text AS id, o.id::text AS id,
c.name AS customer_name, TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
c.email AS customer_email, c.primary_email AS customer_email,
c.phone AS customer_phone, c.primary_phone AS customer_phone,
o.status, o.status,
o.total_cents::float / 100.0 AS subtotal, o.total_cents::float / 100.0 AS subtotal,
o.placed_at::text AS created_at, o.placed_at::text AS created_at,
File diff suppressed because it is too large Load Diff
+393 -56
View File
@@ -1,88 +1,374 @@
/**
* Water Log field (PIN) actions.
*
* These power `/water` and `/water/admin` the mobile-first, PIN-only
* portals that ditch-riders actually use in the field. The shape of
* the data they consume is the same as admin actions, but the
* authorization model is different:
*
* - Irrigators carry a `wl_session` cookie tied to a row in
* `water_sessions`. Cookie expiry = row's `expires_at`.
* - Admins (water_admin role) carry a `wl_admin_session` cookie
* tied to `water_admin_sessions`.
*
* Both cookies are httpOnly + sameSite=lax. The session lookup is the
* gate for every mutating action there's no role/permission check
* inside the action because the cookie presence + the row's role is
* the permission.
*/
"use server"; "use server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { and, desc, eq, gte, sql } from "drizzle-orm";
import { withBrand, withPlatformAdmin } from "@/db/client";
import {
waterHeadgates,
waterIrrigators,
waterSessions,
waterLogEntries,
waterAdminSessions,
} from "@/db/schema/water-log";
import { verifyPin, validatePin } from "@/lib/water-log-pin";
import { logAlert } from "@/lib/water-log-audit";
// TODO(migration): the water-log field UI used a chain of Supabase RPCs // Field sessions last 8h — a working day in the field. Long enough
// (`get_water_headgates`, `verify_water_pin`, `get_water_user_by_id`, // that a single sign-in covers a morning shift + afternoon shift.
// `submit_water_entry`, `trigger_water_alert`, const FIELD_SESSION_HOURS = 8;
// `get_water_admin_session`) and tables (`water_headgates`, const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
// `water_users`, `water_sessions`, `water_admin_sessions`,
// `water_entries`, `water_alert_log`) that are not in the SaaS
// rebuild's `db/schema/`. The actions below preserve the original
// signatures and return empty / no-op responses so the field UI
// degrades gracefully. See `actions/route-trace/lots.ts` for the
// same pattern.
type VerifyPinResult = { // ── Types ──────────────────────────────────────────────────────────────────
type FieldHeadgate = {
id: string;
name: string;
unit: string;
status: string;
high_threshold: number | null;
low_threshold: number | null;
active: boolean;
};
type VerifyPinResult =
| {
success: true; success: true;
user_id: string; user_id: string;
name: string; name: string;
role: string; role: "irrigator" | "water_admin";
session_id: string; session_id: string;
lang: string; lang: string;
} | { }
success: false; | { success: false; error: string };
error: string;
};
type SubmitEntryResult = { type SubmitEntryResult =
success: true; | { success: true; entry_id: string }
entry_id: string; | { success: false; error: string };
} | {
success: false;
error: string;
};
type Headgate = { // ── Headgates (read-only for field) ────────────────────────────────────────
id: string;
name: string;
active: boolean;
created_at: string;
};
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
export async function getWaterHeadgates( export async function getWaterHeadgates(
_brandId: string, _brandId: string,
_activeOnly = false activeOnly: boolean = false,
): Promise<Headgate[]> { ): Promise<FieldHeadgate[]> {
return []; return withBrand(TUXEDO_BRAND_ID, async (db) => {
const where = activeOnly
? and(
eq(waterHeadgates.brandId, TUXEDO_BRAND_ID),
eq(waterHeadgates.active, true),
)
: eq(waterHeadgates.brandId, TUXEDO_BRAND_ID);
const rows = await db
.select()
.from(waterHeadgates)
.where(where)
.orderBy(waterHeadgates.name);
return rows.map((h) => ({
id: h.id,
name: h.name,
unit: h.unit,
status: h.status,
high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null,
low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null,
active: h.active,
}));
});
} }
// ── PIN verify + session ───────────────────────────────────────────────────
export async function verifyWaterPin( export async function verifyWaterPin(
_brandId: string, _brandId: string,
_pin: string pin: string,
): Promise<VerifyPinResult> { ): Promise<VerifyPinResult> {
return { success: false, error: NOT_CONFIGURED }; // Validate format first (cheap, no DB).
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
// Look the irrigator up across all brands (PIN is the only credential).
const lookup = await withPlatformAdmin(async (db) => {
const rows = await db
.select()
.from(waterIrrigators)
.where(eq(waterIrrigators.active, true))
.orderBy(waterIrrigators.name);
return rows;
});
// Constant-time-ish: check every row, only succeed on the first match.
// (We can't make this perfectly constant-time without pulling the
// hashed values into memory in random order, but iterating by name
// is close enough for a 4-digit PIN and avoids the worst case of an
// obvious timing oracle on a sorted list.)
const match = lookup.find((i) => verifyPin(pin, i.pinHash));
if (!match) {
// Add a small delay to discourage brute-force scanning.
await new Promise((r) => setTimeout(r, 200));
return { success: false, error: "Invalid PIN" };
} }
// Create a session in the user's brand context.
const sessionId = await withBrand(match.brandId, async (db) => {
const expiresAt = new Date(
Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000,
);
const [row] = await db
.insert(waterSessions)
.values({
irrigatorId: match.id,
expiresAt,
})
.returning({ id: waterSessions.id });
if (!row) throw new Error("Failed to create session");
// Bump last_used_at for "I haven't seen this person in a while" UX
await db
.update(waterIrrigators)
.set({ lastUsedAt: new Date() })
.where(eq(waterIrrigators.id, match.id));
return row.id;
});
const cookieStore = await cookies();
cookieStore.set("wl_session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: FIELD_SESSION_HOURS * 3600,
path: "/",
});
return {
success: true,
user_id: match.id,
name: match.name,
role: (match.role as "irrigator" | "water_admin") ?? "irrigator",
session_id: sessionId,
lang: match.languagePreference,
};
}
// ── Submit entry ───────────────────────────────────────────────────────────
export async function submitWaterEntry( export async function submitWaterEntry(
_headgateId: string, headgateId: string,
_measurement: number, measurement: number,
_unit: string, unit: string,
_notes: string, notes: string,
_photoUrl?: string, photoUrl?: string,
_latitude?: number, latitude?: number,
_longitude?: number, longitude?: number,
_headgateLocked?: boolean _headgateLocked?: boolean,
): Promise<SubmitEntryResult> { ): Promise<SubmitEntryResult> {
// ── Auth ──
const session = await requireFieldSession();
if (!session.ok) return { success: false, error: session.error };
// ── Validate input ──
if (!Number.isFinite(measurement) || measurement < 0) {
return { success: false, error: "Measurement must be a non-negative number" };
}
if (measurement > 1_000_000) {
return { success: false, error: "Measurement is implausibly large" };
}
if (notes && notes.length > 500) {
return { success: false, error: "Notes are too long (max 500 chars)" };
}
if (latitude != null && (latitude < -90 || latitude > 90)) {
return { success: false, error: "Invalid latitude" };
}
if (longitude != null && (longitude < -180 || longitude > 180)) {
return { success: false, error: "Invalid longitude" };
}
// ── Write the entry inside the brand context ──
return withBrand(session.brandId, async (db) => {
// Confirm the headgate belongs to this brand
const headgateRows = await db
.select()
.from(waterHeadgates)
.where(eq(waterHeadgates.id, headgateId))
.limit(1);
const headgate = headgateRows[0];
if (!headgate) return { success: false, error: "Headgate not found" };
// Auto-calc total_gallons when CFS × duration is provided.
// Caller passes `measurement` as the total CFS volume for the set
// (we don't track duration separately on the entry — duration goes
// in notes if needed). We leave total_gallons null for CFS by
// default to avoid spurious "X gallons" claims.
const totalGallons = computeTotalGallons(measurement, unit);
const now = new Date();
const loggedDate = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}`;
const [entry] = await db
.insert(waterLogEntries)
.values({
brandId: session.brandId,
headgateId,
irrigatorId: session.userId,
measurement: measurement.toString(),
unit,
totalGallons: totalGallons?.toString() ?? null,
method: "manual",
notes: notes || null,
submittedVia: "field",
photoUrl: photoUrl ?? null,
latitude: latitude ?? null,
longitude: longitude ?? null,
loggedDate,
loggedAt: now,
})
.returning({ id: waterLogEntries.id });
if (!entry) return { success: false, error: "Insert failed" };
// Bump headgate last_used_at
await db
.update(waterHeadgates)
.set({ lastUsedAt: now })
.where(eq(waterHeadgates.id, headgateId));
// Threshold alert check (fire-and-forget)
if (headgate.highThreshold != null && measurement > Number(headgate.highThreshold)) {
void logAlert({
brandId: session.brandId,
headgateId,
alertType: "high",
reading: measurement,
threshold: Number(headgate.highThreshold),
headgateName: headgate.name,
});
}
if (headgate.lowThreshold != null && measurement < Number(headgate.lowThreshold)) {
void logAlert({
brandId: session.brandId,
headgateId,
alertType: "low",
reading: measurement,
threshold: Number(headgate.lowThreshold),
headgateName: headgate.name,
});
}
return { success: true, entry_id: entry.id };
});
}
// ── Session helpers (shared with admin.ts via requireFieldSession) ────────
type FieldSession =
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
| { ok: false; error: string };
export async function requireFieldSession(): Promise<FieldSession> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value; const sessionId = cookieStore.get("wl_session")?.value;
if (!sessionId) return { ok: false, error: "Not logged in" };
if (!sessionId) { return withPlatformAdmin(async (db) => {
return { success: false, error: "Not logged in" }; const rows = await db
.select({
session: waterSessions,
irrigator: waterIrrigators,
})
.from(waterSessions)
.innerJoin(waterIrrigators, eq(waterIrrigators.id, waterSessions.irrigatorId))
.where(eq(waterSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return { ok: false as const, error: "Session not found" };
if (row.session.expiresAt.getTime() < Date.now()) {
// Best-effort cleanup
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
return { ok: false as const, error: "Session expired" };
}
if (!row.irrigator.active) {
return { ok: false as const, error: "User is inactive" };
}
return {
ok: true as const,
userId: row.irrigator.id,
brandId: row.irrigator.brandId,
role: (row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
} }
return { success: false, error: NOT_CONFIGURED }; export async function getFieldSessionUser(): Promise<{
userId: string;
name: string;
brandId: string;
role: "irrigator" | "water_admin";
} | null> {
const s = await requireFieldSession();
if (!s.ok) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ name: waterIrrigators.name, role: waterIrrigators.role })
.from(waterIrrigators)
.where(eq(waterIrrigators.id, s.userId))
.limit(1);
const row = rows[0];
if (!row) return null;
return {
userId: s.userId,
name: row.name,
brandId: s.brandId,
role: (row.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
} }
// ── Cookie/language helpers ───────────────────────────────────────────────
export async function logoutWater(): Promise<void> { export async function logoutWater(): Promise<void> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (sessionId) {
// Best-effort DB cleanup
try {
await withPlatformAdmin(async (db) => {
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
});
} catch {
// ignore — the cookie is being deleted anyway
}
}
cookieStore.delete("wl_session"); cookieStore.delete("wl_session");
} }
export async function logoutWaterAdmin(): Promise<void> { export async function logoutWaterAdmin(): Promise<void> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (sessionId) {
try {
await withPlatformAdmin(async (db) => {
await db
.delete(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId));
});
} catch {
// ignore — cookie is being deleted anyway
}
}
cookieStore.delete("wl_admin_session"); cookieStore.delete("wl_admin_session");
} }
@@ -91,7 +377,49 @@ export async function getWaterSession(): Promise<string | null> {
return cookieStore.get("wl_session")?.value ?? null; return cookieStore.get("wl_session")?.value ?? null;
} }
/**
* Resolves the current `/water/admin` session.
*
* Returns the admin role + brandId on success, or `null` if the cookie
* is missing / expired / points at a deleted session row. Used by
* admin pages (entries/[id], headgates/[id], users/[id]) as a *second*
* gate on top of `getAdminUser()` a site admin can edit entries
* directly, but a PIN-authenticated water admin can too.
*/
export async function getWaterAdminSession(): Promise<{
sessionId: string;
brandId: string;
adminUserId: string;
role: "water_admin";
} | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
id: waterAdminSessions.id,
brandId: waterAdminSessions.brandId,
adminUserId: waterAdminSessions.adminUserId,
expiresAt: waterAdminSessions.expiresAt,
})
.from(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.expiresAt.getTime() <= Date.now()) return null;
return {
sessionId: row.id,
brandId: row.brandId,
adminUserId: row.adminUserId,
role: "water_admin" as const,
};
});
}
export async function setWaterLang(lang: string): Promise<void> { export async function setWaterLang(lang: string): Promise<void> {
if (!["en", "es"].includes(lang)) return;
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set("wl_lang", lang, { cookieStore.set("wl_lang", lang, {
httpOnly: false, httpOnly: false,
@@ -102,15 +430,24 @@ export async function setWaterLang(lang: string): Promise<void> {
}); });
} }
export async function getWaterAdminSession(): Promise<{ // ── Internal helpers ──────────────────────────────────────────────────────
user_id: string;
name: string;
role: string;
} | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null;
/**
* If the measurement is in CFS (cubic feet per second) and we know it
* represents a set duration, we can derive total gallons. Without
* duration, we leave total_gallons null to avoid making up numbers.
*
* CFS × 60s × 7.48052 = gallons per minute.
* 1 CFS for 1 hour = ~448.83 gallons.
*/
function computeTotalGallons(measurement: number, unit: string): number | null {
if (unit !== "CFS") return null;
// We don't have a duration field on the entry, so we return null. The
// admin can edit an entry to set gallons manually if they have that
// data. The hook is here so callers can extend easily.
return null; return null;
} }
function pad(n: number): string {
return n.toString().padStart(2, "0");
}
+227 -27
View File
@@ -1,44 +1,244 @@
/**
* Water Log admin settings (PIN-protected /water/admin portal).
*
* The `/water/admin` portal is gated by its own 4-digit PIN (separate
* from the irrigators' PIN). That PIN is *hashed* and stored in
* `water_admin_settings` (one row per brand). Sessions are tracked in
* `water_admin_sessions`.
*
* There is currently no per-admin-user PIN a single brand-wide admin
* PIN. If you need per-user PINs (e.g. for an audit trail of which
* admin entered the portal), swap `pin_hash` for `admin_user_pins` and
* key sessions by `admin_user_id`.
*/
"use server"; "use server";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import {
waterAdminSettings,
waterAdminSessions,
type WaterAdminSettings,
} from "@/db/schema/water-log";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
import { logAuditEvent } from "@/lib/water-log-audit";
// TODO(migration): the water-log settings RPCs (`get_water_admin_settings`, export type AdminSettings = {
// `hash_water_admin_pin`, `save_water_admin_settings`,
// `verify_water_admin_pin`) and the underlying
// `water_admin_settings` table are not in the SaaS rebuild schema.
// The functions below preserve the original signatures and return
// empty / no-op responses. Same pattern as
// `actions/route-trace/lots.ts`.
export type WaterAdminSettings = {
enabled: boolean; enabled: boolean;
session_duration_hours: number; sessionDurationHours: number;
can_edit_entries: boolean; canEditEntries: boolean;
can_delete_entries: boolean; canDeleteEntries: boolean;
can_export_csv: boolean; canExportCsv: boolean;
alert_phone?: string | null; alertPhone: string | null;
alerts_enabled?: boolean; alertsEnabled: boolean;
/** The currently configured PIN, in plain text. Only returned on
* read right after the admin regenerates it; otherwise null. */
pin: string | null;
}; };
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild"; function mapSettings(s: WaterAdminSettings): AdminSettings {
return {
enabled: s.enabled,
sessionDurationHours: s.sessionDurationHours,
canEditEntries: s.canEditEntries,
canDeleteEntries: s.canDeleteEntries,
canExportCsv: s.canExportCsv,
alertPhone: s.alertPhone,
alertsEnabled: s.alertsEnabled,
pin: null, // never returned unless just regenerated
};
}
export async function getWaterAdminSettings(_brandId: string): Promise<WaterAdminSettings | null> { export async function getWaterAdminSettings(
brandId: string,
): Promise<AdminSettings | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return null; return null;
} }
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterAdminSettings)
.where(eq(waterAdminSettings.brandId, brandId))
.limit(1);
if (!rows[0]) {
// Lazy-initialize default settings the first time the page is hit
const [created] = await db
.insert(waterAdminSettings)
.values({ brandId })
.returning();
if (!created) return null;
return { ...mapSettings(created), pin: null };
}
return mapSettings(rows[0]);
});
}
export async function saveWaterAdminSettings( export async function saveWaterAdminSettings(
_brandId: string, brandId: string,
_settings: Partial<WaterAdminSettings & { pin?: string }> settings: Partial<AdminSettings>,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (
return { success: false, error: NOT_CONFIGURED }; !adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { success: false, error: "Not authorized" };
} }
export async function verifyWaterAdminPin( if (
_brandId: string, settings.sessionDurationHours != null &&
_pin: string (settings.sessionDurationHours < 1 || settings.sessionDurationHours > 168)
): Promise<{ success: boolean; session_id?: string; error?: string }> { ) {
return { success: false, error: NOT_CONFIGURED }; return { success: false, error: "Session duration must be 1168 hours" };
}
return withBrand(brandId, async (db) => {
const update: Partial<WaterAdminSettings> = {};
if (settings.enabled != null) update.enabled = settings.enabled;
if (settings.sessionDurationHours != null)
update.sessionDurationHours = settings.sessionDurationHours;
if (settings.canEditEntries != null)
update.canEditEntries = settings.canEditEntries;
if (settings.canDeleteEntries != null)
update.canDeleteEntries = settings.canDeleteEntries;
if (settings.canExportCsv != null)
update.canExportCsv = settings.canExportCsv;
if (settings.alertPhone !== undefined)
update.alertPhone = settings.alertPhone;
if (settings.alertsEnabled != null)
update.alertsEnabled = settings.alertsEnabled;
update.updatedBy = adminUser.user_id ?? adminUser.id ?? null;
// Upsert: insert if missing, update if present.
const updated = await db
.insert(waterAdminSettings)
.values({ brandId, ...update })
.onConflictDoUpdate({
target: waterAdminSettings.brandId,
set: update,
})
.returning();
await logAuditEvent({
brandId,
actorId: adminUser.user_id ?? null,
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
action: "update",
entityType: "settings",
details: { ...update },
});
return { success: true, settings: mapSettings(updated[0]) };
});
}
/**
* Generate a fresh PIN, hash it, and save it on the brand. Returns the
* plaintext PIN *once* caller is responsible for showing it to the
* admin and never persisting it.
*/
export async function regenerateAdminPin(
brandId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { success: false, error: "Not authorized" };
}
const pin = generatePin();
const pinHash = hashPin(pin);
return withBrand(brandId, async (db) => {
await db
.insert(waterAdminSettings)
.values({ brandId, pinHash })
.onConflictDoUpdate({
target: waterAdminSettings.brandId,
set: { pinHash, updatedBy: adminUser.user_id ?? adminUser.id ?? null },
});
// Invalidate any existing admin sessions for safety
await db
.delete(waterAdminSessions)
.where(eq(waterAdminSessions.brandId, brandId));
await logAuditEvent({
brandId,
actorId: adminUser.user_id ?? null,
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
action: "regenerate_admin_pin",
entityType: "settings",
});
return { success: true, pin };
});
}
/**
* PIN verify for the `/water/admin` portal. Creates a session row and
* sets the `wl_admin_session` cookie.
*/
export async function verifyWaterAdminPin(
brandId: string,
pin: string,
): Promise<{ success: boolean; session_id?: string; error?: string }> {
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterAdminSettings)
.where(eq(waterAdminSettings.brandId, brandId))
.limit(1);
const settings = rows[0];
if (!settings) {
return { success: false, error: "Admin portal not configured" };
}
if (!settings.enabled) {
return { success: false, error: "Admin portal is disabled" };
}
if (!settings.pinHash) {
return { success: false, error: "No PIN configured — generate one in /admin/water-log/settings" };
}
if (!verifyPin(pin, settings.pinHash)) {
await new Promise((r) => setTimeout(r, 200));
return { success: false, error: "Invalid PIN" };
}
// Tie the session to the calling site admin (best-effort).
const adminUser = await getAdminUser();
const expiresAt = new Date(
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
);
const [session] = await db
.insert(waterAdminSessions)
.values({
brandId,
adminUserId: adminUser?.user_id ?? adminUser?.id ?? "00000000-0000-0000-0000-000000000000",
pinHashUsed: settings.pinHash,
expiresAt,
})
.returning({ id: waterAdminSessions.id });
if (!session) return { success: false, error: "Failed to create session" };
const cookieStore = await cookies();
cookieStore.set("wl_admin_session", session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: settings.sessionDurationHours * 3600,
path: "/",
});
return { success: true, session_id: session.id };
});
} }
-18
View File
@@ -1,18 +0,0 @@
import { getAdminUser } from "@/lib/admin-permissions";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import CommandCenterDashboard from "@/components/admin/CommandCenterDashboard";
import { redirect } from "next/navigation";
export default async function CommandCenterPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (adminUser.role !== "platform_admin") return <AdminAccessDenied message="Platform admin access required." />;
return (
<main className="min-h-screen bg-black px-4 py-8">
<div className="mx-auto max-w-[1600px]">
<CommandCenterDashboard />
</div>
</main>
);
}
+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>
+29
View File
@@ -2,9 +2,25 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getShippingOrders } from "@/actions/shipping"; import { getShippingOrders } from "@/actions/shipping";
import ShippingFulfillmentPanel from "@/components/admin/ShippingFulfillmentPanel"; import ShippingFulfillmentPanel from "@/components/admin/ShippingFulfillmentPanel";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const TruckIcon = () => (
<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="M5 17h14M5 17a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM19 17a2 2 0 1 0 4 0 2 2 0 0 0-4 0Z" />
<path d="M14 17V5H3v12M14 9h4l3 4v4" />
</svg>
);
export default async function ShippingFulfillmentPage() { export default async function ShippingFulfillmentPage() {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />; if (!adminUser) return <AdminAccessDenied />;
@@ -12,9 +28,22 @@ export default async function ShippingFulfillmentPage() {
const { orders } = await getShippingOrders(); const { orders } = await getShippingOrders();
return ( return (
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader
title="Shipping Fulfillment"
subtitle="Create FedEx labels, print packing slips, and track outbound shipments."
icon={<TruckIcon />}
/>
</div>
<div className="px-4 sm:px-6 md:px-8 pb-6 sm:pb-8">
<ShippingFulfillmentPanel <ShippingFulfillmentPanel
initialOrders={orders ?? []} initialOrders={orders ?? []}
canManageOrders={adminUser.can_manage_orders ?? false} canManageOrders={adminUser.can_manage_orders ?? false}
/> />
</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>
); );
} }
+237 -160
View File
@@ -1,25 +1,43 @@
"use client"; "use client";
/**
* /admin/water-log/settings
*
* Site-admin-facing settings page for the Water Log module. Controls:
* - Whether `/water/admin` is enabled
* - 4-digit admin PIN (regenerate on demand)
* - Session duration (hours)
* - Per-coarse-permission flags (edit / delete / export)
* - High/low alert phone + enable flag
*
* Visual language: Field Almanac. Same cream + forest palette as
* the main WaterLogAdminPanel, but trimmed to a single column for
* a focused "form" feel.
*/
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { getWaterAdminSettings, saveWaterAdminSettings, type WaterAdminSettings } from "@/actions/water-log/settings"; import {
import { useRouter } from "next/navigation"; getWaterAdminSettings,
saveWaterAdminSettings,
regenerateAdminPin,
type AdminSettings,
} from "@/actions/water-log/settings";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export default function WaterLogSettingsPage() { export default function WaterLogSettingsPage() {
const router = useRouter(); const [settings, setSettings] = useState<AdminSettings | null>(null);
const [settings, setSettings] = useState<WaterAdminSettings | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [revealedPin, setRevealedPin] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Form state // Form state
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [pin, setPin] = useState("");
const [confirmPin, setConfirmPin] = useState("");
const [sessionDuration, setSessionDuration] = useState(12); const [sessionDuration, setSessionDuration] = useState(12);
const [canEdit, setCanEdit] = useState(true); const [canEdit, setCanEdit] = useState(true);
const [canDelete, setCanDelete] = useState(false); const [canDelete, setCanDelete] = useState(true);
const [canExport, setCanExport] = useState(true); const [canExport, setCanExport] = useState(true);
const [alertPhone, setAlertPhone] = useState(""); const [alertPhone, setAlertPhone] = useState("");
const [alertsEnabled, setAlertsEnabled] = useState(false); const [alertsEnabled, setAlertsEnabled] = useState(false);
@@ -30,239 +48,298 @@ export default function WaterLogSettingsPage() {
if (data) { if (data) {
setSettings(data); setSettings(data);
setEnabled(data.enabled); setEnabled(data.enabled);
setSessionDuration(data.session_duration_hours); setSessionDuration(data.sessionDurationHours);
setCanEdit(data.can_edit_entries); setCanEdit(data.canEditEntries);
setCanDelete(data.can_delete_entries); setCanDelete(data.canDeleteEntries);
setCanExport(data.can_export_csv); setCanExport(data.canExportCsv);
setAlertPhone(data.alert_phone ?? ""); setAlertPhone(data.alertPhone ?? "");
setAlertsEnabled(data.alerts_enabled ?? false); setAlertsEnabled(data.alertsEnabled);
} }
setLoading(false); setLoading(false);
}, []); }, []);
useEffect(() => { useEffect(() => {
const init = async () => { // Load on mount — setState-in-effect is intentional here
await loadSettings(); // because we're hydrating from server data.
}; // eslint-disable-next-line react-hooks/set-state-in-effect
init(); void loadSettings();
}, [loadSettings]); }, [loadSettings]);
async function handleSave(e: React.FormEvent) { async function handleSave(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (enabled && pin && pin !== confirmPin) {
setMessage({ type: "error", text: "PINs do not match" });
return;
}
if (enabled && pin && (pin.length !== 4 || !/^\d{4}$/.test(pin))) {
setMessage({ type: "error", text: "PIN must be exactly 4 digits" });
return;
}
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, { const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
enabled, enabled,
pin: pin || undefined, sessionDurationHours: sessionDuration,
session_duration_hours: sessionDuration, canEditEntries: canEdit,
can_edit_entries: canEdit, canDeleteEntries: canDelete,
can_delete_entries: canDelete, canExportCsv: canExport,
can_export_csv: canExport, alertPhone: alertPhone || null,
alert_phone: alertPhone || null, alertsEnabled,
alerts_enabled: alertsEnabled,
}); });
if (result.success) { if (result.success) {
setMessage({ type: "success", text: "Settings saved" }); setMessage({ type: "success", text: "Settings saved" });
setPin(""); if (result.settings) setSettings(result.settings);
setConfirmPin("");
} else { } else {
setMessage({ type: "error", text: result.error ?? "Save failed" }); setMessage({ type: "error", text: result.error ?? "Save failed" });
} }
setSaving(false); setSaving(false);
} }
async function handleRegenerate() {
if (!confirm("Regenerate the admin PIN? All current admin sessions will be signed out.")) {
return;
}
setRegenerating(true);
setMessage(null);
const result = await regenerateAdminPin(TUXEDO_BRAND_ID);
if (result.success && result.pin) {
setRevealedPin(result.pin);
setMessage({
type: "success",
text: "New PIN generated. Save it now — it will not be shown again.",
});
} else {
setMessage({ type: "error", text: result.error ?? "Failed to regenerate PIN" });
}
setRegenerating(false);
}
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center"> <div className="min-h-screen bg-[#fdfaf2] flex items-center justify-center font-sans text-[#1d1d1f]">
<span className="text-zinc-500">Loading...</span> <span className="text-[#5a5d5a]">Loading</span>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-zinc-950"> <div className="min-h-screen bg-[#fdfaf2] font-sans text-[#1d1d1f]">
{/* Header */} {/* Header */}
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4"> <div className="border-b border-[#d4d9d3] bg-white">
<div className="mx-auto max-w-lg"> <div className="mx-auto max-w-2xl px-6 py-6">
<div className="flex items-center gap-3"> <p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">§ 04 Settings</p>
<button onClick={() => router.back()} className="text-zinc-500 hover:text-zinc-400 text-sm"> Back</button> <h1
<div> className="mt-2 text-3xl font-medium text-[#1a4d2e]"
<h1 className="text-xl font-bold text-zinc-100">Water Log Admin Portal</h1> style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
<p className="text-xs text-zinc-500 mt-0.5">Configure PIN access for the field admin portal at /water/admin</p> >
</div> Water Log · Admin Portal
</div> </h1>
<p className="mt-2 text-sm text-[#5a5d5a]">
Configure PIN access for the field admin portal at <code className="rounded bg-[#f4f1e8] px-1 py-0.5 font-mono text-xs">/water/admin</code>.
This is separate from the platform admin login.
</p>
</div> </div>
</div> </div>
<div className="mx-auto max-w-lg px-6 py-6"> <div className="mx-auto max-w-2xl px-6 py-8">
<form onSubmit={handleSave} className="space-y-6"> <form onSubmit={handleSave} className="space-y-6">
{message && ( {message && (
<div className={`rounded-xl px-4 py-3 text-sm font-semibold ${message.type === "success" ? "bg-green-900/40 text-green-800 border border-green-200" : "bg-red-900/40 text-red-800 border border-red-200"}`}> <div
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
message.type === "success"
? "border-[#1a4d2e] bg-[#1a4d2e]/5 text-[#1a4d2e]"
: "border-[#a4452b] bg-[#a4452b]/5 text-[#a4452b]"
}`}
>
{message.text} {message.text}
</div> </div>
)} )}
{/* Enable toggle */} {/* Enable toggle */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5"> <Card title="Admin Portal" subtitle="PIN-based access to /water/admin">
<div className="flex items-center justify-between"> <ToggleRow
<div> label="Enable"
<p className="font-semibold text-zinc-100">Enable Admin Portal</p> description="Allow water admins to sign in with a 4-digit PIN."
<p className="text-xs text-zinc-500 mt-0.5">Allow PIN-based access to /water/admin (separate from platform login)</p> value={enabled}
</div> onChange={setEnabled}
<button />
type="button" </Card>
onClick={() => setEnabled((v) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? "bg-green-500" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
</div>
{enabled && ( {enabled && (
<> <>
{/* PIN */} {/* PIN */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4"> <Card title="Admin PIN" subtitle="4-digit PIN required for /water/admin sign-in">
<p className="font-semibold text-zinc-100">4-Digit PIN</p> {revealedPin ? (
<p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <p className="text-xs font-mono uppercase tracking-wider text-[#8a6b3b]">
<div> New PIN write it down
<label htmlFor="water-new-pin" className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label> </p>
<input <div className="rounded-lg border border-[#1a4d2e] bg-[#1a4d2e]/5 px-6 py-4 text-center">
id="water-new-pin" <span
type="password" className="font-mono text-4xl font-bold tracking-[0.4em] text-[#1a4d2e]"
inputMode="numeric" aria-label={`PIN: ${revealedPin.split("").join(" ")}`}
pattern="[0-9]*" >
maxLength={4} {revealedPin}
value={pin} </span>
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder="••••"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
style={{ letterSpacing: "0.4em" }}
/>
</div>
<div>
<label htmlFor="water-confirm-pin" className="block text-xs font-medium text-zinc-400 mb-1">Confirm PIN</label>
<input
id="water-confirm-pin"
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={confirmPin}
onChange={(e) => setConfirmPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder="••••"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
style={{ letterSpacing: "0.4em" }}
/>
</div> </div>
<button
type="button"
onClick={() => setRevealedPin(null)}
className="text-xs font-medium text-[#5a5d5a] underline-offset-2 hover:underline"
>
I&apos;ve saved it dismiss
</button>
</div> </div>
) : (
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-[#5a5d5a]">
{settings?.pin === null
? "Generate a new PIN to give to your water admin."
: "A PIN is already configured. Regenerate to issue a new one (this signs out existing admin sessions)."}
</p>
<button
type="button"
onClick={handleRegenerate}
disabled={regenerating}
className="shrink-0 rounded-lg border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#143d24] disabled:opacity-50"
>
{regenerating ? "Generating…" : "Regenerate PIN"}
</button>
</div> </div>
)}
</Card>
{/* Session Duration */} {/* Session Duration */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3"> <Card title="Session Duration" subtitle="How long the admin session lasts after sign-in (1168 hours)">
<p className="font-semibold text-zinc-100">Session Duration</p> <div className="flex items-center gap-4">
<p className="text-xs text-zinc-500">How long the admin session lasts after login (172 hours).</p>
<div className="flex items-center gap-3">
<input <input
type="range" type="range"
min={1} min={1}
max={72} max={168}
value={sessionDuration} value={sessionDuration}
onChange={(e) => setSessionDuration(parseInt(e.target.value))} onChange={(e) => setSessionDuration(parseInt(e.target.value, 10))}
className="flex-1" className="flex-1 accent-[#1a4d2e]"
aria-label="Session duration in hours"
/> />
<span className="w-16 text-center text-sm font-bold text-zinc-100">{sessionDuration}h</span> <span className="w-20 rounded border border-[#d4d9d3] bg-white px-3 py-1.5 text-center font-mono text-sm font-semibold">
</div> {sessionDuration}h
</span>
</div> </div>
</Card>
{/* Permissions */} {/* Permissions */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3"> <Card title="Permissions" subtitle="Coarse-grained flags. The PIN sign-in gates the portal; these flags gate specific actions.">
<p className="font-semibold text-zinc-100">Permissions</p> <div className="divide-y divide-[#d4d9d3]">
{[ <ToggleRow
{ key: "canEdit", label: "Edit entries", desc: "Allow modifying existing log entries", value: canEdit, setter: setCanEdit }, label="Edit entries"
{ key: "canDelete", label: "Delete entries", desc: "Allow removing log entries", value: canDelete, setter: setCanDelete }, description="Allow modifying existing log entries."
{ key: "canExport", label: "Export CSV", desc: "Allow exporting water log data", value: canExport, setter: setCanExport }, value={canEdit}
].map(({ key, label, desc, value, setter }) => ( onChange={setCanEdit}
<div key={key} className="flex items-center justify-between"> />
<div> <ToggleRow
<p className="text-sm font-medium text-zinc-200">{label}</p> label="Delete entries"
<p className="text-xs text-zinc-500">{desc}</p> description="Allow removing log entries."
</div> value={canDelete}
<button onChange={setCanDelete}
type="button" />
onClick={() => setter((v: boolean) => !v)} <ToggleRow
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${value ? "bg-green-500" : "bg-stone-300"}`} label="Export CSV"
> description="Allow exporting water log data."
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${value ? "translate-x-6" : "translate-x-1"}`} /> value={canExport}
</button> onChange={setCanExport}
</div> />
))}
</div> </div>
</Card>
{/* High/Low Alerts */} {/* High/Low Alerts */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4"> <Card title="High / Low Alerts" subtitle="SMS a phone when a reading exceeds a headgate's thresholds.">
<p className="font-semibold text-zinc-100">High/Low Alerts</p> <ToggleRow
<p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate&apos;s thresholds.</p> label="Enable alerts"
description="Threshold breach notifications."
<div className="flex items-center justify-between"> value={alertsEnabled}
<div> onChange={setAlertsEnabled}
<p className="text-sm font-medium text-zinc-200">Enable Alerts</p> />
<p className="text-xs text-zinc-500">SMS on threshold breach</p>
</div>
<button
type="button"
onClick={() => setAlertsEnabled((v) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${alertsEnabled ? "bg-green-500" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${alertsEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
{alertsEnabled && ( {alertsEnabled && (
<div> <div className="mt-4">
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-zinc-400 mb-1">Alert Phone Number</label> <label htmlFor="water-alert-phone" className="block text-xs font-medium text-[#5a5d5a]">
Alert phone number
</label>
<input <input
id="water-alert-phone" id="water-alert-phone"
type="tel" type="tel"
value={alertPhone} value={alertPhone}
onChange={(e) => setAlertPhone(e.target.value)} onChange={(e) => setAlertPhone(e.target.value)}
placeholder="+1234567890" placeholder="+13035551234"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900" className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 text-base outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
autoComplete="tel" autoComplete="tel"
/> />
<p className="text-xs text-zinc-500 mt-1">U.S. format recommended. Must include country code (e.g. +1 for USA).</p> <p className="mt-1 text-xs text-[#8a8b88]">
</div> U.S. format recommended. Include country code (e.g. <span className="font-mono">+1</span> for USA).
)}
</div>
{/* QR hint */}
<div className="rounded-xl bg-amber-50 border border-amber-100 p-4">
<p className="text-xs text-amber-700">
<strong>QR Lock:</strong> Irrigators can lock a headgate by visiting <code className="bg-amber-900/40 px-1 rounded">/water?h={'{headgate_token}'}</code>.
</p> </p>
</div> </div>
)}
</Card>
</> </>
)} )}
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
className="w-full rounded-xl bg-stone-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50" className="w-full rounded-lg bg-[#1a4d2e] px-6 py-3.5 text-sm font-semibold uppercase tracking-wider text-white transition hover:bg-[#143d24] disabled:opacity-50"
> >
{saving ? "Saving..." : "Save Settings"} {saving ? "Saving" : "Save Settings"}
</button> </button>
</form> </form>
</div> </div>
</div> </div>
); );
} }
function Card({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-xl border border-[#d4d9d3] bg-white p-5 shadow-sm">
<header className="mb-4">
<h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>}
</header>
{children}
</section>
);
}
function ToggleRow({
label,
description,
value,
onChange,
}: {
label: string;
description?: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-[#1d1d1f]">{label}</p>
{description && <p className="mt-0.5 text-xs text-[#5a5d5a]">{description}</p>}
</div>
<button
type="button"
role="switch"
aria-checked={value}
aria-label={label}
onClick={() => onChange(!value)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
);
}
+9 -3
View File
@@ -68,13 +68,19 @@ export async function GET(request: Request) {
for (const contact of contacts) { for (const contact of contacts) {
if (!contact.email) continue; if (!contact.email) continue;
const ok = await sendCampaignEmail({ const result = await sendCampaignEmail({
to: contact.email, to: contact.email,
subject: campaign.subject ?? campaign.name, subject: campaign.subject ?? campaign.name,
html: campaign.body_html, html: campaign.body_html,
}); });
if (ok) sent++; if (result.ok) sent++;
else errors++; else {
errors++;
console.error(
`[cron/send-scheduled] campaign ${campaign.id} contact ${contact.email}:`,
result.error,
);
}
} }
// 3. Mark campaign as sent // 3. Mark campaign as sent
+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();
+34 -40
View File
@@ -1,52 +1,46 @@
/**
* POST /api/water-admin-auth
*
* Body: { brandId: string, pin: string }
* Side effect: sets the `wl_admin_session` cookie on success.
*
* Used by the mobile/PIN-only `/water/admin/login` portal. The session
* itself is created by `verifyWaterAdminPin()` in
* `src/actions/water-log/settings.ts`, which is the single source of
* truth for admin PIN verification.
*/
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { cookies } from "next/headers"; import { verifyWaterAdminPin } from "@/actions/water-log/settings";
import { pool } from "@/lib/db"; import { captureError } from "@/lib/sentry";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const { brandId, pin } = await request.json(); const { brandId, pin } = (await request.json()) as {
brandId?: string;
pin?: string;
};
if (!brandId || !pin) { if (!brandId || !pin) {
return NextResponse.json({ success: false, error: "Missing params" }, { status: 400 }); return NextResponse.json(
} { success: false, error: "Missing brandId or pin" },
{ status: 400 },
// Get admin settings
const settingsRes = await pool.query<{
get_water_admin_settings: { enabled: boolean; session_duration_hours?: number } | null;
}>(
`SELECT get_water_admin_settings($1) AS "get_water_admin_settings"`,
[brandId],
); );
const settings = settingsRes.rows[0]?.get_water_admin_settings;
if (!settings?.enabled) {
return NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 });
} }
const result = await verifyWaterAdminPin(brandId, pin);
// Verify PIN if (!result.success) {
const verifyRes = await pool.query<{ // Don't leak whether the PIN was wrong vs. not configured — both
verify_water_admin_pin: { success: boolean; session_id?: string } | null; // are the same to a field attacker probing the endpoint.
}>( const status = result.error === "Invalid PIN" ? 401 : 403;
`SELECT verify_water_admin_pin($1, $2) AS "verify_water_admin_pin"`, return NextResponse.json(
[brandId, pin], { success: false, error: "Invalid PIN" },
{ status },
); );
const verifyData = verifyRes.rows[0]?.verify_water_admin_pin;
if (!verifyData?.success || !verifyData.session_id) {
return NextResponse.json({ success: false, error: "Invalid PIN" }, { status: 401 });
} }
// Create session cookie
const sessionId = verifyData.session_id;
const cookieStore = await cookies();
const durationHours = settings.session_duration_hours ?? 4;
cookieStore.set("wl_admin_session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: durationHours * 3600,
path: "/",
});
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch { } catch (err) {
return NextResponse.json({ success: false, error: "Server error" }, { status: 500 }); captureError(err as Error, { path: "/api/water-admin-auth" });
return NextResponse.json(
{ success: false, error: "Server error" },
{ status: 500 },
);
} }
} }
+61 -35
View File
@@ -1,60 +1,86 @@
/**
* GET /api/water-logs/export
*
* Streams the water log as JSON or CSV. Admin-only checked via
* `getAdminUser()` + `can_manage_water_log`. Brand comes from the admin
* session, with Tuxedo fallback for backward compat.
*
* GET /api/water-logs/export?format=csv
*/
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db"; import { getWaterEntries } from "@/actions/water-log/admin";
import type { WaterLogReportRow } from "@/lib/water-log-reporting";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) { if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
if (!adminUser.can_manage_water_log) { if (!adminUser.can_manage_water_log) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const format = searchParams.get("format") ?? "json"; const format = searchParams.get("format") ?? "json";
const brandId =
adminUser.brand_id ??
process.env.TUXEDO_BRAND_ID ??
TUXEDO_BRAND_ID;
// Use brand_id from session (always Tuxedo for water log) or fallback to env const raw = await getWaterEntries(brandId, 10000);
const brandId = adminUser.brand_id ?? process.env.TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"; const rows: WaterLogReportRow[] = raw.map((r) => ({
logged_at: r.logged_at,
type WaterEntry = { headgate_name: r.headgate_name,
id: string; user_name: r.user_name,
user_id: string | null; user_role: "irrigator",
headgate_id: string | null; measurement: r.measurement,
measurement: number | null; unit: r.unit,
unit: string | null; notes: r.notes,
notes: string | null; submitted_via: r.submitted_via,
created_at: string; }));
};
const { rows: data } = await pool.query<{ get_water_entries: WaterEntry[] | null }>(
`SELECT get_water_entries($1, $2) AS "get_water_entries"`,
[brandId, 10000],
);
const entries = data[0]?.get_water_entries ?? [];
if (format === "csv") { if (format === "csv") {
const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"]; const headers = [
const csvRows = [headers.join(",")]; "When",
for (const row of entries) { "Headgate",
csvRows.push([ "User",
row.id, "Measurement",
row.user_id ?? "", "Unit",
row.headgate_id ?? "", "Total Gallons",
row.measurement ?? "", "Method",
row.unit ?? "", "Notes",
`"${(row.notes ?? "").replace(/"/g, '""')}"`, "Via",
row.created_at ?? "", "Photo URL",
].join(",")); ];
const esc = (s: string | null | undefined) =>
`"${(s ?? "").replace(/"/g, '""')}"`;
const lines: string[] = [headers.join(",")];
for (const e of raw) {
lines.push(
[
esc(e.logged_at),
esc(e.headgate_name),
esc(e.user_name),
String(e.measurement),
esc(e.unit),
e.total_gallons != null ? String(e.total_gallons) : "",
esc(e.method),
esc(e.notes),
esc(e.submitted_via),
esc(e.photo_url),
].join(","),
);
} }
return new NextResponse(csvRows.join("\n"), { return new NextResponse(lines.join("\n"), {
headers: { headers: {
"Content-Type": "text/csv", "Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="water-log-${new Date().toISOString().slice(0, 10)}.csv"`, "Content-Disposition": `attachment; filename="water-log-${new Date().toISOString().slice(0, 10)}.csv"`,
}, },
}); });
} }
return NextResponse.json(entries); return NextResponse.json({ rows });
} }
+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>
); );
} }
+195 -32
View File
@@ -177,6 +177,89 @@ 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.
*/
/* Route fade pure opacity, no slide. The old "translateY(6px) on enter,
* translateY(-4px) on exit" was a classic multi-axis vestibular trigger.
* 140ms total is the sweet spot: barely there, but softens the cut. */
::view-transition-old(page-content) {
animation: route-fade-out 90ms ease-out both;
}
::view-transition-new(page-content) {
animation: route-fade-in 140ms ease-out both;
}
@keyframes route-fade-out {
to { opacity: 0; }
}
@keyframes route-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* 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. */
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
* but flattens everything that fights vestibular comfort. This block wipes:
* - all CSS animations and transitions across the app
* - all view transitions and route fades
* - GSAP, framer-motion, and any scroll-driven transforms (those need JS-side guards
* in their components, but the CSS-side transforms inherit this via inheritance).
* If you want even less, just set prefers-reduced-motion: reduce in your OS and
* every animation in the app turns off. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
::view-transition-old(page-content),
::view-transition-new(page-content) {
animation: none !important;
}
@keyframes transition-shimmer {
0%, 100% { transform: translateX(0); }
}
.atelier-enter,
.atelier-backdrop,
.atelier-stagger > *,
.atelier-cta .atelier-cta-shimmer,
.parallax-float,
.animate-bounce,
.hero-reveal {
animation: none !important;
transition: none !important;
}
.atelier-stagger > *,
.parallax-float,
.hero-reveal {
opacity: 1 !important;
transform: none !important;
}
/* Scroll progress bar (Tuxedo hero) — no animated width when motion is off */
.scroll-progress-bar {
transition: 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,
@@ -226,7 +309,7 @@ select:-webkit-autofill:focus {
box-shadow: box-shadow:
0 16px 48px rgba(0, 0, 0, 0.08), 0 16px 48px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.9); inset 0 1px 0 rgba(255, 255, 255, 0.9);
transform: translateY(-2px); transform: translateY(-1px);
} }
/* ─── Premium card - Light Theme ─────────────────────────────────────── */ /* ─── Premium card - Light Theme ─────────────────────────────────────── */
@@ -257,10 +340,10 @@ select:-webkit-autofill:focus {
0 8px 32px rgba(0, 0, 0, 0.06), 0 8px 32px rgba(0, 0, 0, 0.06),
0 0 0 1px rgba(26, 77, 46, 0.08), 0 0 0 1px rgba(26, 77, 46, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.9); inset 0 1px 0 rgba(255, 255, 255, 0.9);
transform: translateY(-2px); transform: translateY(-1px);
} }
.card:active { .card:active {
transform: scale(0.98) translateY(0); transform: scale(0.995) translateY(0);
} }
.card-dark { .card-dark {
@@ -313,10 +396,11 @@ select:-webkit-autofill:focus {
box-shadow: box-shadow:
0 8px 20px rgba(26, 77, 46, 0.4), 0 8px 20px rgba(26, 77, 46, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.15); inset 0 1px 0 rgba(255, 255, 255, 0.15);
transform: translateY(-1px); /* No vertical lift on hover was a 1px translate that compounded with the gradient
* shift and the inset highlight to read as jittery on the eye. */
} }
.btn-primary:active { .btn-primary:active {
transform: scale(0.98) translateY(0); transform: scale(0.995);
} }
.btn-primary-green { .btn-primary-green {
@@ -342,7 +426,6 @@ select:-webkit-autofill:focus {
box-shadow: box-shadow:
0 8px 20px rgba(22, 163, 74, 0.4), 0 8px 20px rgba(22, 163, 74, 0.4),
inset 0 1px 0 rgba(255,255,255,0.2); inset 0 1px 0 rgba(255,255,255,0.2);
transform: translateY(-1px);
} }
/* ─── Gradient text ───────────────────────────────────────────── */ /* ─── Gradient text ───────────────────────────────────────────── */
@@ -665,13 +748,12 @@ select:-webkit-autofill:focus {
.atelier-type-card:hover { .atelier-type-card:hover {
border-color: rgba(34, 78, 47, 0.25); border-color: rgba(34, 78, 47, 0.25);
background: rgba(255, 255, 255, 0.85); background: rgba(255, 255, 255, 0.85);
transform: translateY(-1px);
} }
.atelier-type-card:hover::before { .atelier-type-card:hover::before {
opacity: 1; opacity: 1;
} }
.atelier-type-card:active { .atelier-type-card:active {
transform: translateY(0) scale(0.985); transform: scale(0.995);
} }
.atelier-type-card.is-selected { .atelier-type-card.is-selected {
background: linear-gradient(135deg, #224E2F 0%, #14532D 100%); background: linear-gradient(135deg, #224E2F 0%, #14532D 100%);
@@ -683,11 +765,13 @@ select:-webkit-autofill:focus {
} }
.atelier-type-card .atelier-type-icon { .atelier-type-card .atelier-type-icon {
color: #786B53; color: #786B53;
transition: color 220ms ease, transform 220ms ease; transition: color 180ms ease;
} }
.atelier-type-card.is-selected .atelier-type-icon { .atelier-type-card.is-selected .atelier-type-icon {
color: #FCD34D; color: #FCD34D;
transform: scale(1.06); /* Removed the 1.06 scale on selected the color change alone communicates the
* selection. Scale + color change in the same beat read as "double confirmation",
* which adds up when many cards animate at once. */
} }
.atelier-type-card .atelier-type-name { .atelier-type-card .atelier-type-name {
font-family: var(--font-fraunces); font-family: var(--font-fraunces);
@@ -704,12 +788,11 @@ select:-webkit-autofill:focus {
.atelier-type-card .atelier-type-check { .atelier-type-card .atelier-type-check {
opacity: 0; opacity: 0;
color: #FCD34D; color: #FCD34D;
transform: scale(0.6); transition: opacity 180ms ease;
transition: opacity 200ms ease, transform 200ms ease;
} }
.atelier-type-card.is-selected .atelier-type-check { .atelier-type-card.is-selected .atelier-type-check {
opacity: 1; opacity: 1;
transform: scale(1); /* Removed the scale(0.6 → 1) on the checkmark — pure opacity fade is calmer. */
} }
/* Pill toggle — Active / Taxable */ /* Pill toggle — Active / Taxable */
@@ -746,7 +829,11 @@ select:-webkit-autofill:focus {
background: #FFFFFF; background: #FFFFFF;
border-radius: 999px; border-radius: 999px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1); /* Was cubic-bezier(0.34, 1.56, 0.64, 1) the bouncy overshoot ease that
* overshoots ~13% past the target then settles. Looks playful on a static
* design, but in a real product it reads as twitchy and contributes to the
* sense of "too much movement". */
transition: transform 160ms ease-out;
} }
.atelier-toggle.is-on .atelier-toggle-thumb { .atelier-toggle.is-on .atelier-toggle-thumb {
transform: translateX(16px); transform: translateX(16px);
@@ -876,35 +963,38 @@ select:-webkit-autofill:focus {
color: #A8A29E; color: #A8A29E;
} }
/* Modal enter animation */ /* Modal enter animation calmer version. The previous 420ms scale + 20px slide
* with 80-440ms cumulative stagger delays meant the modal kept moving for nearly
* a full second. Now: 180ms opacity-only fade-in, 4px max, no scale, no stagger. */
@keyframes atelier-enter { @keyframes atelier-enter {
from { opacity: 0; transform: translateY(20px) scale(0.96); } from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translateY(0); }
} }
@keyframes atelier-backdrop { @keyframes atelier-backdrop {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
@keyframes atelier-stagger { @keyframes atelier-stagger {
from { opacity: 0; transform: translateY(8px); } from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
.atelier-enter { .atelier-enter {
animation: atelier-enter 420ms cubic-bezier(0.22, 1, 0.36, 1) both; animation: atelier-enter 180ms ease-out both;
} }
.atelier-backdrop { .atelier-backdrop {
animation: atelier-backdrop 300ms ease both; animation: atelier-backdrop 140ms ease-out both;
} }
.atelier-stagger > * { .atelier-stagger > * {
animation: atelier-stagger 480ms cubic-bezier(0.22, 1, 0.36, 1) both; animation: atelier-stagger 180ms ease-out both;
/* No more 80-440ms cumulative delays — items appear together, not as a parade. */
} }
.atelier-stagger > *:nth-child(1) { animation-delay: 80ms; } .atelier-stagger > *:nth-child(1) { animation-delay: 0ms; }
.atelier-stagger > *:nth-child(2) { animation-delay: 140ms; } .atelier-stagger > *:nth-child(2) { animation-delay: 30ms; }
.atelier-stagger > *:nth-child(3) { animation-delay: 200ms; } .atelier-stagger > *:nth-child(3) { animation-delay: 60ms; }
.atelier-stagger > *:nth-child(4) { animation-delay: 260ms; } .atelier-stagger > *:nth-child(4) { animation-delay: 90ms; }
.atelier-stagger > *:nth-child(5) { animation-delay: 320ms; } .atelier-stagger > *:nth-child(5) { animation-delay: 120ms; }
.atelier-stagger > *:nth-child(6) { animation-delay: 380ms; } .atelier-stagger > *:nth-child(6) { animation-delay: 150ms; }
.atelier-stagger > *:nth-child(7) { animation-delay: 440ms; } .atelier-stagger > *:nth-child(7) { animation-delay: 180ms; }
/* Primary CTA — gradient with subtle inner highlight */ /* Primary CTA — gradient with subtle inner highlight */
.atelier-cta { .atelier-cta {
@@ -929,7 +1019,7 @@ select:-webkit-autofill:focus {
0 6px 18px -6px rgba(20, 83, 45, 0.55), 0 6px 18px -6px rgba(20, 83, 45, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.14),
inset 0 -1px 0 rgba(0, 0, 0, 0.10); inset 0 -1px 0 rgba(0, 0, 0, 0.10);
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1); transition: background-position 220ms ease-out, box-shadow 180ms ease-out;
overflow: hidden; overflow: hidden;
} }
.atelier-cta:hover { .atelier-cta:hover {
@@ -938,10 +1028,9 @@ select:-webkit-autofill:focus {
0 10px 28px -8px rgba(20, 83, 45, 0.65), 0 10px 28px -8px rgba(20, 83, 45, 0.65),
inset 0 1px 0 rgba(255, 255, 255, 0.20), inset 0 1px 0 rgba(255, 255, 255, 0.20),
inset 0 -1px 0 rgba(0, 0, 0, 0.10); inset 0 -1px 0 rgba(0, 0, 0, 0.10);
transform: translateY(-1px);
} }
.atelier-cta:active { .atelier-cta:active {
transform: translateY(0) scale(0.985); transform: scale(0.995);
} }
.atelier-cta:disabled { .atelier-cta:disabled {
opacity: 0.5; opacity: 0.5;
@@ -958,7 +1047,9 @@ select:-webkit-autofill:focus {
height: 100%; height: 100%;
background: linear-gradient(105deg, transparent 30%, rgba(255, 255, 255, 0.18) 50%, transparent 70%); background: linear-gradient(105deg, transparent 30%, rgba(255, 255, 255, 0.18) 50%, transparent 70%);
pointer-events: none; pointer-events: none;
transition: left 700ms cubic-bezier(0.4, 0, 0.2, 1); /* Was 700ms the shimmer is decorative but a long linear slide on every
* hover draws the eye. 400ms keeps the highlight but doesn't linger. */
transition: left 400ms ease-out;
} }
.atelier-cta:hover .atelier-cta-shimmer { .atelier-cta:hover .atelier-cta-shimmer {
left: 130%; left: 130%;
@@ -1030,3 +1121,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>
);
} }
+172 -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,9 +12,12 @@ 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);
const [googleLoading, setGoogleLoading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(null);
async function handleSignIn() { async function handleSignIn() {
@@ -48,160 +51,236 @@ export default function LoginClient({ error }: LoginClientProps) {
} }
} }
async function handleGoogleSignIn() {
setGoogleLoading(true);
setLocalError(null);
try {
const { signInWithGoogleAction } = await import("@/actions/auth-actions");
const result = await signInWithGoogleAction({ callbackURL: REDIRECT_URL });
if (result.error || !result.url) {
setLocalError(
result.error ??
"Google sign-in is not available. Contact your administrator or sign in with email + password.",
);
setGoogleLoading(false);
return;
}
window.location.href = result.url;
} catch {
setLocalError("Google sign-in failed. Please try again.");
setGoogleLoading(false);
}
}
function handleDevLogin(role: string) { function handleDevLogin(role: string) {
// Set the dev_session cookie for local development bypass // Set the dev_session cookie for local development bypass
document.cookie = `dev_session=${role}; path=/; max-age=86400`; document.cookie = `dev_session=${role}; path=/; max-age=86400`;
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
>
{/* Google sign-in (primary path — works for both new and existing users) */}
<button
type="button"
onClick={handleGoogleSignIn}
disabled={googleLoading || loading}
className="atelier-cta atelier-cta-secondary w-full"
style={{ background: "white", color: "#1c1917", border: "1px solid #e7e5e4", boxShadow: "0 1px 2px rgba(0,0,0,0.04)" }}
>
{googleLoading ? (
<span className="relative z-10 inline-flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Redirecting to Google
</span>
) : (
<span className="relative z-10 inline-flex items-center gap-2.5">
<svg className="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.1c-.22-.66-.35-1.36-.35-2.1s.13-1.44.35-2.1V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.83z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Continue with Google
</span>
)}
</button>
{/* Divider */}
<div className="flex items-center gap-3" aria-hidden="true">
<div className="h-px flex-1 bg-stone-200" />
<span className="text-[10px] font-semibold tracking-[0.15em] uppercase text-stone-400" style={{ fontFamily: "var(--font-fragment-mono)" }}>
or
</span>
<div className="h-px flex-1 bg-stone-200" />
</div>
<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>
);
} }
+72
View File
@@ -53,6 +53,7 @@ export default function WholesaleLoginPage() {
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState({ email: "", password: "", brandId: BRANDS[1].id }); const [form, setForm] = useState({ email: "", password: "", brandId: BRANDS[1].id });
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [googleSubmitting, setGoogleSubmitting] = useState(false);
// Read ?error=... from the URL in the lazy initializer. Safe in a client // Read ?error=... from the URL in the lazy initializer. Safe in a client
// component since window is always defined here, and avoids a // component since window is always defined here, and avoids a
// set-state-in-effect on mount. // set-state-in-effect on mount.
@@ -69,6 +70,9 @@ export default function WholesaleLoginPage() {
if (err === "invalid_credentials") { if (err === "invalid_credentials") {
return "Invalid email or password. Please try again."; return "Invalid email or password. Please try again.";
} }
if (err === "oauth") {
return "Google sign-in failed or was cancelled. Please try again or use your password.";
}
return null; return null;
}); });
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({}); const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
@@ -77,6 +81,37 @@ export default function WholesaleLoginPage() {
// form.brandId is always a valid BRANDS id, so this lookup is O(n) over a 2-item array. // form.brandId is always a valid BRANDS id, so this lookup is O(n) over a 2-item array.
const selectedBrand = BRANDS.find(b => b.id === form.brandId) ?? BRANDS[1]; const selectedBrand = BRANDS.find(b => b.id === form.brandId) ?? BRANDS[1];
async function handleGoogleSignIn() {
setGoogleSubmitting(true);
setError(null);
try {
const { signInWithGoogleAction } = await import("@/actions/auth-actions");
const result = await signInWithGoogleAction({
callbackURL: "/wholesale/portal",
errorCallbackURL: "/wholesale/login?error=oauth",
});
if (result.error || !result.url) {
// TODO: Wholesale auth still runs on Supabase Auth. The Google
// button works for admins (who use Neon Auth); wholesale buyers
// who hit it will be redirected through Neon Auth and then
// bounced at /wholesale/portal because the session isn't
// recognized by the wholesale auth path. Once wholesale auth
// is migrated to Neon Auth, this will start working for them
// too.
setError(
result.error ??
"Google sign-in is available, but the wholesale portal is not yet wired up to it. Please use email + password for now.",
);
setGoogleSubmitting(false);
return;
}
window.location.href = result.url;
} catch {
setError("Google sign-in failed. Please try again.");
setGoogleSubmitting(false);
}
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setSubmitting(true); setSubmitting(true);
@@ -165,6 +200,43 @@ export default function WholesaleLoginPage() {
)} )}
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
{/* Google sign-in — works once wholesale auth migrates to Neon Auth */}
<button
type="button"
onClick={handleGoogleSignIn}
disabled={googleSubmitting || submitting}
className="w-full rounded-xl bg-white py-3.5 text-sm font-bold text-zinc-900 hover:bg-zinc-100 active:bg-zinc-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-zinc-300 flex items-center justify-center gap-2.5"
>
{googleSubmitting ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Redirecting to Google
</>
) : (
<>
<svg className="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.1c-.22-.66-.35-1.36-.35-2.1s.13-1.44.35-2.1V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.83z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Continue with Google
</>
)}
</button>
{/* Divider */}
<div className="flex items-center gap-3" aria-hidden="true">
<div className="h-px flex-1 bg-zinc-800" />
<span className="text-[10px] font-semibold tracking-[0.15em] uppercase text-zinc-500">
or
</span>
<div className="h-px flex-1 bg-zinc-800" />
</div>
<FormField label="Company" id="brand_id" error={null}> <FormField label="Company" id="brand_id" error={null}>
<select <select
id="brand_id" id="brand_id"
+14
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { MotionConfig } from "framer-motion";
import { CartProvider } from "@/context/CartContext"; import { CartProvider } from "@/context/CartContext";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import SiteHeader from "@/components/layout/SiteHeader"; import SiteHeader from "@/components/layout/SiteHeader";
@@ -28,6 +29,18 @@ export function Providers({ children }: { children: React.ReactNode }) {
// The visibility:hidden pattern was causing click handling issues on hidden elements // The visibility:hidden pattern was causing click handling issues on hidden elements
return ( return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false} disableTransitionOnChange> <ThemeProvider attribute="class" defaultTheme="light" enableSystem={false} disableTransitionOnChange>
{/*
* MotionConfig caps every framer-motion animation in the tree:
* - default transition is 0.2s easeOut (was usually 0.6-1s with custom cubic-bezier eases)
* - `reducedMotion="user"` makes framer-motion automatically strip x/y/scale/rotate
* from animations when the user has prefers-reduced-motion: reduce set, leaving
* only opacity. This is the single most important lever for vestibular comfort
* it overrides the 71 `initial={{ y: 48 }}` / `initial={{ scale: 0 }}` reveals
* across the public site without touching each file.
* Combined with the prefers-reduced-motion block in globals.css, this means a user
* with motion sensitivity gets the same content with zero positional movement.
*/}
<MotionConfig transition={{ duration: 0.2, ease: "easeOut" }} reducedMotion="user">
<CartProvider> <CartProvider>
{!isBrandRoute && !isLandingPage && !isAuthPage && !isAdminRoute && !isStandalonePage && <SiteHeader />} {!isBrandRoute && !isLandingPage && !isAuthPage && !isAdminRoute && !isStandalonePage && <SiteHeader />}
{children} {children}
@@ -35,6 +48,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
<CartToast /> <CartToast />
<CartRestoredToast /> <CartRestoredToast />
</CartProvider> </CartProvider>
</MotionConfig>
</ThemeProvider> </ThemeProvider>
); );
} }
+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" />
+1 -3
View File
@@ -80,9 +80,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
? [{ href: "/admin/route-trace", label: "Route Trace" }] ? [{ href: "/admin/route-trace", label: "Route Trace" }]
: []; : [];
const fullNavLinks = BASE_NAV_LINKS.concat( const fullNavLinks = BASE_NAV_LINKS.concat(moduleLinks);
userRole === "platform_admin" ? [{ href: "/admin/command-center", label: "Command Center" }] : []
).concat(moduleLinks);
const navLinks = isStoreEmployee ? EMPLOYEE_NAV_LINKS : fullNavLinks; const navLinks = isStoreEmployee ? EMPLOYEE_NAV_LINKS : fullNavLinks;
const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin"; const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin";
+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>
+313 -308
View File
@@ -1,225 +1,139 @@
"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,
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 },
],
},
{
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 +141,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 +151,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 +234,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 +345,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 +358,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 +390,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 +405,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 +501,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 }[];
@@ -1,622 +0,0 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect, useCallback } from "react";
import {
getPlatformMetrics,
getPlatformActivityFeed,
getBrandHealthSnapshot,
getPainLog,
resolvePainLogItem,
createPainLogItem,
type PlatformMetrics,
type ActivityEvent,
type BrandHealth,
type PainLogItem,
} from "@/actions/platform/command-center";
// ── Constants ─────────────────────────────────────────────────────────────────
const REFRESH_INTERVAL = 30000;
const EVENT_COLORS: Record<string, { bg: string; text: string; dot: string }> = {
order_placed: { bg: "bg-violet-500/20", text: "text-violet-300", dot: "bg-violet-400" },
order_completed: { bg: "bg-emerald-500/20", text: "text-emerald-300", dot: "bg-emerald-400" },
pickup_completed: { bg: "bg-blue-900/300/20", text: "text-blue-300", dot: "bg-blue-400" },
payment_failed: { bg: "bg-red-900/300/20", text: "text-red-300", dot: "bg-red-400" },
stop_created: { bg: "bg-amber-900/300/20", text: "text-amber-300", dot: "bg-amber-400" },
route_updated: { bg: "bg-cyan-500/20", text: "text-cyan-300", dot: "bg-cyan-400" },
default: { bg: "bg-zinc-900/10", text: "text-white/60", dot: "bg-zinc-900/40" },
};
const SEVERITY_CONFIG: Record<string, { bg: string; text: string; border: string }> = {
critical: { bg: "bg-red-900/300/15", text: "text-red-300", border: "border-red-500/30" },
high: { bg: "bg-orange-500/15",text: "text-orange-300", border: "border-orange-500/30" },
medium: { bg: "bg-amber-900/300/15",text: "text-amber-300", border: "border-amber-500/30" },
low: { bg: "bg-zinc-900/8", text: "text-white/60", border: "border-white/10" },
};
const HEALTH_CONFIG: Record<string, { label: string; bg: string; text: string; border: string; dot: string }> = {
healthy: { label: "Operational", bg: "bg-emerald-500/15", text: "text-emerald-300", border: "border-emerald-500/25", dot: "bg-emerald-400" },
warning: { label: "Attention", bg: "bg-amber-900/300/15", text: "text-amber-300", border: "border-amber-500/25", dot: "bg-amber-400" },
critical: { label: "Critical", bg: "bg-red-900/300/15", text: "text-red-300", border: "border-red-500/25", dot: "bg-red-400" },
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatCurrency(n: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency", currency: "USD",
minimumFractionDigits: 0, maximumFractionDigits: 0,
}).format(n);
}
function formatCurrencyFull(n: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency", currency: "USD",
minimumFractionDigits: 2, maximumFractionDigits: 2,
}).format(n);
}
function timeAgo(dateStr: string): string {
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (diff < 60) return `${diff}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400)return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
// ── Metric Card ─────────────────────────────────────────────────────────────────
function MetricCard({
label,
value,
sub,
prefix,
suffix,
status,
delay = 0,
tv = false,
}: {
label: string;
value: string | number;
sub?: string;
prefix?: string;
suffix?: string;
status?: "healthy" | "warning" | "critical";
delay?: number;
tv?: boolean;
}) {
const valueColor = {
healthy: "text-white",
warning: "text-amber-300",
critical: "text-red-300",
}[status ?? "healthy"]!;
return (
<div
className={`
rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-5
${status === "critical" ? "border-red-500/20" : ""}
`}
>
<p className={`text-[10px] font-semibold uppercase tracking-[0.18em] text-white/25`}>{label}</p>
<div className="mt-3 flex items-baseline gap-2">
{prefix && <span className="text-2xl text-white/25">{prefix}</span>}
<span className={`${tv ? "text-5xl" : "text-4xl"} font-black tracking-tighter leading-none ${valueColor}`}>
{typeof value === "number" ? value.toLocaleString() : value}
</span>
{suffix && <span className="text-2xl text-white/25">{suffix}</span>}
</div>
{sub && <p className={`text-xs mt-2 text-white/20`}>{sub}</p>}
</div>
);
}
// ── Brand Health Card ─────────────────────────────────────────────────────────
function BrandCard({ brand, tv = false }: { brand: BrandHealth; tv?: boolean }) {
const c = HEALTH_CONFIG[brand.health_status as keyof typeof HEALTH_CONFIG] ?? HEALTH_CONFIG.warning;
return (
<div className={`rounded-2xl border ${c.border} ${c.bg} p-6`}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<p className={`font-bold text-white truncate leading-tight ${tv ? "text-2xl" : "text-lg"}`}>{brand.brand_name}</p>
<p className={`text-sm text-white/20 mt-1`}>{brand.brand_slug}</p>
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<span className={`rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-wider ${c.bg} ${c.text} ${c.border}`}>
{c.label}
</span>
<div className={`h-2 w-2 rounded-full ${c.dot}`} />
</div>
</div>
<div className={`mt-6 grid grid-cols-3 gap-6`}>
{[
{ label: "Orders", value: brand.orders_today },
{ label: "Revenue", value: formatCurrency(Number(brand.revenue_today)) },
{ label: "Routes", value: brand.active_stops },
].map(({ label, value }) => (
<div key={label} className="text-center">
<p className={`text-xs font-medium text-white/20`}>{label}</p>
<p className={`${tv ? "text-2xl" : "text-xl"} font-black text-white mt-1 leading-none`}>{value}</p>
</div>
))}
</div>
{brand.open_pain_items > 0 && (
<div className="mt-4 flex items-center gap-2 rounded-xl bg-red-900/300/10 border border-red-500/20 px-3 py-2.5">
<span className="h-2 w-2 rounded-full bg-red-400" />
<span className={`text-sm font-semibold text-red-300`}>
{brand.open_pain_items} open issue{brand.open_pain_items !== 1 ? "s" : ""}
</span>
</div>
)}
</div>
);
}
// ── Activity Item ──────────────────────────────────────────────────────────────
function ActivityItem({ event }: { event: ActivityEvent }) {
const c = EVENT_COLORS[event.event_type] ?? EVENT_COLORS.default;
const label = event.event_type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase());
return (
<div className="flex items-start gap-3.5 py-3.5 border-b border-white/[0.03] last:border-0">
<div className="mt-1.5 flex-shrink-0">
<span className={`block h-2 w-2 rounded-full ${c.dot}`} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`rounded-md px-2 py-0.5 text-xs font-semibold ${c.bg} ${c.text}`}>
{label}
</span>
{event.brand_name && (
<span className={`text-xs font-medium text-white/25`}>{event.brand_name}</span>
)}
</div>
<div className="mt-1 flex items-center gap-3">
<p className={`text-sm text-white/40 truncate`}>
{event.entity_type && <span>{event.entity_type}</span>}
{event.entity_id && <span className="font-mono text-white/15 ml-1">#{event.entity_id.slice(0, 6)}</span>}
</p>
<span className={`text-xs text-white/15 shrink-0`}>
{event.created_at ? timeAgo(event.created_at) : ""}
</span>
</div>
</div>
</div>
);
}
// ── Pain Item ─────────────────────────────────────────────────────────────────
function PainItem({
item,
onResolve,
onSnooze,
}: {
item: PainLogItem;
onResolve: (id: string) => void;
onSnooze: (id: string) => void;
}) {
const c = SEVERITY_CONFIG[item.severity] ?? SEVERITY_CONFIG.low;
return (
<div className={`rounded-xl border ${c.border} ${c.bg} p-4`}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5 flex-wrap min-w-0">
<span className={`rounded-full border px-2.5 py-0.5 text-xs font-bold uppercase tracking-wider ${c.bg} ${c.text} ${c.border}`}>
{item.severity}
</span>
<span className={`text-sm font-semibold text-white/80 leading-tight`}>{item.title}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => onSnooze(item.id)}
className="rounded-lg bg-zinc-900/5 border border-white/10 px-2.5 py-1 text-xs font-medium text-white/35 hover:bg-zinc-900/10 hover:text-white/60 transition-all"
>
Snooze
</button>
<button
onClick={() => onResolve(item.id)}
className="rounded-lg bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 text-xs font-medium text-emerald-300 hover:bg-emerald-500/30 transition-all"
>
Done
</button>
</div>
</div>
<div className={`mt-2 flex items-center gap-2 text-xs text-white/25`}>
{item.brand_name && <span>{item.brand_name}</span>}
<span>·</span>
<span>{item.category}</span>
<span>·</span>
<span>{item.created_at ? timeAgo(item.created_at) + " ago" : ""}</span>
</div>
</div>
);
}
// ── AI Briefing ────────────────────────────────────────────────────────────────
function AIBriefing({ metrics, brandHealth }: {
metrics: PlatformMetrics | null;
brandHealth: BrandHealth[];
}) {
const lines: string[] = [];
if (!metrics) return <div className="h-16 animate-pulse bg-zinc-900/[0.04] rounded-xl" />;
if (metrics.orders_today > 0) {
lines.push(`${metrics.orders_today} order${metrics.orders_today !== 1 ? "s" : ""} placed today.`);
}
if (Number(metrics.revenue_today) > 0) {
lines.push(`${formatCurrencyFull(Number(metrics.revenue_today))} in revenue processed.`);
}
if (metrics.failed_orders_today > 0) {
lines.push(`${metrics.failed_orders_today} failed order${metrics.failed_orders_today !== 1 ? "s" : ""} need attention.`);
}
if (metrics.pending_orders_today > 0) {
lines.push(`${metrics.pending_orders_today} order${metrics.pending_orders_today !== 1 ? "s" : ""} pending confirmation.`);
}
const crit = brandHealth.filter(b => b.health_status === "critical").length;
if (crit > 0) lines.push(`${crit} brand${crit !== 1 ? "s" : ""} in critical state.`);
const warn = brandHealth.filter(b => b.health_status === "warning").length;
if (warn > 0 && crit === 0) lines.push(`${warn} brand${warn !== 1 ? "s" : ""} need attention.`);
if (lines.length === 0) {
lines.push("Platform is running smoothly — no active issues.");
}
return (
<div className="space-y-2">
{lines.map((line, i) => (
<p key={i} className="text-sm text-white/50 leading-relaxed">
{line}
</p>
))}
</div>
);
}
// ── Main ─────────────────────────────────────────────────────────────────────
export default function CommandCenterDashboard() {
const [metrics, setMetrics] = useState<PlatformMetrics | null>(null);
const [activity, setActivity] = useState<ActivityEvent[]>([]);
const [brandHealth, setBrandHealth] = useState<BrandHealth[]>([]);
const [painLog, setPainLog] = useState<PainLogItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [resolving, setResolving] = useState<Set<string>>(new Set());
const [showAddForm, setShowAddForm] = useState(false);
const [tvMode, setTvMode] = useState(false);
const [addForm, setAddForm] = useState({ severity: "medium", category: "operations", title: "", description: "" });
const [snoozed, setSnoozed] = useState<Set<string>>(new Set());
const tv = tvMode;
const fetchAll = useCallback(async () => {
try {
const [m, a, b, p] = await Promise.all([
getPlatformMetrics(),
getPlatformActivityFeed(),
getBrandHealthSnapshot(),
getPainLog(),
]);
setMetrics(m);
setActivity(a);
setBrandHealth(b);
setPainLog(p);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAll();
const iv = setInterval(fetchAll, REFRESH_INTERVAL);
return () => clearInterval(iv);
}, [fetchAll]);
const handleResolve = async (id: string) => {
setResolving(prev => new Set([...prev, id]));
try {
const r = await resolvePainLogItem(id);
if (r.success) setPainLog(prev => prev.filter(i => i.id !== id));
} finally {
setResolving(prev => { const n = new Set(prev); n.delete(id); return n; });
}
};
const handleSnooze = (id: string) => {
setSnoozed(prev => new Set([...prev, id]));
setTimeout(() => {
setSnoozed(prev => { const n = new Set(prev); n.delete(id); return n; });
}, 3600000);
};
const handleAdd = async () => {
if (!addForm.title.trim()) return;
const r = await createPainLogItem({
severity: addForm.severity as "low" | "medium" | "high" | "critical",
category: addForm.category,
title: addForm.title,
description: addForm.description || null,
});
if (r.success) {
setAddForm({ severity: "medium", category: "operations", title: "", description: "" });
setShowAddForm(false);
fetchAll();
}
};
const openItems = painLog.filter(p => p.status === "open" && !snoozed.has(p.id));
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-6">
<div className="h-14 w-14 rounded-full border-2 border-white/[0.08] border-t-white/25 animate-spin" />
<p className="text-xs text-white/25 tracking-widest uppercase">Initializing</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center max-w-md">
<div className="mb-5 text-5xl"></div>
<h3 className="text-xl font-bold text-white">Connection Lost</h3>
<p className="text-sm text-white/35 mt-2">{error}</p>
<button
onClick={fetchAll}
className="mt-8 rounded-2xl bg-zinc-900/10 border border-white/20 px-8 py-3 text-sm font-semibold text-white hover:bg-zinc-900/20 transition-all"
>
Reconnect
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen">
{/* ── Header ─────────────────────────────────────────────────────────── */}
<div className="mb-10 flex items-center justify-between px-8">
<div>
<h1 className={`${tv ? "text-4xl" : "text-3xl"} font-black tracking-tight text-white`}>
Command Center
</h1>
<p className={`${tv ? "text-base" : "text-sm"} text-white/25 mt-2 tracking-wide`}>
{new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
{" · "}
<span className="text-emerald-400/50 font-semibold">LIVE</span>
<span className="ml-2 inline-block h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
</p>
</div>
<div className="flex items-center gap-4">
<button
onClick={fetchAll}
className="flex items-center gap-2.5 rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-2 text-sm text-white/35 hover:bg-zinc-900/10 hover:text-white/60 transition-all"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
Refresh
</button>
<button
onClick={() => setTvMode(v => !v)}
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all ${
tv
? "bg-zinc-900/15 border border-white/25 text-white/80"
: "bg-zinc-900/5 border border-white/10 text-white/35 hover:text-white/60"
}`}
>
{tv ? "Exit TV" : "TV Mode"}
</button>
</div>
</div>
{/* ── KPI Row ──────────────────────────────────────────────────────────── */}
<div className={`grid ${tv ? "grid-cols-3" : "grid-cols-2 lg:grid-cols-6"} gap-5 px-8 mb-12`}>
<MetricCard label="Active Brands" value={metrics?.active_brands ?? 0} status="healthy" />
<MetricCard label="Orders Today" value={metrics?.orders_today ?? 0} status={metrics?.failed_orders_today ? "critical" : "healthy"} />
<MetricCard label="Revenue Today" value={formatCurrency(Number(metrics?.revenue_today ?? 0))} status="healthy" />
<MetricCard label="Active Routes" value={metrics?.active_routes ?? 0} status={(metrics?.pending_orders_today ?? 0) > 5 ? "warning" : "healthy"} />
<MetricCard label="Failed Orders" value={metrics?.failed_orders_today ?? 0} status={(metrics?.failed_orders_today ?? 0) > 0 ? "critical" : "healthy"} />
<MetricCard label="Pending Orders" value={metrics?.pending_orders_today ?? 0} status={(metrics?.pending_orders_today ?? 0) > 5 ? "warning" : "healthy"} />
</div>
{/* ── AI Briefing (full width) ──────────────────────────────────────── */}
<div className="px-8 mb-10">
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-8">
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-white/[0.04]">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-violet-500/15 border border-violet-500/20">
<span className="text-lg">🤖</span>
</div>
<div>
<h2 className="text-xs font-bold uppercase tracking-[0.22em] text-white/40">Daily Briefing</h2>
<p className="text-xs text-white/20 mt-0.5">Platform intelligence summary</p>
</div>
</div>
<AIBriefing metrics={metrics} brandHealth={brandHealth} />
</div>
</div>
{/* ── 2-Column Body ───────────────────────────────────────────────────── */}
<div className={`grid gap-10 px-8 ${tv ? "grid-cols-12" : "grid-cols-1 lg:grid-cols-2"}`}>
{/* ── LEFT: Brand Health + Activity ───────────────────────────── */}
<div className="space-y-10">
{/* Brand Health */}
<div>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
Brand Health
</h2>
<span className="rounded-full bg-zinc-900/5 px-2.5 py-0.5 text-xs text-white/15">
{brandHealth.length}
</span>
</div>
<div className="space-y-5">
{brandHealth.length === 0 ? (
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center">
<p className="text-sm text-white/25">No active brands</p>
</div>
) : (
brandHealth.map((b) => (
<BrandCard key={b.brand_id} brand={b} tv={tv} />
))
)}
</div>
</div>
{/* Activity Feed */}
<div>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
Live Activity
</h2>
<span className="rounded-full bg-zinc-900/5 px-2.5 py-0.5 text-xs text-white/15">
{activity.length}
</span>
</div>
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl overflow-hidden">
<div className="max-h-[420px] overflow-y-auto p-5">
{activity.length === 0 ? (
<div className="py-12 text-center">
<p className="text-sm text-white/20">No recent events</p>
</div>
) : (
activity.slice(0, 30).map((e) => (
<ActivityItem key={e.id} event={e} />
))
)}
</div>
</div>
</div>
</div>
{/* ── RIGHT: Founder Queue ─────────────────────────────── */}
<div className="space-y-10">
<div>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
Founder Queue
</h2>
{openItems.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-amber-900/300/20 text-xs font-black text-amber-300">
{openItems.length}
</span>
)}
</div>
<button
onClick={() => setShowAddForm(v => !v)}
className="text-xs text-white/25 hover:text-white/50 transition-colors"
>
+ Add
</button>
</div>
{showAddForm && (
<div className="mb-5 space-y-2.5 rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-5">
<select
value={addForm.severity}
onChange={e => setAddForm(f => ({ ...f, severity: e.target.value }))}
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 outline-none focus:border-white/20"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
<input
value={addForm.title}
onChange={e => setAddForm(f => ({ ...f, title: e.target.value }))}
placeholder="Issue title..."
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
/>
<input
value={addForm.category}
onChange={e => setAddForm(f => ({ ...f, category: e.target.value }))}
placeholder="Category (e.g. deployment, payment)"
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
/>
<textarea
value={addForm.description}
onChange={e => setAddForm(f => ({ ...f, description: e.target.value }))}
placeholder="Description (optional)"
rows={2}
className="w-full resize-none rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
/>
<div className="flex gap-2.5">
<button onClick={handleAdd} className="flex-1 rounded-xl bg-emerald-500/20 border border-emerald-500/30 px-4 py-2.5 text-sm font-semibold text-emerald-300 hover:bg-emerald-500/30 transition-all">
Log Issue
</button>
<button onClick={() => setShowAddForm(false)} className="flex-1 rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-2.5 text-sm font-medium text-white/35 hover:bg-zinc-900/10 transition-all">
Cancel
</button>
</div>
</div>
)}
<div className="space-y-3">
{openItems.length === 0 ? (
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center">
<p className="text-base font-semibold text-white/35">All clear</p>
<p className="text-sm text-white/20 mt-1.5">Add an issue to track it here.</p>
</div>
) : (
openItems.map((item) => (
<PainItem
key={item.id}
item={item}
onResolve={handleResolve}
onSnooze={handleSnooze}
/>
))
)}
</div>
</div>
{/* Quick Access */}
<div>
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25 mb-5">
Quick Access
</h2>
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-4 space-y-1">
{[
{ label: "Reports", href: "/admin/reports" },
{ label: "Orders", href: "/admin/orders" },
{ label: "Stops", href: "/admin/stops" },
{ label: "Settings", href: "/admin/settings" },
].map(link => (
<a
key={link.href}
href={link.href}
className="flex items-center justify-between rounded-xl px-4 py-3.5 text-sm text-white/30 hover:bg-zinc-900/5 hover:text-white/70 transition-all"
>
<span className="font-medium">{link.label}</span>
<span className="text-white/10"></span>
</a>
))}
</div>
</div>
</div>
</div>
<div className="h-24" />
</div>
);
}
+474
View File
@@ -0,0 +1,474 @@
"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. The panel now fades in without
the scale(0.98) start (removed the slight scale combined with
the backdrop fade read as a "pop" when the palette opened). */}
<style>{`
@keyframes cp-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes cp-scale-in {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
@keyframes cp-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes cp-scale-in { from { opacity: 0; } to { opacity: 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>
);
}
+143 -3
View File
@@ -16,6 +16,14 @@ type Props = {
}; };
}; };
type SuccessState = {
user: import("@/actions/admin/users").AdminUserRow;
tempPassword: string;
emailSent: boolean;
emailError?: string;
authPath?: "admin" | "signup";
};
const FLAG_LABELS: Record<string, string> = { const FLAG_LABELS: Record<string, string> = {
can_manage_products: "Products", can_manage_products: "Products",
can_manage_stops: "Stops", can_manage_stops: "Stops",
@@ -61,6 +69,8 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
const [flags, setFlags] = useState<Record<string, boolean>>({ ...defaultFlags }); const [flags, setFlags] = useState<Record<string, boolean>>({ ...defaultFlags });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<SuccessState | null>(null);
const [copied, setCopied] = useState(false);
const showBrandSelect = role === "brand_admin" || role === "store_employee"; const showBrandSelect = role === "brand_admin" || role === "store_employee";
@@ -82,6 +92,8 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
setBrandId(null); setBrandId(null);
setFlags({ ...defaultFlags }); setFlags({ ...defaultFlags });
setError(null); setError(null);
setSuccess(null);
setCopied(false);
} }
async function handleSubmit() { async function handleSubmit() {
@@ -111,10 +123,16 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
return; return;
} }
if (result.user) { if (result.user && result.tempPassword) {
onSuccess(result.user); onSuccess(result.user);
resetForm(); setSuccess({
onClose(); user: result.user,
tempPassword: result.tempPassword,
emailSent: result.emailSent ?? false,
emailError: result.emailError,
authPath: result.authPath,
});
// Don't close — show the success state so the caller can copy the temp password.
} }
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : "An unexpected error occurred."); setError(e instanceof Error ? e.message : "An unexpected error occurred.");
@@ -130,6 +148,17 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
} }
} }
async function copyPassword() {
if (!success) return;
try {
await navigator.clipboard.writeText(success.tempPassword);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard not available — caller can select manually.
}
}
const roleDescriptions: Record<Role, string> = { const roleDescriptions: Record<Role, string> = {
platform_admin: "Full platform access", platform_admin: "Full platform access",
brand_admin: "Brand-level admin", brand_admin: "Brand-level admin",
@@ -138,6 +167,117 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
if (!isOpen) return null; if (!isOpen) return null;
// Success state — show temp password + email status, then "Done" closes.
if (success) {
return (
<GlassModal
title="User Created"
titleIcon={
<svg className="h-5 w-5 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
}
subtitle={`${success.user.display_name ?? success.user.email} is ready to sign in`}
onClose={handleClose}
maxWidth="max-w-lg"
>
<div className="space-y-4 sm:space-y-5">
<p className="text-sm text-[var(--admin-text-secondary)]">
The account has been created in Neon Auth and linked to the local admin record.
Share the temporary password below with the new user it will not be shown again.
</p>
{/* Email */}
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-3 sm:p-4">
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
Sign-in email
</p>
<p className="text-sm font-medium text-[var(--admin-text-primary)] break-all">
{success.user.email}
</p>
</div>
{/* Temp password */}
<div className="rounded-xl border border-amber-300/80 bg-amber-50/80 p-3 sm:p-4">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-800">
Temporary password
</p>
<button
type="button"
onClick={copyPassword}
className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-900 hover:text-amber-700 transition-colors inline-flex items-center gap-1"
>
{copied ? (
<>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 6L9 17l-5-5" />
</svg>
Copied
</>
) : (
<>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
Copy
</>
)}
</button>
</div>
<p className="font-mono text-sm text-amber-900 break-all select-all">
{success.tempPassword}
</p>
<p className="text-[11px] text-amber-700 mt-2">
The user should change this on first sign-in (must_change_password is set).
</p>
</div>
{/* Email delivery status */}
<div
className={`rounded-xl border p-3 sm:p-4 ${
success.emailSent
? "border-emerald-200/80 bg-emerald-50/60"
: "border-rose-200/80 bg-rose-50/60"
}`}
>
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
Welcome email
</p>
{success.emailSent ? (
<p className="text-sm text-emerald-800">
Sent to {success.user.email}. The user has the password in their inbox.
</p>
) : (
<p className="text-sm text-rose-800">
Could not be sent automatically
{success.emailError ? `: ${success.emailError}` : "."} Share the password above out-of-band.
</p>
)}
</div>
{success.authPath === "signup" && (
<p className="text-[11px] text-[var(--admin-text-muted)]">
Note: the admin sign-up endpoint was unavailable, so the account was created via
the public sign-up path. This is fine the user can sign in normally.
</p>
)}
</div>
<div className="flex items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
<button
onClick={handleClose}
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
>
Done
</button>
</div>
</GlassModal>
);
}
return ( return (
<GlassModal <GlassModal
title="Create User" title="Create User"
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 };
}

Some files were not shown because too many files have changed in this diff Show More