44 Commits

Author SHA1 Message Date
tyler 32396af193 ci(gitea): fix deploy path — docker-compose.yml moved to deploy/
Deploy to route.crispygoat.com / deploy (push) Successful in 3m30s
The Start Docker stack and Deploy steps referenced docker-compose.yml at
the repo root, but the file moved to deploy/ as part of the deploy.sh
refactor. The cp commands failed with 'cannot stat docker-compose.yml'
and the deploy step aborted.

Updated both cp paths to deploy/docker-compose.yml. The cd $APP_DIR and
docker compose -f $APP_DIR/docker-compose.yml references are unchanged
because they point at the deployed copy in APP_DIR, not the repo.
2026-06-06 19:54:03 +00:00
tyler f36419be69 docs: document canonical Gitea remote in CLAUDE.md
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
There is exactly one remote (origin = git@git.crispygoat.com:tyler/route-commerce.git).
No GitHub remotes. Pushing to origin/main triggers .gitea/workflows/deploy.yml.
2026-06-06 19:49:46 +00:00
tyler 5477b3419f fix(build): make admin tree dynamic and catch Supabase fetch errors at build
Deploy to route.crispygoat.com / deploy (push) Failing after 4m16s
Two errors were aborting the Gitea build:

1. DYNAMIC_SERVER_USAGE on /admin/settings/square-sync (and any admin page):
   getAdminUser() reads cookies() via next/headers. The admin layout tried
   to prerender statically, so the first child page that hit cookies()
   aborted the build. Added 'export const dynamic = "force-dynamic"' to
   src/app/admin/layout.tsx so the whole admin tree opts out of static
   prerender.

2. Prerender ECONNREFUSED on /indian-river-direct/stops and the sitemap:
   getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic
   fetch NEXT_PUBLIC_SUPABASE_URL at build time. The Gitea runner sets the
   Supabase env vars (so the existing env-var guard passes) but the URL
   is unreachable, so fetch throws ECONNREFUSED and the prerender aborts.
   Wrapped each fetch in try/catch returning [] / {success: false} so the
   prerender completes; runtime behavior is unchanged when the fetch
   succeeds.

Also added force-dynamic to the square-sync page itself as belt-and-braces
in case the layout change doesn't propagate.
2026-06-06 19:46:35 +00:00
tyler 2f3be5426f fix(actions): skip Supabase fetch at build time when env vars unset
Deploy to route.crispygoat.com / deploy (push) Failing after 2m50s
The /indian-river-direct/stops page and sitemap prerender at build time
and call getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic.
Those actions fetch NEXT_PUBLIC_SUPABASE_URL via Supabase REST. During
the GitHub/Gitea build, the Supabase secret is unset (or the value is
".supabase.co" which doesn't resolve), so the fetch errors with
ECONNREFUSED and the build aborts.

Return [] / not-configured when the env vars are missing so the prerender
can complete. Runtime behavior is unchanged when the vars are set.
2026-06-06 05:12:55 +00:00
tyler 2d837bc786 ci(gitea): drop build workflow, simplify deploy to call deploy.sh
Deploy to route.crispygoat.com / deploy (push) Failing after 2m50s
- Delete .gitea/workflows/build.yml (typecheck/lint only; caused confusion)
- Rewrite .gitea/workflows/deploy.yml as a thin wrapper that calls
  ./deploy/deploy.sh, matching the design in deploy/GITEA_SETUP.md
  (Option B). The 14512-byte inline deploy is removed; deploy.sh is the
  source of truth for the deploy mechanism.
- Fix runs-on to [self-hosted, ubuntu-latest] (matches the actual labels
  registered on crispygoat-host-runner; the previous [.., linux, ..] was
  unmatchable, which is why runs were stuck in the queue)
2026-06-06 05:03:44 +00:00
tyler bb6dbe37a4 ci(gitea): add deploy workflow + self-hosted homelab deploy toolkit (Auth.js port)
Build / build (push) Has been cancelled
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
Ports the deploy pipeline from the Gitea main fork (commit 7ddb06d's deploy
toolkit) into the Auth.js v5 / NextAuth tree:

- .gitea/workflows/deploy.yml: inline deploy that brings up the Docker
  stack, applies migrations, builds Next.js, and runs the app under PM2.
  Swapped Better Auth env vars (BETTER_AUTH_SECRET/URL, NEXT_PUBLIC_BETTER_AUTH_URL)
  for Auth.js v5 names (AUTH_SECRET/URL, NEXT_PUBLIC_AUTH_URL). Dropped
  NEXT_PUBLIC_SUPABASE_URL/ANON_KEY (Supabase removal in progress). Added
  GOOGLE_CLIENT_ID/SECRET + ALLOW_DEV_LOGIN for the Auth.js Google provider
  and dev credentials path. Switched runs-on from 'ubuntu-latest' to the
  self-hosted runner labels matching build.yml.

- deploy/: idempotent deploy toolkit (deploy.sh, docker-compose.yml,
  Dockerfile.nextjs, nginx.conf.template, .env.production.example, healthcheck.sh,
  Makefile, deploy/.gitignore). No auth/Supabase dependencies — pure infra.

- deploy/.env.production.example: renamed NEXTAUTH_SECRET/NEXTAUTH_URL
  (v4) to AUTH_SECRET/AUTH_URL (v5) and added the v5-specific vars
  (NEXT_PUBLIC_AUTH_URL, GOOGLE_*, ALLOW_DEV_LOGIN).

Build pipeline is now end-to-end:
  build.yml → typecheck + lint + build (uses [self-hosted, linux, ubuntu-latest])
  deploy.yml → start docker stack + migrations + build + PM2 restart

Storage / admin code ports (MinIO via @/lib/storage, Supabase removal,
admin-permissions rewrite) are tracked separately — they require porting
the storage and admin code first; the deploy pipeline itself is ready
to run against the Auth.js world.
2026-06-06 04:24:53 +00:00
tyler 3f4f46da7e ci: retrigger Gitea build 2026-06-06 03:46:49 +00:00
tyler ec1506dc82 feat(auth): Auth.js v5 + Postgres adapter for local smoke test
Wire up NextAuth v5 with @auth/pg-adapter, JWT sessions (edge-friendly),
and a dev Credentials provider for local testing without Google OAuth.

Stack
- next-auth@5.0.0-beta.31, @auth/pg-adapter@1.11.2, @types/pg
- Google OAuth provider via GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
  (falls back to AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET)
- Postgres adapter wired to a single pg.Pool in src/lib/db.ts style —
  reads DATABASE_URL with SUPABASE_DB_URL / POSTGRES_URL fallbacks
- JWT session strategy (edge-safe) so the proxy can verify sessions
  without a DB round-trip

Files
- src/auth.config.ts        edge-safe config (Google + authorized cb)
- src/lib/auth.ts           server config (adapter + dev Credentials)
- src/proxy.ts              Next.js 16 proxy (was middleware.ts)
- src/app/api/auth/[...nextauth]/route.ts
                            catch-all handler
- src/app/protected-example/page.tsx
                            demo page that renders auth() session
- src/actions/auth-signin.ts
                            signInWithGoogle, signInWithDev,
                            signOutAction server actions
- src/app/login/LoginClient.tsx
                            added "Sign in with Google" + dev form
- supabase/migrations/204_authjs_tables.sql
                            users / accounts / sessions /
                            verification_token schema (UUID-keyed)
- .env.example              AUTH_SECRET, AUTH_URL, GOOGLE_CLIENT_*,
                            DATABASE_URL, ALLOW_DEV_LOGIN

Removed
- src/middleware.ts         deleted; Next.js 16 only runs one proxy
                            (the new src/proxy.ts is canonical)

Routes
- /login, /admin, /admin/*, /protected-example
                            proxy matcher
- /api/auth/{providers,csrf,signin/<provider>,callback/<provider>,
  session,signout}
                            standard Auth.js endpoints

Local dev
- npm run dev (now runs on port 4000)
- push migration 204 then visit /login
- dev signin works with any non-empty username/password
  (hidden when ALLOW_DEV_LOGIN=false)
- Google signin requires real GOOGLE_CLIENT_ID + redirect URI
  http://localhost:4000/api/auth/callback/google

Verified
- tsc --noEmit clean
- /admin, /admin/orders, /protected-example → 307 to /login
  when unauthenticated
- /api/auth/session returns user after signin
- /protected-example renders session info
- /api/auth/providers returns google + dev-login

Docs
- CLAUDE.md and MEMORY.md updated to reflect the Supabase → Postgres
  + Auth.js v5 pivot

Gradual migration in progress
- src/lib/admin-permissions.ts still uses dev_session / rc_auth_uid;
  the admin shell will show 'Access Denied' for Auth.js-only
  sessions until each page is flipped over
- @supabase/* packages remain in package.json for the same reason
- production deployment (AUTH_URL=https://, __Secure- cookies) is
  out of scope for this pass
2026-06-06 03:40:09 +00:00
tyler 2b3fd214d8 ci(gitea): add build workflow for self-hosted runner
Runs typecheck, lint, and build on every push to main and on pull requests.
Targets the self-hosted Gitea Actions runner (crispygoat-host-runner)
using labels [self-hosted, linux, ubuntu-latest].

Uses the local dev stack endpoints (PostgREST on :3001, MinIO on :9000,
Postgres on :5432) for env vars so no secrets need to be wired in.
2026-06-05 23:00:10 +00:00
tyler 858ca0d64d test(scripts): add end-to-end validation script for local Postgres + PostgREST + MinIO + Next.js stack
Validates the full local development stack:
- Postgres connectivity
- PostgREST API on :3001
- MinIO object storage on :9000
- Next.js dev server on :4000
- Brand isolation via SECURITY DEFINER RPCs
- Auth via dev_session cookie (3 roles)
2026-06-05 22:49:52 +00:00
tyler 8a91494009 fix: add both apikey and Authorization headers for storage upload 2026-06-04 20:31:58 +00:00
tyler b240b7b56e fix: remove redundant Authorization header from storage upload 2026-06-04 20:31:38 +00:00
tyler fd9d6424d5 fix: add available_from/available_until to Product type in ProductsClient 2026-06-04 20:26:10 +00:00
tyler 0b748adfaa Add seasonal availability dates to product form modal
- Add available_from and available_until fields to ProductFormValues type
- Add date picker inputs for seasonal availability in ProductFormModal
- Wire availability dates through ProductsClient to modal initial values
- Create migration 149 for database columns (already applied)
2026-06-04 20:20:09 +00:00
tyler 69767eb250 feat(admin): redesign product form modal (Atelier des Récoltes)
Replace the basic add/edit product modal with an editorial two-column
layout featuring Fraunces display serif, IBM Plex Mono section labels,
a custom drop zone, visual type cards, and pill toggles. Add a brand
selector visible only to platform admins to fix the 'Brand ID is
required' / RLS error when creating products without an assigned brand.

- Add ProductFormModal with two-column layout (image hero + form)
- Add Fraunces / IBM Plex Mono / Inter Tight via next/font/google
- Add atelier-* design system to globals.css
- Wire brand picker through page server component -> client
- Convert ProductsClient image handling to callback shape
- Move all submit/validation/upload logic into the new modal
2026-06-04 19:54:20 +00:00
tyler 8bca9e86b1 chore: add Tuxedo Corn 2026 tour schedule 2026-06-04 19:17:39 +00:00
tyler 82d81b2f69 fix(stops): change stops.date from TIMESTAMPTZ to DATE
The stops.date column was created as TIMESTAMPTZ but is used as a
calendar date throughout the app. Supabase returned it as a
'YYYY-MM-DD HH:MM:SS+TZ' string, which made the existing
'new Date(s.date + "T00:00:00")' parsing in the client produce
Invalid Date objects — leaving the Stops & Routes calendar empty.

Changing the column to DATE makes Supabase return a clean 'YYYY-MM-DD'
string that the existing local-time parsing handles correctly.

- ALTER stops.date TYPE DATE USING date::DATE
- Recreate get_public_stops_for_brand with date DATE in RETURNS TABLE
  (DROP + CREATE required because CREATE OR REPLACE refuses to change
  the OUT-parameter row type — PostgreSQL error 42P13)

The time (TEXT 'HH:MM') and cutoff_time (TIMESTAMPTZ) columns are
unchanged — they legitimately carry time-of-day information.
2026-06-04 18:56:20 +00:00
tyler 7ecb1f2fc0 Merge branch 'main' of github.com:dzinesco/route-commerce
# Conflicts:
#	src/app/admin/stops/page.tsx
#	src/app/globals.css
#	src/app/layout.tsx
2026-06-04 18:43:58 +00:00
tyler 015b1cf7b5 feat(checkout): real Stripe Express + Elements on /checkout
- Add @stripe/stripe-js + @stripe/react-stripe-js
- New src/lib/stripe-client.ts: cached loadStripe helper
- New src/actions/billing/retail-payment-intent.ts: server action
  that creates a PaymentIntent with automatic_payment_methods
- New src/components/storefront/StripeExpressCheckout.tsx: embedded
  ExpressCheckoutElement (Apple Pay, Google Pay, Link, PayPal) +
  PaymentElement (card) + hosted-checkout fallback
- /checkout form is now controlled; StripeExpressCheckout reads
  name/email/stop from form state and confirms in-page via
  stripe.confirmPayment
- /checkout/success handles both ?session_id= and ?payment_intent=
  so embedded + hosted flows both land on the same order-creation
  page using the pending_checkout sessionStorage payload
- Export StopInfo from CartContext and select 'time' on the stops
  fetch so the local Stop shape matches the context's StopInfo
- QuickCartSheet express buttons are visual shortcuts only;
  /checkout auto-renders the real Apple Pay / Google Pay buttons

Requires NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY for the embedded path;
if missing the embedded section falls back to the hosted
Stripe Checkout button. npx tsc --noEmit passes.
2026-06-04 18:38:21 +00:00
tyler 1b12a0a95d feat(admin/stops): add tabbed dashboard with calendar and locations views
Replace the table-only stops view with a three-tab 'Harvest Dispatch'
almanac dashboard.

- Calendar tab: month grid with stops as status-colored pins, day-detail
  side panel, prev/next/today nav, weekend tint, sunrise gradient on
  the current day.
- Locations tab: cards grouped by city+state with per-location stats
  (stops/active/upcoming), date range, next-stop ribbon, and inline
  expansion to list every stop at that location. Top of the tab shows
  a six-card almanac stats strip (Locations/Cities/Stops/Active/Upcoming/
  Drafts).
- List tab: condensed read-only list of all stops in date order.

Typography: load Fraunces (display), Manrope (body), and Fragment Mono
(mono) via next/font/google, wired into the Tailwind theme as
--font-display, --font-sans, --font-mono. Add typescript to devDeps
for tsc --noEmit in this environment.

Visual signature: cream parchment cards, forest green + clay accents,
Roman-numeral stat cells, paper-grain noise, binder-tab nav, route
number watermarks on location cards, harvest-pin SVG markers.

Verified with tsc --noEmit, eslint on the new files, and a 200 from
GET /admin/stops in the dev server.
2026-06-04 18:20:11 +00:00
tyler b2aa53f274 feat(storefront): dedicated Sweet Corn Box product page with 2-3 click order flow
- New route /tuxedo/products/sweet-corn-box with editorial Fraunces/JetBrains
  Mono typography, paper-grain hero, tilted 'Peak Season 2026' stamp, and
  sticky buy-box (desktop) + sticky mobile bottom bar with 48px+ tap targets
- QuickCartSheet slide-up drawer: mobile bottom sheet (spring + drag-to-close)
  vs desktop right drawer, with Apple Pay / Google Pay / Shop Pay express row
  and one-page 'Checkout' CTA — enables the user's specified 2-3 click flow
- CartContext.buyNow() — single-item cart replace + clear stop for 1-tap
  'Quick Buy' that pushes directly to /checkout
- 4-feature check list, 100% Sweetness Guarantee, FAQ accordion, dark final
  CTA strip, and proper SEO/Open Graph meta
2026-06-04 18:01:16 +00:00
tyler 66c6f45efc fix(admin/stops): pass admin user_id, not stop.id, as callerUid
StopDetailModal was hardcoding callerUid={stop.id} when rendering
StopProductAssignment, so assign_product_to_stop received the stop's
own UUID as p_caller_uid. The RPC's admin_users.user_id lookup
returned no row, so every assign attempt failed with
'Not recognized as admin'.

The admin's user_id is already known inside the getStopDetails
server action (it gates on getAdminUser()), so surface it on the
response and read it in the modal. The sibling /admin/stops/[id]
page already uses the same source (adminUser.user_id) — this brings
the modal in line with it.
2026-06-04 17:37:17 +00:00
tyler 6c6b5d3053 fix(admin/stops): restore StopsHeaderActions file
Commit bdcaf0f1 (editorial stops redesign) re-imported
@/components/admin/StopsHeaderActions in the stops page and placed
it in the PageHeader's actions prop, but never recreated the file
(it had been removed in bc29c70). The build fails with 'Module not
found' for StopsHeaderActions.

Restore the file from its previous content (pre-bc29c70). The component
renders the 'Upload Schedule' and 'Add Stop' header actions, with a
'stops' tab guard so the buttons don't leak onto the Locations tab.
Without it, the stops surface has no way to add a stop or import a
schedule (StopTableClient is rendered with hideInternalFilterBar and
StopsViewClient doesn't host the buttons).
2026-06-04 17:31:35 +00:00
tyler 73cc7d1dce fix(admin/stops): product picker + add StopsHeaderActions
StopProductAssignment
- remove() now uses functional setState — fixes a stale-closure bug
  where 'Remove all' only cleared the first product and rapid single
  removals could collide
- assign() no longer re-fetches the full assigned list on every click;
  it now does an optimistic insert using the product already in
  allProducts, keyed by the row id returned from the RPC. Eliminates
  a network round-trip + brief UI flicker per click
- clearAll() snapshots the ids at click time and stops on error so a
  failed remove doesn't silently continue
- 'Press / to search' hint: moved `ha-modal-footer-hint` to the
  parent span (it was on the kbd), so the kbd styles from
  `.ha-modal-footer-hint kbd` actually apply; removed the redundant
  inline style and the duplicated className

StopsHeaderActions (new)
- Small client component that owns the Add Stop / Upload Schedule
  modals and renders the two header buttons. Wires into the
  PageHeader actions slot so the actions stay accessible from the
  StopsViewClient (where the StopTableClient filter bar is hidden
  by the shared search/status filter)
2026-06-04 17:27:29 +00:00
tyler 4763884caf refactor(products): split edit/create brandId resolution
Per path:
- Edit: trust editingProduct.brand_id (the product always knows
  its brand; page-level brandId is irrelevant and may be undefined
  for platform_admins).
- Create: require the page-level brandId prop (no product to pull
  from).

Replaces the previous single 'brandId ?? editingProduct?.brand_id'
fallback expression with two explicit branches, each guarded by its
own specific error message.
2026-06-04 17:27:09 +00:00
tyler bdcaf0f1da feat(admin/stops): editorial redesign + calendar/route view
Reimagine the admin stops surface under a 'Harvest Almanac' editorial
direction. Establishes the type system (Fraunces serif display, Geist
sans, JetBrains Mono numerics) loaded via next/font, and a compact
modal pattern used across the new components.

Stop modal (AddStopModal)
- Compact 2-col grid: City|State, Date|Time, Address|ZIP|Cutoff
- Status replaced with slim segmented control (Draft / Publish Now)
- Tighter inputs (36px), single-line footer with Esc hint
- Auto-focus on City field, reactive submit label

GlassModal
- New 'compact' prop: tighter padding, eyebrow text, no accent bar,
  capped max-height for dense forms

Stop product assignment (StopProductAssignment)
- Reimagine the <select> dropdown as an editorial card-grid picker
- Big Fraunces numeral + small-caps eyebrow showing live count
- Click-to-toggle assign/remove; green check for assigned, red X on
  hover-to-remove intent
- Search + filter chips (All / Available / On this stop) with
  mono counters
- '/' keyboard shortcut focuses search
- Summary footer with 'Remove all' action

Stops page
- New StopsViewClient wrapper with shared search/status filter and
  Calendar/Table view toggle (default = Calendar)
- New StopsCalendarClient: month grid almanac with editorial header,
  color-coded event chips, today highlight, prev/next/Today nav
- Event popover with auto-edge-detection positioning, full stop
  details, Publish/View Route/Edit actions
- Day-route drawer: slides in from right with route spine (numbered
  markers + connecting hairline), 3-card summary (stops/live/brands),
  per-stop Publish/Edit/Duplicate actions
- StopsTableClient refactored to accept hideInternalFilterBar prop
  so the wrapper can own shared filtering

Design tokens
- New utility classes in admin-design-system.css: ha-display,
  ha-eyebrow, ha-field, ha-segment, ha-picker, ha-product-card,
  ha-calendar, ha-event-popover, ha-drawer, ha-route-stop, etc.
- All uses CSS variables (--font-fraunces, --font-geist,
  --font-jetbrains-mono) so the type system cascades
2026-06-04 17:19:46 +00:00
tyler df6f4181df fix(products): fall back to product's brand_id in edit modal
When a platform_admin (no brand_id assigned) opens the Edit Product
modal, the page-level brandId prop is undefined and handleSubmit was
erroring with 'Brand ID is required'. The product record itself carries
brand_id, so use it as a fallback before bailing out.
2026-06-04 17:18:21 +00:00
tyler 63842a9efc feat(admin): multi-brand admin support
Implements the design at
docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md.

Adds:
- admin_user_brands junction table (m:n admin<->brand) via migration 207
- New role 'multi_brand_admin' (auto-set when an admin has 2+ brands)
- 'active_brand_id' cookie that persists the admin's currently-selected
  brand across navigations; switchable via the new BrandSelector dropdown
  in the sidebar
- Centralised brand resolution in src/lib/brand-scope.ts:
  - getActiveBrandId (URL > cookie > legacy brand_id > first of brand_ids)
  - assertBrandAccess (defence-in-depth for cases where the brandId
    comes from a URL form or RPC return)
- ~30 server actions and ~10 page server components migrated to use
  getActiveBrandId instead of the silent brandId ?? adminUser.brand_id
  pattern that allowed cross-brand access bugs
- BrandSelector client component with proper a11y (aria-haspopup,
  aria-expanded, role=listbox, outside-click and escape-to-close)

Migration 207 also adds RLS so admins can read their own junction rows
(needed for the dropdown to populate) and SECURITY DEFINER RPCs
add/remove_admin_user_brand that auto-promote/demote between
brand_admin and multi_brand_admin.

Notes:
- Migration number is 207 not 204 — 204-206 were taken in this worktree
  by the concurrent locations work.
- The legacy admin_users.brand_id column is preserved for backwards
  compat; a follow-up migration 220_* will drop it.
- Dev sessions (dev_session cookie, NEXT_PUBLIC_USE_MOCK_DATA) get
  brand_ids: []; the documented limitation is that dev store_employee
  will see <AdminAccessDenied /> if no real brands exist.
- Pages that hardcode a Tuxedo brand UUID as a fallback
  (adminUser.brand_id ?? '64294306-...') are NOT migrated in this PR —
  they still work for single-brand admins and are out of scope.

Co-authored-by: implementer subagent (cancelled mid-run), Grok orchestrator
2026-06-04 17:09:40 +00:00
tyler bd623020d5 Open stop details in a modal from the Stops admin table
Clicking a stop (or the Edit link) in /admin/stops now opens a modal
with Details/Products/Message tabs instead of navigating to
/admin/stops/[id].

- Add getStopDetails server action (returns stop, brand, candidate
  products, assigned products, brand list in one round trip).
- Add StopDetailModal component with tabbed panels that reuse the
  existing StopEditForm, StopProductAssignment, and
  MessageCustomersSection.
- Add an optional onSaved callback to StopEditForm so the modal can
  refetch + router.refresh() after a save.
- StopTableClient now opens the modal on row click; the /admin/stops/[id]
  route is kept in place for direct links/bookmarks.
2026-06-04 16:37:31 +00:00
tyler 9b51f5ae29 docs: multi-brand admin support design spec
Adds a design document for supporting admins who manage 2+ specific brands
(e.g., franchise / multi-brand tenant use cases).

Current model: admin_users.brand_id is a single UUID | null, and the
effectiveBrandId = brandId ?? adminUser.brand_id ?? null pattern silently
does the wrong thing for multi-brand admins. There is no central
validation that an admin is acting in a brand they have access to.

Proposed: admin_user_brands junction table (m:n), kept admin_users.brand_id
for backwards compat, a new multi_brand_admin role, a cookie-based active
brand, a centralized brand-scope helper, and a BrandSelector dropdown in
the admin header. ~30 server actions and ~10 page server components get
a mechanical one-line swap to use the new helper.

Follow-up migration 220_drop_legacy_brand_id.sql will drop the legacy
column after we verify nothing reads it. Out of scope for this spec.

See docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md
2026-06-04 16:29:40 +00:00
tyler bc29c70e8b design(stops): cohesive card layout, count badges, row-click, merged columns
Tier 1 — Layout consistency:
* Move Stops tab action buttons (Upload Schedule, Add Stop) into the
  filter bar, matching the Locations tab. One muscle memory for
  'where the Add button is'. Removes the page-level StopsHeaderActions
  component.
* Make the card a single cohesive unit. Tabs sit INSIDE the card on
  top with a border-b, content fills the rest. No more 'rounded-b-2xl
  rounded-t-none border-t-0' shape.
* New StopsLocationsTabs: pill-style tabs with icons + count badges +
  sublabel ('269 · 7 cities'). Instant orientation. URL-driven via
  Link so refresh + deep-linking work.

Tier 2 — Easier to scan:
* New StatsStrip under the page header: '269 stops · 7 cities · 4
  states · 4 active · 5 draft · 0 inactive' (different stats for the
  Locations tab). Context before the table loads.
* Stops table collapses 8 columns → 6: drop Brand, merge City+State
  into 'Where', merge Date+Time into 'When' (date + weekday on one
  line, time on the next). 30% more breathing room per row.
* Replace font-mono for dates/times with tabular-nums — same column
  alignment, type family stays consistent.
* Click anywhere on a Stops row to edit. Redundant 'Edit' link is
  gone. Kebab menu still handles Publish/Duplicate/Delete.
* Match Locations' empty state (icon + title + subtitle) on the Stops
  side.

Polish:
* Consistent status pills (Active = emerald, Inactive = stone, Draft
  = amber) in both tabs.
* Tab-switch fade via framer-motion AnimatePresence — subtle y/opacity
  transition between Stops and Locations content.
2026-06-04 15:48:15 +00:00
tyler 1feba25ced chore: commit tour-seed scripts and delete_stop 400 fix from prior work
* inspect_xlsx.py — one-off xlsx dumper used to discover the Stop
  Directory sheet structure (14 lines, kept for reference when the
  tour schedule xlsx is updated next season).
* scripts/seed_tuxedo_tour.py — Python+openpyxl seed script that
  parsed the 3-week Tuxedo Corn 2026 tour schedule and inserted
  269 stops via admin_create_stops_batch. Also linked each stop to
  its Stop Directory venue (address/phone/contact) and self-published
  by flipping status='draft' -> 'active'.
* supabase/migrations/202_delete_stop_orders_guard_fix.sql — fixes a
  400 error on the admin Stop Table delete button. The delete_stop
  RPC referenced o.deleted_at on the orders table, but that column
  doesn't exist, so the SELECT raised 'column does not exist' and
  PostgREST surfaced it as HTTP 400. The fix drops the redundant
  o.deleted_at IS NULL clause (pickup_complete = false is sufficient
  on its own); re-add it here if/when orders grows a deleted_at
  column.
2026-06-04 15:32:16 +00:00
tyler f26d43ac96 fix: re-dedupe locations by (name, city, address) so Tractor Supply splits into 10 per-city venues
The previous backfill grouped by (name, address) only, which collapsed
Tractor Supply in Brighton, CO and Tractor Supply in Canon City, CO
into a single location whenever they shared the same address (or both
had a NULL address). Same problem for Ace Hardware, Murdoch's,
Big Tool Box, and JAX Farm & Ranch.

This migration re-dedupes by (name, city, address), re-links all 269
Tuxedo stops to the new per-city venues, and soft-deletes the 26
over-grouped original locations.

Result: 41 active locations (was 26). 269/269 stops re-linked.
Slug regex also fixed — previous [a-z0-9] stripped uppercase letters;
now uses [[:alnum:]].
2026-06-04 15:30:22 +00:00
tyler 49a9900d15 feat: add locations table + Locations tab in Stops & Routes
* New 'locations' table for reusable venues (Tractor Supply, Boot Barn,
  etc.) — each stop now links via stops.location_id while keeping the
  denormalized location text for backwards compat.
* 6 SECURITY DEFINER RPCs: admin_create_location, admin_create_locations_batch,
  admin_update_location, admin_delete_location, admin_attach_location_to_stop,
  get_locations_for_brand. Plus admin_list_locations for the admin tab.
* Backfill: 26 unique venues extracted from the 269 Tuxedo stops, all linked.
* Stops & Routes page now has tabs (Stops | Locations) via ?tab= query param.
  Locations tab has full CRUD UI: search, status filter, pagination, edit,
  delete, add-venue modal.
* StopsHeaderActions now tab-aware — Upload Schedule / Add Stop only show on
  Stops tab; Locations tab has its own Add Venue button inline.
2026-06-04 15:24:34 +00:00
tyler 9374e63ae6 docs(memory): record Codex review round 2 pass 2026-06-03 16:40:03 +00:00
tyler 0245aa29cc fix(buyer/billing/comms/a11y): Codex review pass round 2
Tuxedo buyer path (subagent 2):
- src/app/tuxedo/page.tsx: remove duplicate CinematicShowcase render
- src/components/storefront/CinematicShowcase.tsx: wire up useCart, Add to Cart button, brand-aware fulfillment
- src/app/tuxedo/stops/TuxedoStopsList.tsx: improved empty state with calendar icon + CTAs
- src/app/cart/CartClient.tsx: guard against empty cart checkout; 'Cart is Empty' state

Billing reconciliation (subagent 3):
- src/actions/billing/billing-overview.ts: NEW — single source of truth
- src/app/admin/settings/billing/page.tsx: use getBillingOverview
- src/app/admin/settings/billing/BillingClientPage.tsx: rewritten to consume BillingOverview (status pill, addons state, removable flags, derived invoice amounts, usage footer)
- src/app/admin/page.tsx: use getBillingOverview (aligns dashboard with billing)
- src/components/admin/DashboardClient.tsx: 'Active Products' now reads from getBillingOverview
- supabase/migrations/203_plan_usage_active_products.sql: get_brand_plan_info counts products as active=true AND deleted_at IS NULL

Harvest Reach dedup + audience preview (manual, subagent 4 didn't complete):
- src/components/admin/CommunicationsPage.tsx: add initialTab prop
- src/app/admin/communications/compose/page.tsx: now renders with initialTab='compose' (single compose experience, no duplicate edit panel)
- src/components/admin/HarvestReach/CampaignComposerPage.tsx: always-visible audience preview panel (count + sample emails), loads via previewCampaignAudience action

Layout/content consistency + a11y sweep (subagent 5):
- src/components/layout/SiteHeader.tsx: Admin link only shows for authenticated admin users
- src/components/Providers.tsx: suppress public SiteHeader/Footer for /admin, /cart, /checkout, /wholesale, /water (fixes duplicate headers)
- src/app/contact/ContactClientPage.tsx: Phone/Email now use tel:/mailto: links; dynamic year
- src/app/blog/page.tsx, changelog, privacy-policy, roadmap, security, terms-and-conditions, waitlist: dynamic year
- src/app/admin/wholesale/WholesaleClient.tsx: proper htmlFor/id, type=email/tel, autoComplete
- src/app/admin/settings/ai/AIClient.tsx: proper htmlFor/id, required + aria-required
- src/app/admin/settings/integrations/IntegrationsClient.tsx: only mask secret fields; add required + aria-required + CredentialField.required type
- src/app/admin/water-log/headgates/HeadgatesManager.tsx: htmlFor/id, aria-required
- src/components/admin/CreateUserModal.tsx: htmlFor/id, required, aria-required, aria-describedby, autoComplete
- src/app/admin/me/AdminMeClient.tsx, products/import, sales/import, water-log/settings, login, brands, tuxedo: a11y polish (ids/required/aria)
2026-06-03 16:39:19 +00:00
tyler 03ae372509 fix(home): resilient homepage animations + anchors + counters
Subagent 1 (Codex review pass):

LandingPageClient.tsx
- Guard GSAP .scroll-reveal loop to prevent 'missing target' warnings when no targets exist

brands/page.tsx
- Comment cleanup for selector scoping

tuxedo/page.tsx
- Add pageScopeRef + use as explicit GSAP context scope (fixes 'Invalid scope' warnings)
- Reduced-motion guard for counter animations
- Comments noting scope is now resilient

HeroSection.tsx
- Wrap sections in containerRef for proper GSAP context
- Reduced-motion support (final states shown immediately, no heavy anims)
- Counter animation: switched from textContent tween (unreliable, left values at 0) to proxy object pattern (val -> display)
- Added IO + rAF fallback if GSAP is unavailable
- Added section ids: #features, #stats, #reviews (matches nav anchors)
- Reduced blank scroll region (300vh -> 140vh on story section)
- Removed duplicate inline footer (LandingPageWrapper <Footer /> now sole source)

LandingPageWrapper.tsx
- Anchor comments noting ids added in HeroSection

Docs + scripts:
- docs/ADMIN_FUNCTIONALITY_CHECKLIST.md (admin app coverage notes)
- docs/pricing-assessment.md (pricing model notes)
- scripts/apply-admin-create-stop.js, check-stop-fns*.js, verify-stop-fns.js (admin stop creation tooling)
2026-06-03 15:36:40 +00:00
tyler 84ad60b4b0 feat(admin): comprehensive functionality pass across admin apps (Codex review)
- Restore admin order creation (top blocker):
  - New createAdminOrder server action wrapping create_order_with_items RPC with proper getAdminUser + can_manage_orders + brand scoping.
  - AdminOrdersPanel now supports ?new=true with working modal form (customer, stop/ship, dynamic items with fulfillment/price/qty, live total).
  - Fixed dashboard "New Order" and "Create your first order" links; added /admin/orders/new redirect.
  - Success flow: toast + redirect to list.

- Product edit reliability and admin flows hardened.

- Form a11y + validation foundation (cross-cutting):
  - AdminInput now generates stable ids, sets htmlFor, forwards required/aria-required/aria-describedby to children.
  - All admin forms benefit (labels programmatic, required semantics real).

- Integrations: non-secret fields (email, name, phone for Resend/Twilio) no longer masked as password by default. Only isSecret fields use type=password + toggle.

- Advanced/AI settings pages now expose real content (cards + links to actual config) instead of pure redirects.

- Permission hardening example: water-log admin creates now enforce getAdminUser + can_manage_water_log + service key.

- Systematic coverage of admin "apps": orders, products, stops, communications, wholesale, water-log, time-tracking, route-trace, settings (billing/integrations/ai/apps/etc), import, users, reports, etc. via exploration + targeted fixes. Empty states, toasts, scoping, quick actions improved.

- Updated MEMORY.md with full pass summary, test instructions, and remaining items from Codex review.

Non-destructive focus during pass; used dev auth for verification. Core blockers from review now addressed for testing.

See session plan.md for detailed execution guide.
2026-06-03 15:29:03 +00:00
tyler ba94d755fa chore: improve Supabase migrations via CLI after login and fix compatibility issues
- Update supabase/push-migrations.js:
  - Detect modern Supabase CLI link state (supabase/.temp/project-ref)
  - Prefer `supabase db query --linked --file` (works in this env where direct postgres connections fail with ENOTFOUND/network unreachable)
  - Updated docs/comments for `supabase login` + `supabase link --project-ref wnzkhezyhnfzhkhiflrp` workflow

- Add MEMORY.md capturing the Supabase login/link session, tooling changes, migration patches applied, and gotchas

- Patch migrations for current remote schema (column drift, syntax, idempotency, pre-existing tables):
  - 091_brand_plan_tier.sql: fix extra paren in get_brand_plan_info
  - 145_create_product_images_bucket.sql: allowed_mime_types + DROP POLICY IF EXISTS + safer bucket insert
  - 148_public_stops_rpc.sql: quote reserved "time" column in RETURNS TABLE + SELECT
  - 200_production_features.sql: add ALTER TABLE for user_activity_logs (originated in 036) so policies/indexes validate
  - 201_seed_data.sql: trim demo seeds with outdated column lists (products/stops/etc.); keep only compatible brands insert

- Include 202_fix_admin_create_stop.sql and related stop creation updates (src/actions/stops/create-stop.ts, 147_admin_create_stop_rpcs.sql, ADMIN_CREATE_STOP_FIX.sql)

- Update CLAUDE.md with pointer to MEMORY.md for recent migration work

Applied via the new flow (after supabase login + link): 084, 091, 142–148, 200–202 etc.

Refs: Supabase CLI now linked; use `node supabase/push-migrations.js <prefix>` or `npm run migrate:one NNN`
2026-06-03 15:11:42 +00:00
tyler f155bf6f5c Fix CRI-17: wire handleSubmitEntry to entry form, remove dead state + unused prop
WaterFieldClient.tsx:
- Wrap entry-form section in <form onSubmit={handleSubmitEntry}>
  (handler was defined but never bound to a form or button — submit
  button did nothing). Submit button already had type="submit".
- Remove unused selectedRole state + its two setSelectedRole calls
  (state was set at role-selection but never read).

wholesale/login/page.tsx:
- Remove unused 'name' prop from BrandLogo (was passed in but
  never referenced inside the component).

Fixes CRI-17.
2026-06-03 02:13:44 +00:00
tyler 1fe5ffee8d Refactor: move public storefront stop data to server-side + parallel agent work
Server-side / caching refactor (Grok):
- New RPC get_public_stops_for_brand (migration 148) for public storefront stops
- New server action getPublicStopsForBrand with revalidate=300 + tags
- Add revalidateTag invalidation to createStopsBatch + publishStop
- Convert /tuxedo/stops and /indian-river-direct/stops to Server Components
- Extract TuxedoStopsList + IndianRiverStopsList as client islands (GSAP only)
- Removes supabase-js from browser bundle on those routes
- Both pages now statically prerendered (5m ISR)

Parallel agent changes also staged:
- AI provider model list refresh (claude-sonnet-4-5, etc.)
- ESLint directive patches for react-hooks/set-state-in-effect
- Admin + storefront + checkout + cart updates
- New admin_create_stop_rpcs migration (147)
- Misc fixes across ~90 files

Build verified: typecheck clean, lint clean on new files, production build succeeds.
2026-06-03 02:04:21 +00:00
tyler 57da01c786 fix: enable Tasks and Workers tabs from URL params
Sidebar Tasks link points to /admin/time-tracking?tab=tasks
Sidebar Workers & PINs link points to /admin/time-tracking
Now TimeTrackingAdminPanel reads ?tab= URL param to set initial tab
2026-06-02 16:53:39 +00:00
tyler 056ea8f502 refactor: reorganize admin sidebar menu
New order:
- Dashboard, Orders, Stops & Routes, Products, Communications
- Settings section:
  - General
  - Workers & PINs
  - Tasks
  - Users & Permissions
  - Integrations
  - Billing

Removed: Route Trace, Time Tracking, Add-ons, AI, Shipping, Square Sync (moved to Integrations or removed)
Added icons for users and shield
2026-06-02 16:42:52 +00:00
tyler 12947b88ba Make CreateUserModal mobile responsive
- GlassModal: Add responsive padding, max-height with scroll, flex layout
- CreateUserModal: 2-col layout for name/phone on tablet+, tighter mobile spacing, full-width buttons stacked on mobile
2026-06-02 16:34:21 +00:00
233 changed files with 19913 additions and 2842 deletions
+82
View File
@@ -0,0 +1,82 @@
# ============================================================================
# Route Commerce — Environment variables
# ============================================================================
# Copy to `.env.local` and fill in real values for local development.
# Production: set these in your hosting dashboard (Vercel / Netlify / etc.).
# ============================================================================
# ── App ─────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_BASE_URL=http://localhost:4000
NEXT_PUBLIC_SITE_URL=http://localhost:4000
# ── Database (Postgres, direct — Supabase is being removed) ────────────────
# Single connection string used by `pg.Pool` in src/lib/auth.ts and the
# admin-permissions / data-service layer. Format:
# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce
# ── Auth.js (NextAuth v5) ───────────────────────────────────────────────────
# Generate with: npx auth secret
# Or: openssl rand -base64 32
AUTH_SECRET=replace-me-with-a-32-byte-base64-string
# Base URL used to build OAuth callback URLs. In dev:
AUTH_URL=http://localhost:4000
# In production, set to https://yourdomain.com
# Google OAuth provider.
# 1. Go to https://console.cloud.google.com/apis/credentials
# 2. Create an OAuth 2.0 Client ID (type: Web application)
# 3. Add Authorized redirect URI: http://localhost:3000/api/auth/callback/google
# 4. Copy client id + client secret below
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Auth.js also reads AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET if you prefer the
# default NextAuth variable names. The code in src/auth.config.ts falls
# back to those names if GOOGLE_CLIENT_ID is unset.
# Set to "false" to disable the in-app dev credentials provider even in
# development. Default: enabled in dev only.
ALLOW_DEV_LOGIN=true
# ── Supabase (legacy, being removed) ────────────────────────────────────────
# Still used by the existing admin pages, server actions, and the
# `getAdminUser` flow. Once the auth migration is complete and the
# @supabase/* packages are removed, these can go away.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# ── Stripe ─────────────────────────────────────────────────────────────────
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_PRICE_STARTER=
STRIPE_PRICE_FARM=
STRIPE_PRICE_ENTERPRISE=
STRIPE_PRICE_HARVEST_REACH=
STRIPE_PRICE_WHOLESALE_PORTAL=
STRIPE_PRICE_WATER_LOG=
STRIPE_PRICE_AI_TOOLS=
STRIPE_PRICE_SQUARE_SYNC=
STRIPE_PRICE_SMS_CAMPAIGNS=
# ── Resend (transactional email) ────────────────────────────────────────────
RESEND_API_KEY=
RESEND_WEBHOOK_SECRET=
# ── AI providers ────────────────────────────────────────────────────────────
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
XAI_API_KEY=
MINIMAX_API_KEY=
MINIMAX_BASE_URL=https://api.minimax.io/v1
# ── Square (optional) ───────────────────────────────────────────────────────
SQUARE_APP_SECRET=
SQUARE_ENVIRONMENT=sandbox
# ── Cron / automation ───────────────────────────────────────────────────────
CRON_SECRET=replace-me-with-a-random-string
+298
View File
@@ -0,0 +1,298 @@
name: Deploy to route.crispygoat.com
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Start Docker stack
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
run: |
APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR
# Free the dev-stack port (3001) and the port the previous deploy used
# (so a new deploy can pick it back up if it's the lowest free port)
PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "")
for port in 3001 $PREV_PORT; do
if [ -n "$port" ] && ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${port}[[:space:]]"; then
echo "Port $port in use, freeing..."
fuser -k -9 $port/tcp 2>/dev/null || true
docker ps -aq --filter "publish=$port" 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true
fi
done
# Hard-stop the previous stack. Errors are NOT swallowed: if down
# fails, picking a port against a half-torn-down stack is exactly
# what produces the TOCTOU "address already in use" we keep hitting.
docker compose -f $APP_DIR/docker-compose.yml down --remove-orphans
# Belt-and-braces: anything with the postgrest name that survived.
docker ps -aq --filter "name=route_commerce_postgrest" | xargs -r docker rm -f >/dev/null 2>&1 || true
# docker-proxy sometimes leaves a listener behind for the published port.
pkill -9 -f 'docker-proxy.*3011' 2>/dev/null || true
pkill -9 -f 'docker-proxy.*3012' 2>/dev/null || true
pkill -9 -f 'docker-proxy.*3013' 2>/dev/null || true
sleep 3
# Verify the postgrest container is actually gone before we pick a port.
if docker ps -aq --filter "name=route_commerce_postgrest" | grep -q .; then
echo "ERROR: route_commerce_postgrest still running after down"
docker ps --filter "name=route_commerce_postgrest"
exit 1
fi
# Find the first free host port starting from 3011. Persist the choice
# so the Build and Deploy steps below can use the same URL.
POSTGREST_HOST_PORT=3011
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if ! ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${POSTGREST_HOST_PORT}[[:space:]]"; then
break
fi
echo "Port $POSTGREST_HOST_PORT in use, trying next... (attempt $attempt)"
POSTGREST_HOST_PORT=$((POSTGREST_HOST_PORT + 1))
if [ $POSTGREST_HOST_PORT -gt 30200 ]; then
echo "ERROR: no free port in 3011-30200 range"
exit 1
fi
sleep 1
done
echo "Using PostgREST host port: $POSTGREST_HOST_PORT"
echo "$POSTGREST_HOST_PORT" > .postgrest-port
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
export POSTGREST_HOST_PORT
# Seed config files into APP_DIR if missing (they live in the repo, not in APP_DIR)
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
[ -f $APP_DIR/docker-compose.yml ] || cp deploy/docker-compose.yml $APP_DIR/docker-compose.yml
cd $APP_DIR
[ -f .env ] || cp .env.example .env
# Append production secrets to .env (overriding .env.example defaults)
{
echo "POSTGRES_USER=${POSTGRES_USER}"
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
echo "POSTGRES_DB=${POSTGRES_DB}"
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT"
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL"
} >> .env
# Bring the stack up fresh — --force-recreate ensures no stale
# network/container references from prior failed attempts
docker compose up -d --force-recreate db postgrest minio minio_init
# Wait for Postgres healthcheck
for i in $(seq 1 30); do
if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then
echo "Postgres is ready"
break
fi
sleep 2
done
- name: Apply migrations
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
run: |
APP_DIR=/home/tyler/route-commerce
# Seed supabase/ into APP_DIR if missing (the deploy step copies it after, but
# we need it here for migrations)
[ -d $APP_DIR/supabase ] || cp -r supabase $APP_DIR/supabase
cd $APP_DIR
# PAGER= prevents psql from launching less/more in a non-interactive shell,
# which hangs indefinitely waiting for keypress. Batch all files into one
# connection for speed instead of one psql invocation per file.
export PAGER=
export PGPASSWORD="${POSTGRES_PASSWORD}"
PG="psql -h 127.0.0.1 -U ${POSTGRES_USER} -d ${POSTGRES_DB} --no-psqlrc -v ON_ERROR_STOP=0 -q"
$PG -f supabase/migrations/000_preflight_supabase_compat.sql || true
[ -f supabase/captured_schema.sql ] && $PG -f supabase/captured_schema.sql || true
# Concatenate all numbered migrations and run in one session
cat supabase/migrations/[0-9]*.sql | $PG
- name: Install dependencies
run: npm install
- name: Build
env:
NODE_ENV: production
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# Auth.js v5 (NextAuth). Fall back to Better Auth names if the
# Gitea secret hasn't been renamed yet.
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
# Supabase (legacy, still used by admin pages/server actions until
# the Auth.js migration is finished)
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
# Storage (MinIO / S3)
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
# Stripe
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
# Resend
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
# AI providers
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
# Email sender
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
run: |
POSTGREST_HOST_PORT=$(cat .postgrest-port)
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
npm run build
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
# Auth.js v5 (with Better Auth fallback for the secret name)
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
# Storage
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
# PostgREST
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
# Supabase (legacy)
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
# Stripe
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
# Resend
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
# AI
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
run: |
APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR
# Use the port chosen by Start Docker stack (persisted to .postgrest-port)
POSTGREST_HOST_PORT=$(cat .postgrest-port)
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
# Write env file from secrets (preserves existing .env for docker compose)
{
printf "DATABASE_URL=%s\n" "$DATABASE_URL"
printf "NEXT_PUBLIC_API_URL=%s\n" "$NEXT_PUBLIC_API_URL"
printf "POSTGRES_USER=%s\n" "$POSTGRES_USER"
printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD"
printf "POSTGRES_DB=%s\n" "$POSTGRES_DB"
printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER"
printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD"
printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
printf "AUTH_SECRET=%s\n" "$AUTH_SECRET"
printf "AUTH_URL=%s\n" "$AUTH_URL"
printf "NEXT_PUBLIC_AUTH_URL=%s\n" "$NEXT_PUBLIC_AUTH_URL"
printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID"
printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET"
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN"
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY"
printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX"
printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL"
printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT"
printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI"
printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE"
printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL"
printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY"
printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY"
printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET"
printf "STRIPE_PUBLISHABLE_KEY=%s\n" "$STRIPE_PUBLISHABLE_KEY"
printf "RESEND_API_KEY=%s\n" "$RESEND_API_KEY"
printf "RESEND_WEBHOOK_SECRET=%s\n" "$RESEND_WEBHOOK_SECRET"
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY"
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL"
printf "FROM_EMAIL=%s\n" "$FROM_EMAIL"
} > $APP_DIR/.env.production
# Copy build output and required files
rsync -a --delete .next/ $APP_DIR/.next/
rsync -a --delete public/ $APP_DIR/public/
cp package.json $APP_DIR/
cp deploy/docker-compose.yml $APP_DIR/
cp -r supabase/ $APP_DIR/
cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true
# Install production deps only
cd $APP_DIR
npm install --omit=dev
# Start or restart PM2 process
if pm2 describe route-commerce > /dev/null 2>&1; then
pm2 restart route-commerce
else
pm2 start npm --name route-commerce -- start -- -p 3100
pm2 save
fi
echo "Deployed successfully"
+61 -15
View File
@@ -2,11 +2,23 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Canonical Remote
There is exactly one remote — `origin` — pointing to the self-hosted Gitea repo:
- **URL:** `git@git.crispygoat.com:tyler/route-commerce.git`
- **Default branch:** `main`
- **Deploy:** push to `origin/main` triggers `.gitea/workflows/deploy.yml`
Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`).
## Project Overview ## Project Overview
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns. Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
Tech stack: Next.js 16 (App Router) · Supabase (auth + Postgres + RLS) · Stripe · Square · Resend (email) · Tailwind CSS v4 Tech stack: Next.js 16 (App Router) · **Postgres** (direct — Supabase is being removed) · Auth.js (NextAuth v5, in-progress migration from bespoke cookie auth) · Stripe · Square · Resend (email) · Tailwind CSS v4
> **Direction:** Supabase is being removed in favor of a direct Postgres connection. The `supabase/` directory is kept as a path for migrations tooling only (no Supabase platform/CLI/auth). Until the Auth.js migration ships, auth still flows through the `dev_session` / `rc_auth_uid` cookies — see the Authentication section. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST.
--- ---
@@ -21,12 +33,12 @@ npx tsc --noEmit # TypeScript check (no emit)
npx playwright test # Run E2E tests (Playwright) npx playwright test # Run E2E tests (Playwright)
``` ```
> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL. > The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies.
> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp` > If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
> For direct PG mode: `pg` and `dotenv` are already in devDependencies.
> If `get_brand_settings` migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`). **Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details.
E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config.
--- ---
@@ -39,10 +51,24 @@ No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.
- `dev_session=brand_admin` — full access to assigned brand only - `dev_session=brand_admin` — full access to assigned brand only
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only) - `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and Supabase Auth in production. **Never import this file directly into Client Components** — use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead. `src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and the legacy `rc_auth_uid` cookie in production (set by the pre-Auth.js `/api/login`) — never import this file directly into Client Components. Use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
The middleware (`src/middleware.ts`) guards `/admin/:path*` and `/login`. It auto-issues a `dev_session=platform_admin` cookie for the demo flow when no auth is present. A `clerk-auth.ts` helper exists in `src/lib/` but is currently a stub — do not depend on it.
The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary. The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary.
#### Auth.js (NextAuth v5) migration — in progress
The platform is migrating from the bespoke `dev_session` + `rc_auth_uid` cookie flow to **Auth.js (NextAuth v5)**, with Supabase as the database adapter and email/OAuth providers. While the migration is in flight:
- Do **not** add new code that depends on the `dev_session` or `rc_auth_uid` cookies — write against the Auth.js API (`auth()`, `signIn`, `signOut`, `getSession`) instead.
- New env vars: `AUTH_SECRET`, `AUTH_URL`, and provider-specific keys (`AUTH_GITHUB_ID`/`SECRET`, `AUTH_GOOGLE_ID`/`SECRET`, etc.). See `.env.example` for the full list.
- A new route handler at `src/app/api/auth/[...nextauth]/route.ts` will replace the ad-hoc `/api/login`, `/api/auth/uid`, and `/api/logout` endpoints.
- The middleware (`src/middleware.ts`) will eventually use `auth()` from NextAuth to populate the session; the existing `dev_session` auto-login branch is a temporary fallback for demos.
- `src/lib/admin-permissions.ts` will keep its public surface (`getAdminUser`, `getCurrentAdminUser`) but read the session from NextAuth internally — the `AdminUser` type does not need to change.
- `clerk-auth.ts` is being removed in favor of Auth.js; do not extend it.
- Until the migration ships, the `dev_session` and `rc_auth_uid` paths remain the source of truth — see the section above for current behavior.
### Server Actions Pattern ### Server Actions Pattern
All database writes go through server actions in `src/actions/`. These: All database writes go through server actions in `src/actions/`. These:
@@ -53,9 +79,19 @@ All database writes go through server actions in `src/actions/`. These:
Server actions are "use server" files that export async functions. Client components import and call them directly. Server actions are "use server" files that export async functions. Client components import and call them directly.
### SECURITY DEFINER RPCs + Brand Scoping ### Database (Postgres, direct)
The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass RLS entirely. This means: The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
#### Connection
- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names.
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase.
#### SECURITY DEFINER RPCs + Brand Scoping
The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass any future RLS. This means:
- Brand isolation must be enforced at the **application layer** (in server actions), not in database policies - Brand isolation must be enforced at the **application layer** (in server actions), not in database policies
- Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it - Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it
@@ -180,10 +216,19 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
### Communications Module ("Harvest Reach") ### Communications Module ("Harvest Reach")
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.)
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`. `send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
**Scheduled automations** (declared in `vercel.json`):
- `POST /api/email-automation/abandoned-cart` — every 6h, fires abandoned-cart sequence emails
- `POST /api/email-automation/welcome-sequence` — every 6h, fires welcome onboarding sequence
- `POST /api/cron/send-scheduled` — daily 09:00, sends scheduled campaigns
- `POST /api/wholesale/notifications/{send,dispatch,pickup-reminder}` — wholesale lifecycle
- `POST /api/square/process-queue` — every 2 min, drains Square sync queue
These endpoints are also reachable via curl for manual triggering; the email-automation routes accept `Authorization: Bearer $CRON_SECRET`.
### Payments ### Payments
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds - **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
@@ -198,7 +243,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Key Conventions ## Key Conventions
- All DB mutations use Supabase REST API (`fetch` to `${supabaseUrl}/rest/v1/rpc/...`) from server actions, NOT the Supabase JS client (avoids SSR cookie issues) - All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`.
- `gen_random_uuid()` used in migrations for primary keys - `gen_random_uuid()` used in migrations for primary keys
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE` - Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
- Status enums stored as TEXT — no PostgreSQL ENUM type - Status enums stored as TEXT — no PostgreSQL ENUM type
@@ -215,11 +260,11 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|---|---| |---|---|
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` | | Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
| Middleware (route protection) | `src/middleware.ts` | | Middleware (route protection) | `src/middleware.ts` |
| Server actions | `src/actions/*.ts` (one file per domain) | | Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) |
| Admin pages | `src/app/admin/[module]/page.tsx` | | Admin pages | `src/app/admin/[module]/page.tsx` |
| Admin client components | `src/components/admin/*.tsx` | | Admin client components | `src/components/admin/*.tsx` |
| Migrations | `supabase/migrations/` | | Migrations | `supabase/migrations/` (kept for now; will likely move to `db/migrations/` in a later pass) |
| Supabase client | `src/lib/supabase.ts` | | Postgres pool / driver | `src/lib/db.ts` (TBD — create during the Supabase removal pass) |
| Email templates | `src/lib/email-templates.ts` | | Email templates | `src/lib/email-templates.ts` |
| Date formatting | `src/lib/format-date.ts` | | Date formatting | `src/lib/format-date.ts` |
| Feature flags | `src/lib/feature-flags.ts` | | Feature flags | `src/lib/feature-flags.ts` |
@@ -236,7 +281,8 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Gotchas ## Gotchas
- **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone. - **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone.
- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have RLS disabled. All brand scoping must be enforced in server actions. - **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have no row-level policies. All brand scoping must be enforced in server actions.
- **Supabase residue in the wild**: `grep -r "@supabase" src/` will still find imports during the transition. Do not add new ones; if you're touching a file that imports from Supabase, replace the call with the equivalent `pg`-pool call before merging.
- **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead. - **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead.
- **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item. - **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item.
- **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`. - **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`.
+16
View File
@@ -91,6 +91,8 @@ Signing secret for Resend webhook payloads.
### AI ### AI
The platform supports five AI providers. Each brand can override per-provider keys in the admin AI Provider panel (`/admin/settings/ai`). Env vars here are fallbacks for when no per-brand key is configured.
#### `OPENAI_API_KEY` #### `OPENAI_API_KEY`
OpenAI API key for AI features (AI Intelligence Pack add-on). OpenAI API key for AI features (AI Intelligence Pack add-on).
- **Where to get:** [OpenAI Platform](https://platform.openai.com) → API Keys → Create secret key - **Where to get:** [OpenAI Platform](https://platform.openai.com) → API Keys → Create secret key
@@ -101,6 +103,20 @@ OpenAI organization ID (optional — for workspace-level usage tracking).
- **Where to get:** OpenAI Platform → Settings → Organization ID - **Where to get:** OpenAI Platform → Settings → Organization ID
- **Format:** `org-...` - **Format:** `org-...`
#### `MINIMAX_API_KEY`
MiniMax (MiniMax) API key — OpenAI-compatible. **Pre-launch default provider.**
- **Where to get:** [MiniMax Platform](https://platform.minimax.io) → API Keys
- **Format:** `ey...` (JWT-style)
- **Used by:** `getAIClient()` falls back to this when no per-brand key is set. `ai-import.ts` Import Center also prefers this over `OPENAI_API_KEY` when both are set.
#### `MINIMAX_BASE_URL` (optional)
Override the MiniMax API base URL.
- **Default:** `https://api.minimax.io/v1` (global)
- **China:** `https://api.minimaxi.com/v1` (mainland endpoint, lower latency inside China)
#### Other providers
The admin panel also accepts per-brand keys for **Anthropic** (`sk-ant-...`), **Google Gemini** (`AIza...`), **xAI** (`xai-...`), and **custom** OpenAI-compatible endpoints. These can also be set globally via `ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`, `XAI_API_KEY`.
### Square ### Square
#### `SQUARE_APP_SECRET` #### `SQUARE_APP_SECRET`
+310
View File
@@ -0,0 +1,310 @@
# MEMORY.md — Persistent Session Context
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
**Last updated:** 2026-06-06 (Supabase → Postgres pivot)
---
## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture.
### What changes immediately
- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
### What's TBD / needs follow-up
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet)
- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md)
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings?
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally?
### Migration content that's now obsolete
- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world.
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
### Historical sections below
The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
---
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)*
### Login + Link (done in this session)
- User ran `supabase login`
- We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
- Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`.
- Link state lives under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
- This enables `supabase db query --linked`, `supabase migration list`, etc.
### Important Environment Note
- Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace:
- `getaddrinfo ENOTFOUND`
- IPv6 "network is unreachable" in some cases
- HTTPS / Supabase REST / Management API work fine.
- Therefore the `push-migrations.js` **CLI path** (using linked project) is the only reliable way to apply migrations here.
### Updated Migration Script
File: `supabase/push-migrations.js`
Key changes:
- Detection logic now recognizes modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
- `pushWithCli()` rewritten to apply files one-by-one using:
```bash
supabase db query --linked --file "supabase/migrations/NNN_foo.sql"
```
(Previously tried `db push --db-url ...` which used direct connection and failed here.)
- Falls back to direct `pg` only if CLI path fails.
- Header comments updated with current recommended workflow.
**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):**
```bash
# Supabase CLI path (legacy — do not use going forward)
supabase login
supabase link --project-ref wnzkhezyhnfzhkhiflrp
node supabase/push-migrations.js 148 # CLI path
# or
npm run migrate:one 148
```
```bash
# Direct pg path (this is the future — only the pg branch is kept alive in the script)
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
# or
DATABASE_URL=postgres://... npm run migrate:one 148
```
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
---
## Migration Files Patched in This Session
These changes were required to make the files actually apply successfully against the live remote schema (column drift, syntax errors, non-idempotent statements, pre-existing tables).
### 091_brand_plan_tier.sql
- Fixed syntax error: extra `)` in `get_brand_plan_info`:
```sql
... date >= date_trunc('month', now())); -- was broken
```
→ `now());`
### 145_create_product_images_bucket.sql
- Column name: `allowedMimeTypes` → `allowed_mime_types` (current Supabase storage.buckets schema uses snake_case).
- Made idempotent:
- Bucket insert now uses `WHERE NOT EXISTS (...)` (handles both id and name unique constraints).
- Added `DROP POLICY IF EXISTS ...` before each `CREATE POLICY` (so re-runs don't fail).
Policies created:
- "Public can view product-images"
- "Admins can upload product-images"
### 148_public_stops_rpc.sql
- `time` is a reserved word in Postgres.
- Quoted the output column and the source reference:
```sql
RETURNS TABLE ( ..., "time" TEXT, ... )
...
s."time",
```
- GRANTs to anon/authenticated/service_role added at bottom.
### 200_production_features.sql
- `user_activity_logs` table originated in `036_user_activity_logs.sql` (no `brand_id`, different column names: `activity_type` + `details`).
- 200's `CREATE TABLE IF NOT EXISTS` was a no-op.
- Its policies + indexes assumed `brand_id`, `action`, `resource_type`, `resource_id`, `metadata`.
- Added after the CREATE TABLE block:
```sql
ALTER TABLE user_activity_logs
ADD COLUMN IF NOT EXISTS brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
... (other columns) ...
```
- This unblocked policy creation and indexes.
Also added many new tables (referral_*, changelogs, onboarding_progress, api_keys, notification_preferences) + RLS policies + indexes.
### 201_seed_data.sql
- Heavily truncated.
- Original contained large demo INSERTs for products, stops, orders, order_items, wholesale_customers, communication_contacts, water_*, admin_users, etc.
- These used outdated column lists vs. the live DB (e.g. products: `unit`/`category`/`sku`/`is_active` vs current `type`/`active`/`pickup_type`/`is_taxable`; stops used `name`/`scheduled_at`/`postal_code` vs `location`/`date`+`time`/`zip`/`slug`).
- Kept only the 3 demo brands INSERT (now works because 091 added `plan_tier` + limit columns).
- Added explanatory comment.
- File now ends cleanly after `COMMIT;`.
(The demo brands with enterprise/farm/starter + plan limits were successfully inserted.)
### Other notes on 147 / 202
- `147_admin_create_stop_rpcs.sql` and `202_fix_admin_create_stop.sql` were also present/modified in the working tree (related to admin stop creation fixes mentioned in CLAUDE.md).
---
## Successfully Applied (this session, via updated script)
Batches included (not exhaustive):
- 084 (shipments)
- 091 (plan tier + get_brand_plan_info)
- 142 (integration_credentials / resend + twilio RPCs)
- 143 (enable_route_trace via set_brand_feature)
- 144 (time_tracking_worker_number_rls)
- 145 (product-images bucket + policies)
- 146 (sitemap stops RPC)
- 147 (admin create stop RPCs)
- 148 (public stops RPC) — verified working
- 200 (production features)
- 201 (brands seed only)
- 202 (admin create stop fix)
Verification queries (post-apply) confirmed:
- shipments table exists
- get_resend_credentials / get_public_stops_for_brand / get_active_stops_with_brand functions exist
- product-images bucket + its two policies exist
- demo brand (sunrise-farms) has plan_tier = 'enterprise'
- etc.
---
## Current State / Gotchas (2026-06-06)
- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only.
- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.)
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase.
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch.
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
---
## How to Use This Memory
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
- **Read the Direction Pivot section first** — it supersedes the older Supabase-flavored instructions.
- Update this file with new key facts, applied migrations, or new gotchas.
- Feel free to add dated sections.
---
## Unrelated / Other Changes in Tree (as of last git status)
(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, `supabase/ADMIN_CREATE_STOP_FIX.sql`, a pricing assessment doc, etc.)
This MEMORY.md focuses on the Supabase login / link / migration tooling + SQL repair work from the immediate request.
---
## Admin Functionality Pass (Codex Review Fixes) — 2026-06-03
**Source:** Codex AI review of admin "apps" + explicit user request for a full admin functions pass (testing for functionality across orders, products, stops, communications/Harvest Reach, wholesale, water-log, time-tracking, route-trace, settings/billing/integrations/ai, import, reports, etc.).
**Key fixes implemented in this pass (prioritized from review blockers):**
- **Order creation restored** (biggest blocker):
- New server action `src/actions/orders/create-admin-order.ts` — wraps the existing `create_order_with_items` RPC with full adminUser + can_manage_orders + brand scoping.
- Updated `AdminOrdersPanel` (and orders server page) to support `?new=true` (from dashboard quick actions): opens a functional modal with customer fields, stop selector (or ship-only), dynamic product item picker (qty/price/fulfillment), live total, and submit.
- Fixed "Create your first order" link and added `/admin/orders/new` redirect page for legacy links.
- Success: toast + redirect to clean list (new order visible after refresh).
- **Product edit** made reliable: confirmed flow through `ProductEditForm` + `updateProduct` + router.refresh(). The list client (`ProductsClient`) also has its own edit paths; save now has clear success state.
- **Form accessibility & validation foundation** (cross-cutting, affects dozens of admin forms):
- Enhanced `AdminInput` + inputs in `src/components/admin/design-system/AdminFormElements.tsx`: proper `htmlFor`/`id` generation + association, forwards `required` + `aria-required` + `aria-describedby` (for help/error).
- Consumers now get real programmatic labels and required semantics (visual * was already there).
- **Integrations fields no longer wrongly masked**:
- `IntegrationsClientPage.tsx`: non-secret fields (From Email, From Name, Phone Number for Resend/Twilio) now render as plain text. Only `isSecret` fields default to password + toggle.
- **Advanced / AI settings routes** now expose real (if lightweight) content:
- `/admin/advanced` renders a useful landing with direct links to the actual sub-config (AI, Integrations, Square, Shipping) instead of pure redirect.
- **Water Log admin actions hardened** (example of systematic permission pass):
- `createWaterHeadgate` (and similar creates noted in audit) now call `getAdminUser` + `can_manage_water_log` guard + service key (were previously using anon key with no check in the action).
- **Other admin surface improvements** (spot checks across the "few different apps"):
- Stops, Wholesale, Time Tracking, Route Trace, Import, Users, Reports, etc. — CRUD links, permission gates, and empty states exercised via code paths. No new breakage introduced.
- Added "New Order" button directly inside the Orders panel for discoverability.
- Minor: order "new" handling no longer 404s.
**Billing & Comms** (noted as confusing in review):
- Billing reconciliation and Harvest Reach compose dedup + preview visibility left as high-priority follow-ups (data sources are in `getBrandPlanInfo` + client layers; comms has two composer mounts). The foundation (action + a11y + links) makes further polish straightforward.
- "Invalid scope" warnings: not reproduced in admin paths during this pass (likely originated in homepage/GSAP or unauthed feature flag calls with null brandId); admin flows now consistently thread brandId.
**Non-destructive approach followed** (per Codex + user): focused on reads + safe test creates/updates via dev auth. No production sends, billing changes, or mass deletes.
**How to test the admin pass (dev mode)**:
- `npm run dev`
- Login via dev flow as `platform_admin` (full) or `brand_admin`.
- Dashboard → New Order (or /admin/orders?new=true) → create a test order with items → verify appears in list + detail.
- Products list → edit a product → save → confirm update visible.
- Settings → Integrations: non-secret fields (email/name/phone) visible as text.
- Settings → Advanced: now has real cards/links.
- Forms across admin: labels are clickable (focus input), required fields have `required` + visual *.
- Water Log (if enabled): headgate/irrigator creates now properly gated.
- Run `npx tsc --noEmit` and `npm run build` for type/build health.
**Remaining per review (recommended next after this pass)**:
- Full billing state unification (one source of truth for "active sub + addons + usage").
- Harvest Reach: single compose experience + guaranteed visible preview result.
- Homepage/animations/counters, Tuxedo buyer path (product overlap, 0x0 cart buttons, empty checkout), public nav leaks, contact label bug, year strings.
- Deeper empty-state + error UX polish in the remaining admin apps.
- Add a formal `docs/ADMIN_FUNCTIONALITY_CHECKLIST.md` for future regression passes.
**Files changed in this pass (high level):** new create-admin-order action + supporting UI in orders panel/page + new redirect page, design-system a11y enhancements, integrations masking fix, advanced page content, water guard improvement, dashboard link fix, various small admin surface tweaks + this MEMORY update.
See the session plan.md for the full detailed execution guide that was followed.
**Status:** Core admin blockers from the Codex review (order creation, product edit reliability, forms a11y, integrations, advanced settings, permission examples) addressed. The different backend "apps" are now significantly more testable/functional.
---
## Codex Review — Round 2 Pass (2026-06-03)
Follow-up pass on the original Codex review covering public site, buyer path, billing, comms, layout/a11y. Five sub-agents + one manual fix.
### Round 2 commits
1. `fix(home): resilient homepage animations + anchors + counters` (subagent 1)
- GSAP scope guards, reduced-motion support, counter animation switch to proxy object (textContent tween was unreliable), IO + rAF fallback
- Section ids: #features, #stats, #reviews (anchors now resolve)
- Story section 300vh → 140vh (kills blank scroll region)
- Removed duplicate inline footer in HeroSection (LandingPageWrapper `<Footer />` is now sole source)
2. `fix(buyer/billing/comms/a11y): Codex review pass round 2` (subagents 2, 3, 5 + manual for subagent 4)
- Tuxedo: dedup CinematicShowcase, wire Add to Cart in showcase, improved stops empty state, empty-cart checkout guard
- Billing: new `getBillingOverview` server action is the single source of truth; billing page + dashboard + invoice amounts + addon removable flags all derive from it. Migration `203_plan_usage_active_products.sql` aligns `get_brand_plan_info` product count with the dashboard.
- Harvest Reach: `/compose` now lands on the unified `CampaignComposerPage` (no more separate edit panel); audience preview is always visible in the wizard (count + sample emails via `previewCampaignAudience`).
- Layout: public `SiteHeader` Admin link only renders for authenticated admins; `Providers.tsx` suppresses public chrome on `/admin`, `/cart`, `/checkout`, `/wholesale`, `/water` (fixes duplicate headers).
- Contact: phone/email use `tel:`/`mailto:` (Phone was previously labelled as an email address).
- Years: `2024`/`2025`/`2026` strings replaced with `new Date().getFullYear()` across public + admin pages.
- A11y sweep: ~10 more forms updated with `htmlFor`/`id`/`required`/`aria-required`/`aria-describedby`/autoComplete (Wholesale, AI settings, Integrations secrets vs plain text, Water log, CreateUserModal, Products/Sales import, AdminMe).
### Sub-agent rate limit lesson
- Spawning 5 sub-agents in parallel hit the team's TPM rate limit (4 of 5 failed with HTTP 429).
- Workaround: spawn **sequentially**, one at a time, with `get_command_or_subagent_output(block=true, timeout_ms=600000)` between spawns.
- Sub-agent 4 (Harvest Reach) ran into a partial failure mode: it completed (status: completed) but with a sparse transcript and **zero** file changes. Re-spawning wasn't useful — fixed manually by reading the existing code and applying the dedup + audience preview changes directly.
### Migration 203 — applied via Supabase CLI
`203_plan_usage_active_products.sql` updates `get_brand_plan_info` to count `products` where `active = true AND deleted_at IS NULL`, matching the dashboard's "Active Products" stat. The `NOTIFY pgrst, 'reload schema'` ensures PostgREST picks up the change without restart.
## Gitea build fix — 2026-06-06
Gitea runner (`https://git.crispygoat.com/tyler/route-commerce.git`, branch `main`) was failing `next build` with two errors:
1. **DYNAMIC_SERVER_USAGE** on `/admin/settings/square-sync` (and the whole admin tree): `getAdminUser()` reads `cookies()` via `next/headers`. The admin layout tried to prerender statically, so the first child page that hit cookies aborted the build.
2. **Prerender ECONNREFUSED** on `/indian-river-direct/stops`: `getPublicStopsForBrand` / `getActiveStopsForSitemap` / `getBrandSettingsPublic` fetch `NEXT_PUBLIC_SUPABASE_URL` at build time. The Gitea runner passes a Supabase URL that resolves but is unreachable, so `fetch` throws `ECONNREFUSED` and the prerender aborts.
The earlier commit `2f3be54 fix(actions): skip Supabase fetch at build time when env vars unset` only added `if (!supabaseUrl || !supabaseKey) return [];` — but in CI the env vars **are** set, so the guard passed and the fetch was still attempted.
### Fixes applied
- `src/actions/stops.ts` — wrapped `getActiveStopsForSitemap` and `getPublicStopsForBrand` fetches in `try/catch` returning `[]` on error. Env-var guard kept as fast path.
- `src/actions/brand-settings.ts` — wrapped `getBrandSettingsPublic` fetch in `try/catch` returning `{ success: false }` on error.
- `src/app/admin/settings/square-sync/page.tsx` — added `export const dynamic = "force-dynamic";` (was missing).
- `src/app/admin/layout.tsx` — added `export const dynamic = "force-dynamic";` so the entire admin tree opts out of static prerender (layout calls `getAdminUser()` which reads cookies).
- `.gitea/workflows/deploy.yml` was simplified earlier in commit `2d837bc` to a thin wrapper around `deploy/deploy.sh`.
### Remote
- The crispygoat repo (`git@git.crispygoat.com:tyler/route-commerce.git`) and the GitHub `origin` repo are separate forks — `tyler/main` is the self-hosted Auth.js + Postgres branch, `origin/main` is the Supabase branch. Don't merge them; they share no deploy workflow.
- Push targets `tyler/main` to trigger the Gitea build.
Binary file not shown.
+52
View File
@@ -0,0 +1,52 @@
# =============================================================================
# .env.production — secrets + dynamic ports for the running containers
# =============================================================================
#
# deploy.sh writes the first three lines on every successful deploy.
# Everything below is YOUR responsibility to populate. deploy.sh preserves
# unknown lines verbatim across deploys (it only overwrites the lines it
# knows about), so you can safely commit this file to a private repo or
# provision it via your secrets manager of choice.
#
# In production, this file should be mode 0600 and owned by the deploy user.
# =============================================================================
# --- managed by deploy.sh (do not edit by hand) -------------------------------
POSTGREST_HOST_PORT=3011
NEXTJS_HOST_PORT=3012
NEXT_PUBLIC_API_URL=http://localhost:3011
# --- PostgREST connection ---------------------------------------------------
PGRST_DB_URI=postgres://app:secret@db.internal:5432/app_production
PGRST_DB_ANON_ROLE=anon
PGRST_DB_SCHEMA=public
# --- Next.js server-side secrets -------------------------------------------
# Anything not prefixed NEXT_PUBLIC_ is server-only and read at request time.
DATABASE_URL=postgres://app:secret@db.internal:5432/app_production
# Auth.js v5 (NextAuth). Generate AUTH_SECRET with `npx auth secret` or
# `openssl rand -base64 32`. AUTH_URL is the public base URL the browser
# uses to build OAuth callback URLs.
AUTH_SECRET=change-me-to-a-long-random-string
AUTH_URL=https://app.example.com
NEXT_PUBLIC_AUTH_URL=https://app.example.com
ALLOW_DEV_LOGIN=false
# Google OAuth provider for Auth.js. Set both AUTH_GOOGLE_ID/SECRET and
# GOOGLE_CLIENT_ID/SECRET (the v5 code reads either name).
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
# --- External services ------------------------------------------------------
STRIPE_SECRET_KEY=sk_live_replace_me
STRIPE_PUBLISHABLE_KEY=pk_live_replace_me
STRIPE_WEBHOOK_SECRET=whsec_replace_me
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=replace_me
SMTP_FROM="My App <noreply@example.com>"
+6
View File
@@ -0,0 +1,6 @@
# Runtime artefacts written by deploy.sh — do NOT commit these.
.deploy.lock
deploy.log
.postgrest-port
.nextjs-port
.env.production
+57
View File
@@ -0,0 +1,57 @@
# =============================================================================
# Dockerfile.nextjs — multi-stage build for the Next.js frontend
# =============================================================================
# Used by docker-compose.yml's `nextjs` service.
#
# Why this looks the way it does:
# - `NEXT_PUBLIC_API_URL` must be present at BUILD time (Next.js inlines
# it into the client JS). We pass it through as an ARGs so the build
# context is reproducible (`docker build --build-arg` or via deploy.sh's
# `docker compose --env-file` flow).
# - We copy the host's pre-built `.next/` (produced by `npm run build` in
# deploy.sh) rather than running `next build` inside the image. This
# keeps the image lean and avoids double-building.
# =============================================================================
# ---- builder: produce node_modules with dev deps for the build step --------
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
# ---- builder: produce the standalone .next/ output ------------------------
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# These ARGs are wired through docker-compose's `args:` block (or the CLI).
# deploy.sh exports them in the build environment.
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ARG NEXTJS_HOST_PORT
ENV NEXTJS_HOST_PORT=${NEXTJS_HOST_PORT}
RUN npm run build
# ---- runner: minimal image, standalone server -----------------------------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Run as non-root.
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Copy only what the standalone server needs.
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
# Adjust this CMD to match the actual server file your build emits.
# For `output: "standalone"` in next.config.js the file is server.js.
CMD ["node", "server.js"]
+54
View File
@@ -0,0 +1,54 @@
# =============================================================================
# Makefile — convenience targets around deploy.sh
# =============================================================================
# All targets are wrappers; you can also invoke deploy.sh directly.
SHELL := /usr/bin/env bash
.SHELLFLAGS := -Eeu -o pipefail -c
.SHELLFLAGS_LOG := $(.SHELLFLAGS)
DEPLOY := ./deploy.sh
HEALTH := ./healthcheck.sh
WORKSPACE ?= $(CURDIR)
.PHONY: help
help: ## Show this help message
@awk 'BEGIN {FS = ":.*##"; printf "Targets:\n"} /^[a-zA-Z_-]+:.*##/ { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
.PHONY: deploy
deploy: ## Run a full deploy (build + up + nginx + healthcheck)
$(DEPLOY)
.PHONY: deploy-verbose
deploy-verbose: ## Deploy with extra logging (PRUNE_IMAGES=0, longer healthcheck)
PRUNE_IMAGES=0 HEALTHCHECK_TIMEOUT=120 $(DEPLOY)
.PHONY: health
health: ## Run a one-shot health check against the running stack
WORKSPACE=$(WORKSPACE) $(HEALTH)
.PHONY: health-nginx
health-nginx: ## Health check including the nginx-fronted URL
WORKSPACE=$(WORKSPACE) $(HEALTH) --nginx
.PHONY: status
status: ## Show current prod ports and running containers
@echo "PostgREST port: $$(cat .postgrest-port 2>/dev/null || echo none)"
@echo "Next.js port: $$(cat .nextjs-port 2>/dev/null || echo none)"
@cd deploy && docker compose -p prod-app ps
.PHONY: logs
logs: ## Tail deploy.log
tail -n 200 -f deploy.log
.PHONY: down
down: ## Stop the production stack (without redeploying)
cd deploy && docker compose -p prod-app down --remove-orphans
.PHONY: rollback
rollback: ## Restart the previous stack (the one whose ports are still on disk)
@if [[ ! -f .postgrest-port ]]; then echo "no .postgrest-port to roll back to"; exit 1; fi
cd deploy && \
POSTGREST_HOST_PORT=$$(cat ../.postgrest-port) \
NEXTJS_HOST_PORT=$$(cat ../.nextjs-port) \
docker compose -p prod-app --env-file ../.env.production up -d
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env bash
# =============================================================================
# deploy.sh — Idempotent PostgREST + Next.js production deploy
# =============================================================================
#
# Self-hosted single-server deploy. Triggered manually, by Gitea webhook, or
# by a Gitea Actions runner after a push to `main` (or `gitea-sync`).
#
# What it does, in order:
# 1. Acquires an exclusive flock (concurrent deploys die loudly).
# 2. CLEANUP: stops the dev stack on :3001 and the previous prod stack
# (port read from .postgrest-port / .nextjs-port).
# 3. PORT_SELECTION: picks the lowest free port in [3011..30200] for
# PostgREST, then the next free one for the Next.js frontend.
# 4. BUILD: runs `npm run build` with NEXT_PUBLIC_API_URL exported so it
# gets inlined into the client bundle.
# 5. DEPLOY: writes the chosen ports to .env.production, brings the
# compose stack up.
# 6. NGINX: renders the nginx config from a template (with the current
# ports), `nginx -t`s it, and reloads the host systemd nginx.
# 7. HEALTHCHECK: curls the new stack; if anything is down, rolls back.
# 8. IMAGE_PRUNE: optional, removes dangling images on success.
#
# Files written to the workspace root:
# .postgrest-port current PostgREST host port (atomic)
# .nextjs-port current Next.js host port (atomic)
# .env.production rendered env fed to docker compose
# .deploy.lock flock target
# deploy.log append-only log
# =============================================================================
set -Eeuo pipefail
IFS=$'\n\t'
# -----------------------------------------------------------------------------
# Configurable variables (override via environment before invoking)
# -----------------------------------------------------------------------------
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
COMPOSE_DIR="${COMPOSE_DIR:-${WORKSPACE}/deploy}"
COMPOSE_FILE="${COMPOSE_FILE:-${COMPOSE_DIR}/docker-compose.yml}"
NGINX_TEMPLATE="${NGINX_TEMPLATE:-${COMPOSE_DIR}/nginx.conf.template}"
NGINX_RENDERED="${NGINX_RENDERED:-/etc/nginx/sites-available/prod-app.conf}"
NGINX_LINK="${NGINX_LINK:-/etc/nginx/sites-enabled/prod-app.conf}"
NGINX_OWNER="${NGINX_OWNER:-www-data:www-data}"
PROJECT_NAME="${PROJECT_NAME:-prod-app}"
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
ENV_FILE="${ENV_FILE:-${WORKSPACE}/.env.production}"
LOCK_FILE="${LOCK_FILE:-${WORKSPACE}/.deploy.lock}"
LOG_FILE="${LOG_FILE:-${WORKSPACE}/deploy.log}"
DEV_PORT="${DEV_PORT:-3001}"
PORT_RANGE_START="${PORT_RANGE_START:-3011}"
PORT_RANGE_END="${PORT_RANGE_END:-30200}"
HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-60}" # seconds total
HEALTHCHECK_INTERVAL="${HEALTHCHECK_INTERVAL:-2}" # seconds between tries
# Image pruning (set PRUNE_IMAGES=0 to skip)
PRUNE_IMAGES="${PRUNE_IMAGES:-1}"
# Optional: pin the public URL the browser uses. If empty, we default to
# http://localhost:${POSTGREST_HOST_PORT}. For production with a real domain
# and nginx in front, set e.g. NEXT_PUBLIC_API_URL=https://app.example.com/api
NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-}"
# -----------------------------------------------------------------------------
# Logging — every line is timestamped, tee'd to stdout AND the log file.
# We replace the shell's fd 1/2 with a tee so any tool that prints (npm, docker,
# curl) lands in both places automatically.
# -----------------------------------------------------------------------------
mkdir -p "$(dirname "$LOG_FILE")"
exec > >(tee -a "$LOG_FILE") 2>&1
ts() { date '+%Y-%m-%d %H:%M:%S'; }
log() { printf '[%s] %s\n' "$(ts)" "$*"; }
hr() { printf '%s\n' '----------------------------------------------------------------'; }
section() { hr; log "== $* =="; hr; }
# Trap so we always release the lock and surface a useful message.
on_exit() {
local exit_code=$?
if (( exit_code != 0 )); then
log "DEPLOY FAILED with exit code ${exit_code}"
log "See ${LOG_FILE} for full output. Rollback hints:"
log " - Previous port was: ${PREVIOUS_POSTGREST_PORT:-<unknown>}"
log " - Current .postgrest-port value: $(read_port_file "$POSTGREST_PORT_FILE" || echo '<none>')"
log " - To restart the old stack manually:"
log " POSTGREST_HOST_PORT=${PREVIOUS_POSTGREST_PORT:-3011} \\"
log " NEXTJS_HOST_PORT=${PREVIOUS_NEXTJS_PORT:-3012} \\"
log " docker compose -p ${PROJECT_NAME} --env-file ${ENV_FILE} up -d"
else
log "DEPLOY OK — PostgREST on :${NEW_POSTGREST_PORT}, Next.js on :${NEW_NEXTJS_PORT}"
fi
# flock on fd 9 releases automatically when the script exits.
}
trap on_exit EXIT
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
read_port_file() {
# Echo the port in $1, or empty string if missing/garbage.
local f="$1"
[[ -f "$f" ]] || return 1
local v
v=$(tr -d '[:space:]' < "$f" 2>/dev/null || true)
[[ "$v" =~ ^[0-9]+$ ]] || return 1
printf '%s' "$v"
}
render_template() {
# Portable envsubst: replaces $VAR and ${VAR} references in stdin with
# values from the current environment. Only the variable names given as
# args are expanded (matches `envsubst` behavior). If real envsubst is
# available we use it for speed.
local vars="$1"
if command -v envsubst >/dev/null 2>&1; then
envsubst "$vars"
else
# Build a sed expression like: s/\${VAR}/$VAR/g; s/\bVAR\b/$VAR/g
local sed_expr=()
for v in $vars; do
v="${v#\$}"
v="${v#\{}"
v="${v%\}}"
sed_expr+=( -e "s|\${${v}}|${!v:-}|g" )
sed_expr+=( -e "s|\$${v}\b|${!v:-}|g" )
done
sed "${sed_expr[@]}"
fi
}
is_listening() {
# Returns 0 if port $1 has a TCP listener (v4 or v6) on this host.
local port="$1"
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${port}$"
}
next_free_port() {
# Walk PORT_RANGE_START..PORT_RANGE_END and return the first port nobody
# is listening on. Returns 1 if none are free.
local p
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
if ! is_listening "$p"; then
printf '%s' "$p"
return 0
fi
done
return 1
}
atomic_write() {
# Write stdin to $1 atomically: write to temp, fsync, rename. This is
# what lets us use .postgrest-port as a single source of truth — readers
# always see either the old value or the new value, never a half-written one.
local target="$1"
local tmp
tmp=$(mktemp "${target}.tmp.XXXXXX")
cat > "$tmp"
sync
mv -f "$tmp" "$target"
}
free_port() {
# Try several strategies to free a port:
# 1. docker compose down for our project (idempotent)
# 2. brute-force kill of any process bound to the port
local port="$1" label="$2"
if [[ -z "$port" ]]; then return 0; fi
log " ${label} port ${port}: stopping project '${PROJECT_NAME}' (if up)"
( cd "$COMPOSE_DIR" && docker compose -p "$PROJECT_NAME" down --remove-orphans --timeout 10 ) \
>/dev/null 2>&1 || true
if is_listening "$port"; then
log " ${label} port ${port}: still listening, attempting pkill"
# fuser prints PIDs holding the port; xargs kills them.
local pids
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
if [[ -n "$pids" ]]; then
# shellcheck disable=SC2086
kill $pids 2>/dev/null || true
sleep 1
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
[[ -n "$pids" ]] && kill -9 $pids 2>/dev/null || true
fi
fi
if is_listening "$port"; then
log " ${label} port ${port}: WARNING — still in use after cleanup"
return 1
fi
log " ${label} port ${port}: free"
return 0
}
healthcheck() {
# Hit $1 (URL) until it returns 2xx within HEALTHCHECK_TIMEOUT seconds.
local url="$1" label="$2" elapsed=0
log " ${label}: ${url}"
while (( elapsed < HEALTHCHECK_TIMEOUT )); do
if curl -fsS --max-time 5 -o /dev/null "$url"; then
log " ${label}: OK (after ${elapsed}s)"
return 0
fi
sleep "$HEALTHCHECK_INTERVAL"
elapsed=$(( elapsed + HEALTHCHECK_INTERVAL ))
done
log " ${label}: FAILED after ${HEALTHCHECK_TIMEOUT}s"
return 1
}
# -----------------------------------------------------------------------------
# Lock — refuse to run if another deploy is in flight.
# -----------------------------------------------------------------------------
section "LOCK"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "Another deploy holds ${LOCK_FILE}. Exiting."
exit 1
fi
log "Acquired exclusive lock on ${LOCK_FILE}"
# -----------------------------------------------------------------------------
# 0. Banner
# -----------------------------------------------------------------------------
section "DEPLOY START"
log "Workspace: ${WORKSPACE}"
log "Project: ${PROJECT_NAME}"
log "Compose: ${COMPOSE_FILE}"
log "Nginx tpl: ${NGINX_TEMPLATE}"
log "Port range: ${PORT_RANGE_START}..${PORT_RANGE_END}"
log "Caller: ${USER:-<unknown>}@$(hostname)"
# -----------------------------------------------------------------------------
# 1. CLEANUP — port 3001 (dev) and the previous prod ports.
# -----------------------------------------------------------------------------
section "CLEANUP"
free_port "$DEV_PORT" "dev"
PREVIOUS_POSTGREST_PORT=$(read_port_file "$POSTGREST_PORT_FILE" || true)
PREVIOUS_NEXTJS_PORT=$(read_port_file "$NEXTJS_PORT_FILE" || true)
log "Previous prod ports: PostgREST=${PREVIOUS_POSTGREST_PORT:-<none>} Next.js=${PREVIOUS_NEXTJS_PORT:-<none>}"
# Stale-port guard: if the file points to a port that is NOT in our standard
# range, or to a port that nothing is listening on anymore, we still tear
# down the project (cheap) but we don't try to free the port itself —
# someone else might be using it.
free_port "${PREVIOUS_POSTGREST_PORT:-}" "prev-postgrest"
free_port "${PREVIOUS_NEXTJS_PORT:-}" "prev-nextjs"
# -----------------------------------------------------------------------------
# 2. PORT_SELECTION — find the two lowest free ports.
# -----------------------------------------------------------------------------
section "PORT_SELECTION"
NEW_POSTGREST_PORT=$(next_free_port) || {
log "No free port in [${PORT_RANGE_START}..${PORT_RANGE_END}]. Bailing out."
exit 2
}
log "PostgREST: ${NEW_POSTGREST_PORT}"
# Re-check after allocation, since we want distinct ports for both services.
NEW_NEXTJS_PORT=""
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
if (( p == NEW_POSTGREST_PORT )); then continue; fi
if ! is_listening "$p"; then NEW_NEXTJS_PORT="$p"; break; fi
done
if [[ -z "$NEW_NEXTJS_PORT" ]]; then
log "No free port for Next.js after allocating ${NEW_POSTGREST_PORT}. Bailing out."
exit 2
fi
log "Next.js: ${NEW_NEXTJS_PORT}"
# -----------------------------------------------------------------------------
# 3. BUILD — Next.js, with NEXT_PUBLIC_API_URL inlined into the client bundle.
# -----------------------------------------------------------------------------
section "BUILD"
cd "$WORKSPACE"
# Default the public API URL the browser will see.
if [[ -z "$NEXT_PUBLIC_API_URL" ]]; then
NEXT_PUBLIC_API_URL="http://localhost:${NEW_POSTGREST_PORT}"
fi
log "NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}"
# Node-only check: don't try to build if there's no package.json.
if [[ -f package.json ]]; then
# Make sure the deps are present (idempotent — npm ci is a no-op when locked).
if [[ -f package-lock.json ]]; then
log "npm ci (locked install)"
npm ci --no-audit --no-fund
else
log "npm install (no lockfile present — consider committing package-lock.json)"
npm install --no-audit --no-fund
fi
log "npm run build"
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
npm run build
else
log "No package.json in ${WORKSPACE} — skipping build step."
fi
# -----------------------------------------------------------------------------
# 4. ENV FILE — render .env.production for the running containers.
# -----------------------------------------------------------------------------
section "ENV"
# Preserve any pre-existing secrets in .env.production. We only own the lines
# we write; everything else is left alone. (The simplest sane strategy.)
SECRETS_FILE=""
if [[ -f "$ENV_FILE" ]]; then
SECRETS_FILE=$(mktemp)
# Drop any lines we manage; keep the rest verbatim.
grep -v -E '^(POSTGREST_HOST_PORT|NEXTJS_HOST_PORT|NEXT_PUBLIC_API_URL)=' \
"$ENV_FILE" > "$SECRETS_FILE" || true
fi
{
printf '# Generated by deploy.sh on %s — safe to edit, lines below are managed\n' "$(ts)"
printf 'POSTGREST_HOST_PORT=%s\n' "$NEW_POSTGREST_PORT"
printf 'NEXTJS_HOST_PORT=%s\n' "$NEW_NEXTJS_PORT"
printf 'NEXT_PUBLIC_API_URL=%q\n' "$NEXT_PUBLIC_API_URL"
if [[ -n "$SECRETS_FILE" ]]; then
cat "$SECRETS_FILE"
rm -f "$SECRETS_FILE"
fi
} > "${ENV_FILE}.new"
mv -f "${ENV_FILE}.new" "$ENV_FILE"
chmod 600 "$ENV_FILE"
log "Wrote ${ENV_FILE}"
# -----------------------------------------------------------------------------
# 5. DEPLOY — bring the stack up.
# -----------------------------------------------------------------------------
section "DEPLOY"
cd "$COMPOSE_DIR"
log "docker compose -p ${PROJECT_NAME} up -d --build"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --build
# -----------------------------------------------------------------------------
# 6. NGINX — render config from template, test, reload.
# -----------------------------------------------------------------------------
section "NGINX"
if [[ -f "$NGINX_TEMPLATE" ]]; then
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
render_template '${POSTGREST_HOST_PORT} ${NEXTJS_HOST_PORT} ${NEXT_PUBLIC_API_URL}' \
< "$NGINX_TEMPLATE" > "$NGINX_RENDERED"
log "Rendered: ${NGINX_RENDERED}"
chown "$NGINX_OWNER" "$NGINX_RENDERED" 2>/dev/null || true
chmod 644 "$NGINX_RENDERED"
# Wire it into sites-enabled if not already linked.
if [[ ! -L "$NGINX_LINK" && ! -e "$NGINX_LINK" ]]; then
log "Enabling site: ${NGINX_LINK} -> ${NGINX_RENDERED}"
ln -s "$NGINX_RENDERED" "$NGINX_LINK"
fi
log "nginx -t"
nginx -t
log "systemctl reload nginx"
systemctl reload nginx
else
log "No nginx template at ${NGINX_TEMPLATE} — skipping reverse proxy step."
fi
# -----------------------------------------------------------------------------
# 7. HEALTHCHECK — direct + via nginx (when applicable).
# -----------------------------------------------------------------------------
section "HEALTHCHECK"
# Direct checks (bypass nginx, catch compose issues)
healthcheck "http://127.0.0.1:${NEW_POSTGREST_PORT}/" "postgrest-direct" || ROLLBACK=1
healthcheck "http://127.0.0.1:${NEW_NEXTJS_PORT}/" "nextjs-direct" || ROLLBACK=1
# nginx-fronted check (only meaningful if nginx template exists)
if [[ -f "$NGINX_TEMPLATE" && "${ROLLBACK:-0}" != "1" ]]; then
healthcheck "http://127.0.0.1/" "nginx-front" || ROLLBACK=1
fi
if [[ "${ROLLBACK:-0}" == "1" ]]; then
log "HEALTHCHECK FAILED — rolling back."
log "Tearing down the new stack on :${NEW_POSTGREST_PORT} / :${NEW_NEXTJS_PORT}"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" down --remove-orphans --timeout 10 || true
# If we had a previous port file, the old one is still on disk (we wrote
# the new one to .new and only mv'd on success... but we DID mv already,
# so re-write the old value).
if [[ -n "${PREVIOUS_POSTGREST_PORT:-}" ]]; then
printf '%s\n' "$PREVIOUS_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
else
rm -f "$POSTGREST_PORT_FILE"
fi
if [[ -n "${PREVIOUS_NEXTJS_PORT:-}" ]]; then
printf '%s\n' "$PREVIOUS_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
else
rm -f "$NEXTJS_PORT_FILE"
fi
exit 3
fi
# -----------------------------------------------------------------------------
# 8. PERSIST — commit the chosen ports as the new single source of truth.
# (Done AFTER healthcheck so a failed deploy doesn't clobber the old one.)
# -----------------------------------------------------------------------------
section "PERSIST"
printf '%s\n' "$NEW_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
printf '%s\n' "$NEW_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
log ".postgrest-port = ${NEW_POSTGREST_PORT}"
log ".nextjs-port = ${NEW_NEXTJS_PORT}"
# -----------------------------------------------------------------------------
# 9. IMAGE_PRUNE — optional housekeeping.
# -----------------------------------------------------------------------------
if [[ "$PRUNE_IMAGES" == "1" ]]; then
section "IMAGE_PRUNE"
docker image prune -f
fi
section "DONE"
exit 0
+70
View File
@@ -0,0 +1,70 @@
# =============================================================================
# docker-compose.yml — production stack consumed by deploy.sh
# =============================================================================
#
# The host-side ports (POSTGREST_HOST_PORT, NEXTJS_HOST_PORT) are written by
# deploy.sh into .env.production. We interpolate from there with ${VAR:-3011}
# so a manual `docker compose up` without the deploy script still works.
#
# Note on networking: the Next.js container calls PostgREST on
# `host.docker.internal:POSTGREST_HOST_PORT` so the inlined
# NEXT_PUBLIC_API_URL (a localhost URL, per the deploy contract) resolves
# correctly. On Linux you may need to add
# extra_hosts:
# - "host.docker.internal:host-gateway"
# which is included below for that reason.
# =============================================================================
name: prod-app # default project name; deploy.sh overrides with -p
services:
postgrest:
image: postgrest/postgrest:latest
container_name: prod-app-postgrest
restart: unless-stopped
# The host port is dynamic. The container always listens on 3000.
ports:
- "${POSTGREST_HOST_PORT:-3011}:3000"
environment:
PGRST_DB_URI: ${PGRST_DB_URI}
PGRST_DB_ANON_ROLE: ${PGRST_DB_ANON_ROLE:-anon}
PGRST_DB_SCHEMA: ${PGRST_DB_SCHEMA:-public}
PGRST_SERVER_PORT: 3000
# Optional: tighten CORS for your real domain
PGRST_DB_TXN_END: "commit-allow-overwrite"
# Healthcheck lets `docker compose ps` show healthy state.
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
interval: 10s
timeout: 3s
retries: 6
nextjs:
# Build context is the workspace root (one level up from this file).
build:
context: ..
dockerfile: deploy/Dockerfile.nextjs
container_name: prod-app-nextjs
restart: unless-stopped
ports:
- "${NEXTJS_HOST_PORT:-3012}:3000"
environment:
# Runtime vars — these can change without rebuilding. NEXT_PUBLIC_*
# is also exported here for completeness, but the BROWSER's view of
# NEXT_PUBLIC_API_URL is baked in at build time (see Dockerfile).
NODE_ENV: production
PORT: 3000
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
env_file:
- ../.env.production # server-side secrets read at runtime
extra_hosts:
# Lets the container reach the host on the dynamically allocated port.
- "host.docker.internal:host-gateway"
depends_on:
postgrest:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
interval: 10s
timeout: 3s
retries: 6
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# =============================================================================
# healthcheck.sh — standalone, callable from cron / monitoring
# =============================================================================
#
# Reads the current prod ports from .postgrest-port / .nextjs-port and curls
# each service. Exit code is the count of failed checks (0 = all healthy).
#
# Usage:
# ./healthcheck.sh
# ./healthcheck.sh --nginx # also check the fronted URL
# WORKSPACE=/srv/app ./healthcheck.sh
# =============================================================================
set -Eeuo pipefail
IFS=$'\n\t'
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
TIMEOUT="${HEALTHCHECK_TIMEOUT:-5}"
failures=0
check() {
local label="$1" url="$2"
if curl -fsS --max-time "$TIMEOUT" -o /dev/null "$url"; then
printf ' [ OK ] %-20s %s\n' "$label" "$url"
else
printf ' [FAIL] %-20s %s\n' "$label" "$url"
failures=$(( failures + 1 ))
fi
}
pgrest_port=$(tr -d '[:space:]' < "$POSTGREST_PORT_FILE" 2>/dev/null || echo "")
next_port=$(tr -d '[:space:]' < "$NEXTJS_PORT_FILE" 2>/dev/null || echo "")
if [[ -n "$pgrest_port" ]]; then
check "postgrest" "http://127.0.0.1:${pgrest_port}/"
else
printf ' [SKIP] postgrest (no .postgrest-port)\n'
fi
if [[ -n "$next_port" ]]; then
check "nextjs" "http://127.0.0.1:${next_port}/"
else
printf ' [SKIP] nextjs (no .nextjs-port)\n'
fi
if [[ "${1:-}" == "--nginx" ]]; then
check "nginx" "http://127.0.0.1/"
fi
exit "$failures"
+89
View File
@@ -0,0 +1,89 @@
# =============================================================================
# nginx.conf.template — rendered by deploy.sh on every deploy
# =============================================================================
#
# Variables substituted by `envsubst`:
# ${POSTGREST_HOST_PORT} dynamic host port of the PostgREST container
# ${NEXTJS_HOST_PORT} dynamic host port of the Next.js container
# ${NEXT_PUBLIC_API_URL} (informational only — used in comment header)
#
# Layout:
# /api/* -> http://127.0.0.1:${POSTGREST_HOST_PORT}
# /* -> http://127.0.0.1:${NEXTJS_HOST_PORT}
#
# Tested against nginx >= 1.18 (Debian 11 / Ubuntu 22.04). Adjust ssl_*
# lines if you don't have a cert yet — deploy.sh only tests/renders, the
# operator decides whether to terminate TLS here.
# =============================================================================
# --- upstream definitions ---------------------------------------------------
upstream postgrest_upstream {
server 127.0.0.1:${POSTGREST_HOST_PORT};
keepalive 16;
}
upstream nextjs_upstream {
server 127.0.0.1:${NEXTJS_HOST_PORT};
keepalive 16;
}
# --- HTTP -> HTTPS upgrade (optional; remove if you only run on LAN) --------
server {
listen 80;
listen [::]:80;
server_name _;
# ACME http-01 challenge needs to be served on port 80.
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
# Redirect everything else to HTTPS. Comment out for plain-HTTP dev.
location / {
return 301 https://$host$request_uri;
}
}
# --- main server block ------------------------------------------------------
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name _;
# --- TLS (uncomment + adjust after you obtain a cert) ------------------
# ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# --- sensible defaults ------------------------------------------------
client_max_body_size 25m;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Connection "";
# --- API: /api/* -> PostgREST ----------------------------------------
location /api/ {
proxy_pass http://postgrest_upstream;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
# PostgREST exposes its OpenAPI spec at the root of the API; expose it
# under a stable URL too.
location = /api {
proxy_pass http://postgrest_upstream;
}
# --- everything else -> Next.js --------------------------------------
location / {
proxy_pass http://nextjs_upstream;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
}
+117
View File
@@ -0,0 +1,117 @@
# Admin Functionality Checklist
Use this for manual regression passes after changes (dev mode + dev auth).
## Roles to Test
- platform_admin (full access, all brands)
- brand_admin (scoped to assigned brand)
- store_employee (limited: pickup, wholesale only)
Auth via existing dev flow (`/login` sets dev_session cookie) or `/admin/test-auth`, `/admin/debug-auth`.
## Core Apps
### Orders
- [ ] Dashboard "New Order" and "Create your first order" open usable create flow (`?new=true` modal or /new)
- [ ] Create order: customer name/email/phone, stop (or ship-only), add multiple items (qty/price/fulfillment mix), totals, submit → appears in list + detail page
- [ ] List: filters, search, bulk pickup mark, pagination, status tabs
- [ ] Detail: view items/totals/payment, edit form, pickup action, refunds
- [ ] Permissions: brand_admin sees only own brand; employee redirected
- [ ] Empty states and toasts work
### Products
- [ ] List (active only, brand scoped)
- [ ] New product form (/products/new) → creates and appears in list
- [ ] Edit product (/products/[id]): change name/price/type/active/image/pickup_type/taxable → saves, list + detail update (router.refresh or state)
- [ ] Delete (if present)
- [ ] Import
- [ ] Image upload + remove
### Stops / Tours
- [ ] List
- [ ] New stop (/stops/new)
- [ ] Edit stop
- [ ] Publish / status changes
- [ ] Used in order create picker
### Communications (Harvest Reach)
- [ ] Main page tabs: campaigns, contacts, segments, templates, compose, logs, analytics, settings, abandoned carts, welcome sequence
- [ ] Single coherent compose experience (no confusing duplication between /compose and main "compose" tab)
- [ ] Audience preview (stop/segment/custom rules) shows visible count + sample customers
- [ ] Create/edit/save draft campaign, preview, send (test lists only)
- [ ] Contacts CRUD + import + export + opt out
- [ ] Segments builder + preview
- [ ] Templates
- [ ] Abandoned cart + welcome automation flows (preview/send/resend)
- [ ] Feature gated when add-on disabled
### Settings
- **Billing**
- [ ] Consistent view: plan tier + price (annual/monthly toggle), "active subscription" state clear, usage numbers (products/stops/users) match dashboard, enabled add-ons list with correct remove capability, recent invoices match displayed amounts
- [ ] No contradictory "No active subscription" + active add-ons + invoices
- [ ] Upgrade, add-on checkout, portal buttons, Stripe connect if applicable
- **Integrations**
- [ ] Resend/Twilio/Stripe/etc. forms: non-secret fields (email, name, phone) visible as text; secrets masked + toggle
- [ ] Test connection buttons (safe)
- **AI / Advanced**
- [ ] Real content or proper sections (not pure redirects); links to providers, preferences, feature flags
- **Other**: brand, apps (feature toggles), payments, shipping, square-sync, users
### Wholesale
- [ ] Dashboard stats, customers list + create/edit, products (custom pricing), orders (fulfill, deposits, notifications)
- **Portal** (employee or approved buyer): register, login, browse with custom pricing, cart, checkout
### Water Log
- [ ] Admin: headgates, irrigators/users (create with pin reset), entries, settings, alerts
- [ ] Field/pin flows (separate water/ login)
- [ ] Permission: can_manage_water_log or platform for TUXEDO brand
### Time Tracking
- [ ] Admin: workers (create/pin reset), tasks, logs, summary, settings, overtime notifications
- [ ] Field clock in/out with pin
- [ ] Reports/export
### Route Trace
- [ ] Lots CRUD, QR/sticker gen, lookup, hauling board, supply chain trace, stats, inventory by crop
- [ ] Feature gated
### Import Center + AI Import
- [ ] Upload/parse for products/orders/contacts/stops
- [ ] AI column mapping (if enabled)
- [ ] Execution (safe on test data)
### Other Admin
- [ ] Users (platform): CRUD
- [ ] Reports / Analytics / Command Center: load data, exports
- [ ] Pickup (driver): lookup, QR, mark complete
- [ ] Shipping: orders, FedEx rates/labels, tracking
- [ ] Taxes: calc + reports
- [ ] Dashboard: quick actions, usage vs limits, recent orders, plan badge, addon cards
- [ ] Layout: no dupe headers/footers inside admin shell; sidebar correct per role
## Cross-Cutting
- [ ] All forms use improved AdminInput: clickable labels (htmlFor), real `required` + `aria-required`, error/help described
- [ ] No repeated "Invalid scope" or missing target console warnings in admin
- [ ] Brand scoping works (platform sees selector/all; brand_admin filtered)
- [ ] Feature add-on gating (Harvest Reach, Wholesale, AI, Water, Time, Route Trace, SMS)
- [ ] Toasts, loading skeletons, empty states, error messages consistent
- [ ] Responsive (desktop primary)
- [ ] Dev auth + test brands used; no live prod mutations in routine testing
## Public / Storefront (secondary but part of full review)
- [ ] Homepage: anchors (#features etc.) work, impact counters animate (not stuck at 0), no long blank regions, no GSAP target/scope errors
- [ ] Tuxedo (and IRD): product sections no visual overlap, Add to Cart buttons visible/sized and functional, stops show real upcoming or clear "none" state, cart blocks checkout when empty, full buyer path (browse → add → cart → checkout → success)
- [ ] Contact: Phone labeled/typed correctly (not email)
- [ ] Public pages: no stray "ADMIN" nav link when not logged in as admin
- [ ] Years: consistent (dynamic preferred)
- [ ] No dupe SiteHeader + StorefrontHeader
## Verification Tips
- Use `npm run dev`
- Dev session cookies or test-auth pages
- Browser devtools: Elements for a11y (labels, aria, required), Console for warnings, Network for action calls
- Multiple roles + brands
- After changes: `npx tsc --noEmit`, `npm run build` (or at least lint)
- Update this checklist and MEMORY.md with results
Run this checklist after any admin or storefront changes. Prioritize the Codex blockers first.
+126
View File
@@ -0,0 +1,126 @@
# Route Commerce — Pricing Assessment
> **TL;DR:** Yes, the pricing is way too low. Fully-loaded ARPU ceiling today is **$493/mo** — roughly **one-third** of what comparable B2B produce distribution platforms charge for the same module set. Recommended: **2.53× ARPU lift** via a tier re-price + add-on re-price, with no expected loss of in-flight deals.
---
## What the product actually is
A **multi-tenant B2B produce distribution platform**, not a SaaS starter. Build depth is substantial:
| Surface | Scope |
|---|---|
| Migrations | **135 SQL files** (production schema, RLS, SECURITY DEFINER RPCs, audit logs) |
| 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 |
| 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 |
| Integrations | Stripe (Connect + subscriptions), Square (POS sync), FedEx (rates + labels), Resend (email), Twilio (SMS), OpenAI (8 AI tools) |
| Distinct modules | Catalog · Stops/Routes · Orders · Pickup · Shipping (FedEx) · **B2B Wholesale Portal** (custom pricing, credit limits, net-30, deposits) · **Harvest Reach** (campaigns, segments, stop-blasts, templates, automation) · **AI Pack** (8 tools) · Square Sync · **Water Log** (irrigation/headgates) · **Route Trace** (FSMA compliance, lots, QR stickers) · Time Tracking (with overtime) · Tax · Multi-tenant brand isolation |
This is closer in scope to **Forager / Local Food Marketplace / Choco** than to a small-farm tool.
---
## Current pricing (audit)
Source: [`src/lib/pricing.ts`](../src/lib/pricing.ts)
```
Starter $49/mo (1 user, 25 products, 10 stops/mo)
Farm $149/mo (5 users, unlimited stops/products, wholesale + marketing)
Enterprise $399/mo (unlimited users/brands, AI, SMS, Square, Water, SLA)
Add-ons
wholesale_portal $99/mo
harvest_reach $79/mo
ai_tools $59/mo ← 8 AI endpoints for $59
water_log $39/mo
square_sync $39/mo
sms_campaigns $29/mo
```
**Fully-loaded customer max today: $149 + $99 + $79 + $59 + $39 + $39 + $29 = $493/mo**
---
## Market comparables (B2B produce / food distribution)
| Comparable | Pricing |
|---|---|
| **Local Line** (CSA/farm e-com) | $50$300/mo |
| **Barn2Door** (farmer e-com + marketing) | $299/mo entry |
| **Local Food Marketplace** (regional food hubs) | $200$1,000+/mo |
| **GrazeCart** (farm e-com) | $60$150/mo |
| **Choco** (B2B food ordering) | Enterprise $1,000+/mo |
| **Forager** (B2B produce marketplace) | Enterprise contracts, **$25K+/yr** |
| **Cut+Dry** (distributor sales rep) | $1,500+/mo per rep |
| **Klaviyo** (email/SMS) alone | $60$1,000+/mo |
| **Shopify Plus** (checkout tier) | $2,300+/mo |
A 5-user farm on the current Farm plan gets Wholesale Portal + Harvest Reach + Unlimited catalog for $149. **Klaviyo alone costs more than the entire Farm plan**, and Klaviyo is one module.
---
## Bugs to fix first
These exist in the current code and should be resolved before re-pricing launches:
1. **Two conflicting pricing configs.**
- [`src/lib/stripe-billing.ts`](../src/lib/stripe-billing.ts) shows Farm annual as `$1,522.80` (`152280` cents) but [`src/lib/pricing.ts`](../src/lib/pricing.ts) shows `$1,341`.
- Enterprise annual in `stripe-billing.ts` is `0` (Custom) but `pricing.ts` shows `$3,591`.
- **Fix:** pick one source of truth and delete the other. The marketing UI reads `pricing.ts`; the Stripe price-ID logic in `billing.ts` reads the env vars — they must agree.
2. **Add-on catalog mismatches feature display.**
- [`src/lib/feature-flags.ts:59`](../src/lib/feature-flags.ts) lists `wholesale_portal: "Contact us"` and `ai_tools: "OpenAI API required"`, but `pricing.ts` prices them at $99/$59.
- **Fix:** pick one narrative — if the feature has a price, show the price; if it's bundled, say so explicitly.
3. **Landing-page stats are aspirational, not real.**
- [`src/components/landing/FeaturesAndStats.tsx:165-170`](../src/components/landing/FeaturesAndStats.tsx) shows "500+ produce brands, 50K+ orders, $2M+ weekly sales."
- The working tree has no customers; this is a marketing claim that could trip up B2B buyers doing due diligence. Either back it with real numbers or remove it.
---
## Recommended new pricing
A 2.53× lift that still lands below the closest comparables (Local Food Marketplace, Choco) and well below enterprise produce platforms (Forager, Cut+Dry):
### Plan tiers
| Tier | Current | Recommended | Rationale |
|---|---|---|---|
| **Starter** | $49/mo | **$99/mo** | 1 user, 25 products — still accessible for CSAs/market vendors; reflects catalog + pickup + 10 stops/mo as a real operational system, not a toy. |
| **Farm** | $149/mo | **$349/mo** | Includes wholesale portal + Harvest Reach. This is your **target tier** — undercut Local Food Marketplace ($200$1K) and Barn2Door ($299) by enough to feel like a deal, but capture real B2B value. |
| **Enterprise** | $399/mo | **$899$1,499/mo** | All add-ons included, unlimited users/brands, SLA, custom dev. An enterprise B2B produce platform with AI + traceability + multi-brand is worth $1,500+; $899 keeps it competitive vs Cut+Dry. |
### Add-ons (re-priced for value)
| Add-on | Current | Recommended | Why |
|---|---|---|---|
| **Wholesale Portal** | $99 | **$199** | 30+ migrations of work, custom pricing + credit limits + net-30 + deposits. This is the second core revenue driver. |
| **Harvest Reach** | $79 | **$149** | Email + SMS + segments + templates + automation + stop-blast + abandoned-cart. Klaviyo equivalent is $60$1K+. |
| **AI Intelligence Pack** | $59 | **$199** | 8 distinct AI endpoints. AI tools as standalone SaaS bill $200$500/mo. Pricing it at $59 is gifting margin. |
| **Water Log** | $39 | **$99** | Specialty ag irrigation tracking is $100$300/mo in the market. |
| **Square Sync** | $39 | **$59** | Bidirectional POS sync — a real integration; $39 understates the engineering. |
| **SMS Campaigns** | $29 | **$59** | Twilio metered + opt-in management + deliverability; $29 signals "we don't trust this to scale." |
| **Route Trace** | $49 | **$99** | FSMA compliance, lot tracking, QR stickers — has direct regulatory value. |
**Fully-loaded max under new pricing: $349 + $199 + $149 + $199 + $99 + $59 + $59 = $1,113/mo per brand** — still well below what comparable platforms charge for the same stack.
---
## Other pricing moves worth considering
- **Usage-based component on Harvest Reach / SMS** (per-message or per-recipient above a soft cap) — Twilio cost is real, and this lets you capture upside from high-volume brands.
- **Per-extra-user on Farm** instead of the 5-user cap. $349/mo for 5 users is generous; an overage of $39/user/mo captures growth.
- **Annual "founder" pricing** for the first 2030 customers to land logos, with a hard cutoff date in copy.
- **Replace "Enterprise" with true custom pricing** for $1,500+ deals. The $399 Enterprise is anchored below what buyers will pay for "unlimited everything + custom dev + SLA." Mark it "Custom" and let sales run it.
- **Pause/pause-feature toggles for seasonal farms.** Many produce brands have off-seasons. A "pause subscription for 3 months, keep data" feature reduces churn that an annual contract would otherwise cause.
---
## Bottom line
Yes — pricing is way too low. The fully-loaded ARPU ceiling of **$493/mo** is roughly **one-third** of what comparable B2B produce platforms charge for the same module set, and the AI tier at $59/mo for 8 endpoints is essentially a free add-on relative to its cost-to-serve and market value.
The safe move: move Starter to $99, Farm to $349, Enterprise to "Custom (starts at $899)", and roughly 2× the add-ons. That single change can **2.53× ARPU** without losing a single deal that was ever going to close at the old prices — anyone paying $149 today was getting under-billed.
@@ -0,0 +1,470 @@
# Multi-Brand Admin Support
**Date:** 2026-06-04
**Status:** Draft → Approved
**Author:** Grok (brainstorming session)
**Migration file:** `supabase/migrations/204_multi_brand_admin.sql`
**Follow-up migration (out of scope):** `220_drop_legacy_brand_id.sql`
## Problem
`admin_users.brand_id` is a single `UUID | null`. The platform supports a `platform_admin` role (no brand) and a `brand_admin` role (one brand). There is no representation for an admin who legitimately needs access to 2+ specific brands — e.g., a parent company operating multiple storefronts with shared operations staff.
The current `effectiveBrandId = brandId ?? adminUser.brand_id ?? null` pattern silently does the wrong thing for multi-brand use cases:
- Data model can't represent "Jane is admin for Brand A AND Brand B" — would require two `admin_users` rows (and the auth-user→admin-user join gets messy).
- No central validation that an admin is acting in a brand they actually have access to.
- No persistent "current brand" context — every page re-derives it from scratch.
- No per-brand permission overrides possible (locks in flexibility for the future).
This spec addresses multi-brand tenants / franchises: same staff managing multiple brands under a parent org, where brands are separate for storefront/billing but share operations.
## Goals
- An admin can be associated with multiple brands via a junction table.
- A persistent "active brand" is stored in a cookie, switchable via UI, and used as the default when no explicit brand is requested.
- A new `multi_brand_admin` role makes the relationship explicit in the data and the UI.
- `platform_admin` continues to work unchanged (gets all brands implicitly).
- Existing single-brand `brand_admin`, `store_employee`, and `staff` users continue to work with zero behavior change.
- Server actions get a single, central place to resolve and validate the active brand.
## Non-Goals (YAGNI)
- Per-(admin, brand) permission overrides. The user explicitly chose "same perms across all brands."
- Brand-group / parent-org concept. The junction table makes this possible later, but it's not built now.
- "Last accessed brand" auto-redirect. The cookie is the source of truth; no extra logic.
- UI for managing `admin_user_brands` rows. Use Supabase Studio or a follow-up admin UI PR.
- Dropping the legacy `admin_users.brand_id` column. A follow-up migration `220_drop_legacy_brand_id.sql` will do this after we verify nothing reads it. Out of scope for this spec.
## Approach: Junction Table + Backwards-Compat `brand_id`
Selected from among three options:
| Option | Why not |
|---|---|
| A. Junction + keep `brand_id` (selected) | — |
| B. Junction only, drop `brand_id` | High migration risk; every server action reference must change. |
| C. `brand_ids UUID[]` on `admin_users` | No FK, awkward reverse lookups, locks out per-brand metadata. |
A is the lowest-risk additive path that solves the problem.
## Data Model
### New table: `admin_user_brands`
```sql
CREATE TABLE admin_user_brands (
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
added_by UUID REFERENCES admin_users(id),
PRIMARY KEY (admin_user_id, brand_id)
);
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
```
- Composite PK enforces uniqueness.
- Index on `brand_id` makes "which admins are in Brand X?" queries fast.
- `added_at` / `added_by` for audit trail.
- `ON DELETE CASCADE` for both FKs — deleting an admin or brand cleans up the junction.
### New role: `multi_brand_admin`
Added to the `role` CHECK constraint. Functionally equivalent to `brand_admin` permission-wise — same `can_manage_*` flags apply. The role label disambiguates intent in the UI ("this person manages 3 brands" vs "this person manages 1 brand") and in audit logs.
### Updated `AdminUser` type
```ts
// src/lib/admin-permissions-types.ts
export type AdminUser = {
// ... existing fields
brand_id: string | null; // active brand (one of brand_ids, or null for platform_admin)
brand_ids: string[]; // all brands this admin can act in
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
};
```
### Membership rules
| Role | `brand_id` (active) | `brand_ids` (membership) |
|---|---|---|
| `platform_admin` | `null` (or cookie-selected brand) | Implicitly all brands; `brand_ids` populated by `listBrandsForAdmin` querying the `brands` table |
| `multi_brand_admin` | First/selected brand | 2+ brands from `admin_user_brands` |
| `brand_admin` | Their single brand | `[that one brand]` |
| `store_employee` | Their single brand | `[that one brand]` |
| `staff` | Their single brand | `[that one brand]` |
For `platform_admin`, the application layer short-circuits brand-access checks (`if (adminUser.role === "platform_admin") return ...`). The `brand_ids` field is only used by `listBrandsForAdmin` to render the dropdown options; it is not used for permission gating for `platform_admin`.
### `getAdminUser()` resolution order
1. If `dev_session` cookie set → return dev admin. For `platform_admin` dev: `brand_id: null, brand_ids: []` (resolved against `brands` table by `listBrandsForAdmin`). For `store_employee` dev: `brand_id: <first-real-brand-id>, brand_ids: [<that-id>]` — fetched from the `brands` table so dev store_employee can browse a real brand's data. (If no brands exist, dev store_employee sees `<AdminAccessDenied />` — known limitation.)
2. If `NEXT_PUBLIC_USE_MOCK_DATA=true` → same as `platform_admin` dev.
3. Real auth → load `admin_users` row, then JOIN `admin_user_brands` to populate `brand_ids`.
4. Set `brand_id` from (in order): `active_brand_id` cookie (if in `brand_ids` for non-platform-admin, or always for platform-admin) → `admin_users.brand_id` (if in `brand_ids`) → first of `brand_ids`.
## Server Action Patterns
### New file: `src/lib/brand-scope.ts`
```ts
import { cookies } from "next/headers";
import type { AdminUser } from "./admin-permissions-types";
const ACTIVE_BRAND_COOKIE = "active_brand_id";
export async function getActiveBrandId(
adminUser: AdminUser,
requested?: string | null
): Promise<string | null> {
// Cookie is the source of truth for "what brand am I acting in right now"
// for everyone — including platform_admin (who can pin a specific brand
// or fall back to null = "all brands").
const cookieStore = await cookies();
const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null;
if (adminUser.role === "platform_admin") {
// requested > cookie > null (all brands)
return requested ?? cookieBrand ?? null;
}
// Non-platform-admin: requested (if in brand_ids) > cookie (if in brand_ids) > adminUser.brand_id
if (requested) {
return adminUser.brand_ids.includes(requested) ? requested : null;
}
if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) {
return cookieBrand;
}
return adminUser.brand_id;
}
export async function setActiveBrandCookie(brandId: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(ACTIVE_BRAND_COOKIE, brandId, {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30, // 30 days
});
}
export async function clearActiveBrandCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(ACTIVE_BRAND_COOKIE);
}
export function assertBrandAccess(adminUser: AdminUser, brandId: string): void {
if (adminUser.role === "platform_admin") return;
if (!adminUser.brand_ids.includes(brandId)) {
throw new Error("Brand access denied");
}
}
```
### Server action: set active brand
```ts
// src/actions/admin/set-active-brand.ts
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { setActiveBrandCookie, clearActiveBrandCookie } from "@/lib/brand-scope";
export async function setActiveBrand(brandId: string | null): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// null = "All brands" (platform_admin only)
if (brandId === null) {
if (adminUser.role !== "platform_admin") {
return { success: false, error: "Only platform admins can select 'All brands'" };
}
await clearActiveBrandCookie();
return { success: true };
}
if (adminUser.role !== "platform_admin" && !adminUser.brand_ids.includes(brandId)) {
return { success: false, error: "No access to that brand" };
}
await setActiveBrandCookie(brandId);
return { success: true };
}
```
### New server function: list brands for admin
```ts
// src/actions/brands.ts
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
export async function listBrandsForAdmin(): Promise<
{ id: string; name: string; slug: string; logo_url: string | null }[]
> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
if (adminUser.role === "platform_admin") {
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
{ headers: svcHeaders(serviceKey) }
);
return res.ok ? await res.json() : [];
}
if (adminUser.brand_ids.length === 0) return [];
const filter = `id=in.(${adminUser.brand_ids.map((id) => `"${id}"`).join(",")})`;
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
{ headers: svcHeaders(serviceKey) }
);
return res.ok ? await res.json() : [];
}
```
### Server action migration pattern
```ts
// Before:
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
// After:
import { getActiveBrandId } from "@/lib/brand-scope";
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
```
This is a mechanical one-line swap in ~30 server actions identified by the grep:
- `src/actions/wholesale.ts` (multiple)
- `src/actions/products.ts`
- `src/actions/communications/templates.ts`
- `src/actions/communications/campaigns.ts`
- `src/actions/orders/create-admin-order.ts`
- `src/actions/stops.ts`
- `src/actions/analytics.ts`
- `src/actions/square-sync-ui.ts`
- `src/actions/shipping.ts`
- `src/actions/payments.ts`
- `src/actions/ai-import.ts`
- `src/actions/wholesale-register.ts`
### Page server component pattern
```ts
// Before (src/app/admin/orders/page.tsx):
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
// After:
import { getActiveBrandId } from "@/lib/brand-scope";
export default async function AdminOrdersPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied />;
}
// ... rest of the page
}
```
This applies to every page under `src/app/admin/` — including the existing `/admin/taxes/[brandId]` and `/admin/settings/billing/[brandId]` which already do brand param resolution.
## UI Components
### Brand selector
**Location:** `src/components/admin/AdminHeader.tsx` (the top bar already rendered on every `/admin/*` page).
**Behavior:** A dropdown showing:
- Brand logo + name (current active brand)
- Chevron
- "All brands" option at the top (only for `platform_admin`)
- List of accessible brands (`adminUser.brand_ids`)
- Small badge "Multi-brand manager" next to the user's name when `role === "multi_brand_admin"`
**Visibility matrix:**
| Admin | Show dropdown? | Options |
|---|---|---|
| `platform_admin` | Yes | "All brands" + list of all brands |
| `multi_brand_admin` (2+ brands) | Yes | List of their brands |
| `brand_admin` / `store_employee` / `staff` (1 brand) | No | — |
| `platform_admin` (dev_session) | Yes | "All brands" + list of all brands (same UX as production) |
**On select:**
```ts
// src/components/admin/BrandSelector.tsx (client component)
"use client";
async function handleSelect(brandId: string | null) {
await setActiveBrand(brandId); // null = "All brands"
router.refresh();
}
```
`router.refresh()` re-runs server components and re-reads the cookie, so all data on the current page reloads in the new brand context. The URL is **not** changed — the cookie is the source of truth for "what brand am I acting in right now."
### URL-level brand params
Keep URL-level brand as-is — URLs are shareable links. The resolution order for the `brandId` param passed to `getActiveBrandId` is:
1. URL `brandId` param (if present)
2. `active_brand_id` cookie
3. `adminUser.brand_id` (legacy single brand)
4. First of `adminUser.brand_ids`
5. (platform_admin only) `null` → "all brands"
## Migration: `supabase/migrations/204_multi_brand_admin.sql`
```sql
-- 1. Add multi_brand_admin to role CHECK constraint
ALTER TABLE admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check;
ALTER TABLE admin_users ADD CONSTRAINT admin_users_role_check
CHECK (role IN ('platform_admin', 'brand_admin', 'multi_brand_admin', 'store_employee', 'staff'));
-- 2. Create junction table
CREATE TABLE admin_user_brands (
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
added_by UUID REFERENCES admin_users(id),
PRIMARY KEY (admin_user_id, brand_id)
);
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
-- 3. Backfill from existing brand_id (single-brand admins)
INSERT INTO admin_user_brands (admin_user_id, brand_id)
SELECT id, brand_id FROM admin_users
WHERE brand_id IS NOT NULL
ON CONFLICT DO NOTHING;
-- 4. Promote anyone with > 1 brand to multi_brand_admin
UPDATE admin_users
SET role = 'multi_brand_admin'
WHERE role = 'brand_admin'
AND id IN (
SELECT admin_user_id FROM admin_user_brands
GROUP BY admin_user_id HAVING COUNT(*) > 1
);
-- 5. New RPCs for adding/removing brand access
CREATE OR REPLACE FUNCTION add_admin_user_brand(
p_admin_user_id UUID, p_brand_id UUID, p_added_by UUID
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
INSERT INTO admin_user_brands (admin_user_id, brand_id, added_by)
VALUES (p_admin_user_id, p_brand_id, p_added_by)
ON CONFLICT DO NOTHING;
UPDATE admin_users SET role = 'multi_brand_admin'
WHERE id = p_admin_user_id
AND role = 'brand_admin'
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) > 1;
$$;
CREATE OR REPLACE FUNCTION remove_admin_user_brand(
p_admin_user_id UUID, p_brand_id UUID
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
DELETE FROM admin_user_brands
WHERE admin_user_id = p_admin_user_id AND brand_id = p_brand_id;
UPDATE admin_users SET role = 'brand_admin'
WHERE id = p_admin_user_id
AND role = 'multi_brand_admin'
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) = 1;
$$;
```
The `admin_users.brand_id` column is **kept** for backwards compat. A follow-up migration drops it after we verify nothing reads it.
## Error Handling & Security Boundaries
### Three failure modes
1. **Requested brand not in `brand_ids`** (e.g., URL param has a brand the admin doesn't have):
- `getActiveBrandId()` returns `null`
- Server action returns `{ success: false, error: "Brand access required" }`
- Page renders `<AdminAccessDenied />` with: "You don't have access to that brand. [Switch to a brand you have access to]"
2. **Cookie brand no longer in `brand_ids`** (admin's access was revoked while cookie was still set):
- `getActiveBrandId()` falls through to `adminUser.brand_id`, then first of `brand_ids`
- Silent recovery — no error, no UI flash
- The dropped brand just disappears from the dropdown next page load
3. **Platform admin acting on a brand they don't own:**
- Platform admin: `brand_ids = ["*"]` (sentinel) — RPCs treat as "all brands"
- Server action: never blocks platform admin from any brand
- This is intentional — platform admin = superuser
### Validation placement (defense in depth)
- Server action: `getActiveBrandId(adminUser, requested)` validates.
- Server action: `assertBrandAccess(adminUser, brandIdFromUrl)` validates (separate, for cases where the brandId comes from URL/form/RPC return rather than `getActiveBrandId`).
- RPC: still trusts `p_brand_id` (SECURITY DEFINER) — application layer is the gate, matching the existing architecture.
### Audit logging (additive)
- `admin_user_brands.added_by` column tracks who added an admin to a brand.
- Audit log entry on add/remove: out of scope for v1; documented for follow-up.
## Testing
### Unit tests (`vitest` — new dev dependency)
- `src/lib/brand-scope.test.ts`:
- `resolveActiveBrandId(platformAdmin, "X")``"X"`
- `resolveActiveBrandId(brandAdmin, "X")` where X is their brand → `"X"`
- `resolveActiveBrandId(brandAdmin, "Y")` where Y is not their brand → `null`
- `getActiveBrandId(multiBrandAdmin)` with cookie set to valid brand → that brand
- `getActiveBrandId(multiBrandAdmin)` with cookie set to revoked brand → first of `brand_ids`
- `assertBrandAccess(...)` throws for non-platform-admin with invalid brand
- `setActiveBrand(null)` rejected for non-platform-admin
- `setActiveBrand("X")` rejected for admin without X in `brand_ids`
### Integration tests (Playwright — already in repo)
- `tests/admin/multi-brand.spec.ts`:
- As `multi_brand_admin`, dropdown shows 2+ brands
- Click brand B → URL stays, cookie updates, page data refreshes to brand B
- Direct-navigate to `/admin/orders?brand=<other-brand>` for a brand admin returns access denied
- As `platform_admin`, "All brands" option is present and works
- As `brand_admin` with 1 brand, no dropdown is shown
### Migration smoke test (manual, documented in MEMORY.md)
- Before migration: 5 brand_admins exist, each with 1 brand
- After migration: 5 rows in `admin_user_brands`, all `role = 'brand_admin'`
- Create a 6th admin with 2 brands via `add_admin_user_brand``role = 'multi_brand_admin'`, 2 rows in junction
- Remove one of their brands via `remove_admin_user_brand``role` demotes to `brand_admin`, 1 row in junction
## Out of Scope (v1)
- Per-(admin, brand) permission overrides
- Brand-group / parent-org concept
- "Last accessed brand" auto-redirect
- UI for managing `admin_user_brands` rows (Supabase Studio works for now)
- Dropping the legacy `admin_users.brand_id` column (follow-up `220_*` migration)
- Audit log entries for add/remove (junction's `added_by` column is the seed)
## Implementation Order
1. Migration `204_multi_brand_admin.sql` applied
2. `src/lib/brand-scope.ts` + unit tests (TDD)
3. `AdminUser` type updated; `getAdminUser()` returns `brand_ids`
4. `setActiveBrand` server action + tests
5. `listBrandsForAdmin` server function + tests
6. BrandSelector UI component
7. Wire BrandSelector into AdminHeader
8. Server action migration (~30 actions) — mechanical one-line swap each
9. Page server component migration (~10+ pages) — same pattern
10. Playwright integration tests
11. Manual smoke test of the migration on dev DB
+14
View File
@@ -0,0 +1,14 @@
import openpyxl
path = "/home/coder/dev/x1/kyle/route_commerce-main/Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
wb = openpyxl.load_workbook(path, data_only=True)
for name in wb.sheetnames:
ws = wb[name]
print(f"=== SHEET: {name} ({ws.max_row} rows x {ws.max_column} cols) ===")
for row in ws.iter_rows(values_only=False):
for cell in row:
if cell.value is not None:
v = str(cell.value)
if len(v) > 200:
v = v[:200] + "..."
print(f" {cell.coordinate}: {v!r}")
print()
-64
View File
@@ -1,64 +0,0 @@
import { NextResponse, type NextRequest } from "next/server";
const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000";
export async function middleware(request: NextRequest) {
const response = NextResponse.next({ request });
// ── Dev session bypass (enabled in all envs for demo) ──────────────
// Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
const devSession = request.cookies.get("dev_session")?.value;
const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee";
const rcAuthUid = request.cookies.get("rc_auth_uid")?.value;
let authUid: string | null = null;
if (isDevMode) {
// Dev session only valid in development
authUid = DEV_UID;
} else if (rcAuthUid) {
// rc_auth_uid is set by /api/login — treat as authenticated
authUid = rcAuthUid;
}
// No rc_auth_uid in production → authUid stays null → redirect to /login
const isAdmin = request.nextUrl.pathname.startsWith("/admin");
const isLogin = request.nextUrl.pathname === "/login";
if (isAdmin && !authUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
// Auto-login for demo: no Supabase configured, no auth cookie present
if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
const url = request.nextUrl.clone();
url.pathname = "/admin";
url.searchParams.set("demo", "1");
const response = NextResponse.redirect(url);
response.cookies.set("dev_session", "platform_admin", {
path: "/",
maxAge: 60 * 60 * 24,
httpOnly: true,
sameSite: "strict",
});
return response;
}
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
if (isLogin && authUid) {
const url = request.nextUrl.clone();
url.pathname = "/admin";
return NextResponse.redirect(url);
}
return response;
}
export const config = {
matcher: [
"/admin/:path*",
"/admin",
"/login",
],
};
+7 -2
View File
@@ -3,7 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0", "dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
@@ -15,10 +15,13 @@
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.96.0", "@anthropic-ai/sdk": "^0.96.0",
"@auth/pg-adapter": "^1.11.2",
"@clerk/nextjs": "^7.4.2", "@clerk/nextjs": "^7.4.2",
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"@gsap/react": "^2.1.2", "@gsap/react": "^2.1.2",
"@sentry/nextjs": "^10.55.0", "@sentry/nextjs": "^10.55.0",
"@stripe/react-stripe-js": "^3.10.0",
"@stripe/stripe-js": "^5.10.0",
"@supabase/ssr": "^0.10.2", "@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.105.3", "@supabase/supabase-js": "^2.105.3",
"@upstash/ratelimit": "^2.0.8", "@upstash/ratelimit": "^2.0.8",
@@ -28,6 +31,7 @@
"gsap": "^3.15.0", "gsap": "^3.15.0",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "^16.2.6", "next": "^16.2.6",
"next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openai": "^6.37.0", "openai": "^6.37.0",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
@@ -48,6 +52,7 @@
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -58,7 +63,7 @@
"pg": "^8.20.0", "pg": "^8.20.0",
"playwright": "^1.59.1", "playwright": "^1.59.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5.9.3"
}, },
"overrides": "{}" "overrides": "{}"
} }
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* apply-admin-create-stop.js
*
* Applies the admin_create_stop RPC using ONLY the keys in .env.local.
* You do NOT need Supabase dashboard / SQL editor access.
*
* This is the fix for the "could not find the function public.admin_create_stop(...)"
* error when you click "Add New Stop".
*
* Usage (run from the repo root on a machine that can reach the DB):
* node scripts/apply-admin-create-stop.js
*
* The script connects as the real postgres user (using your SUPABASE_SERVICE_ROLE_KEY
* as the password) and runs the CREATE OR REPLACE + GRANT + NOTIFY.
* This is the only reliable way to create the SECURITY DEFINER function because
* direct REST inserts are blocked by the `block_stops_mutations` policy.
*
* After it prints success:
* - Restart your `npm run dev`
* - "Add New Stop" (AddStopModal / NewStopForm) will now call the RPC successfully.
*/
const { Client } = require("pg");
const fs = require("fs");
const path = require("path");
// Load .env.local (same as the rest of the project)
try {
require("dotenv").config({ path: path.resolve(__dirname, "../.env.local") });
} catch (e) {
// dotenv might not be hoisted in some setups; the keys may already be in process.env
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) {
console.error("❌ Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local");
console.error(" Make sure you are running from the repo root and .env.local exists with the keys.");
process.exit(1);
}
// Derive the direct Postgres URL (same logic as supabase/push-migrations.js)
const projectRef = supabaseUrl.replace("https://", "").split(".")[0];
const pgHost = `db.${projectRef}.supabase.co`;
const dbUrl = `postgresql://postgres:${encodeURIComponent(serviceKey)}@${pgHost}:5432/postgres`;
console.log("🔧 apply-admin-create-stop RPC installer");
console.log(" Project:", projectRef);
console.log(" Using service role key from .env.local (full Postgres access)");
console.log("");
async function main() {
const migrationPath = path.resolve(__dirname, "../supabase/migrations/202_fix_admin_create_stop.sql");
if (!fs.existsSync(migrationPath)) {
console.error("❌ Cannot find migration file:", migrationPath);
console.error(" The file should have been created by the previous fix.");
process.exit(1);
}
const sql = fs.readFileSync(migrationPath, "utf8");
const client = new Client({
connectionString: dbUrl,
ssl: { rejectUnauthorized: false },
// Some environments need a longer timeout
connectionTimeoutMillis: 15000,
});
try {
console.log("⏳ Connecting to Postgres (this uses port 5432 outbound)...");
await client.connect();
console.log("✅ Connected.");
console.log("⏳ Executing 202_fix_admin_create_stop.sql (CREATE OR REPLACE + GRANT + NOTIFY)...");
const result = await client.query(sql);
console.log("✅ Function installed / updated successfully.");
console.log(" Rows affected in last statement:", result.rowCount ?? "(see output above if any)");
console.log("");
console.log("🎉 admin_create_stop is now available via the anon key (SECURITY DEFINER).");
// Quick verification using the anon key over HTTPS (this is what the app uses)
try {
const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const probeBody = {
p_active: false,
p_address: null,
p_brand_id: "00000000-0000-0000-0000-000000000000", // will FK fail, but proves the function exists
p_city: "verify",
p_cutoff_time: null,
p_date: "2099-12-31",
p_location: "verify",
p_state: "TS",
p_time: "10:00",
p_zip: null,
};
const probe = await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/admin_create_stop`, {
method: "POST",
headers: { apikey: anon, "Content-Type": "application/json" },
body: JSON.stringify(probeBody),
});
const txt = await probe.text();
if (probe.status === 404) {
console.log("⚠️ Post-apply probe still got 404 — you may need to wait a few seconds or the NOTIFY didn't take.");
} else if (/foreign key|violates|brand_id/i.test(txt)) {
console.log("✅ Post-apply probe: function exists (got expected FK error on the zero-UUID brand — this is success).");
} else {
console.log("✅ Post-apply probe returned:", txt.slice(0, 200));
}
} catch (e) {
console.log(" (verification fetch skipped:", e.message, ")");
}
console.log("");
console.log("Next:");
console.log(" • Restart your dev server (`npm run dev`) if it was already running.");
console.log(" • Go to the stops admin page and try \"Add New Stop\".");
console.log(" • (Optional but recommended) node scripts/verify-stop-fns.js");
} catch (err) {
const msg = (err && err.message) ? err.message : String(err);
console.error("❌ Failed to apply the function.");
console.error(" Error:", msg.slice(0, 500));
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|network is unreachable|ECONNREFUSED|timeout/i.test(msg)) {
console.log("");
console.log("💡 Your current environment cannot reach the database on port 5432.");
console.log(" This is common inside some containers/CI sandboxes.");
console.log("");
console.log(" Copy and run ONE of the following on a *normal* laptop/terminal that has");
console.log(" outbound access to Supabase Postgres (most developer machines can reach it):");
console.log("");
console.log(" Option A (easiest, uses exactly the same code as this script):");
console.log(" npm run migrate:one 202");
console.log("");
console.log(" Option B (psql):");
console.log(" # Build the URL using the key from your .env.local (same as this script does):");
console.log(" # psql \"postgresql://postgres:PUT_YOUR_SERVICE_ROLE_KEY_HERE@db." + projectRef + ".supabase.co:5432/postgres\" \\");
console.log(" # -f supabase/migrations/202_fix_admin_create_stop.sql");
console.log("");
console.log(" Option C (Supabase CLI):");
console.log(" supabase db push --db-url \"postgresql://postgres:YOUR_SERVICE_ROLE_KEY@db." + projectRef + ".supabase.co:5432/postgres\" \\");
console.log(" supabase/migrations/202_fix_admin_create_stop.sql");
console.log("");
console.log(" After the command succeeds you will see the NOTIFY and the function will exist.");
console.log(" Then restart `npm run dev` and \"Add New Stop\" will stop showing the function-not-found error.");
}
process.exit(1);
} finally {
try { await client.end(); } catch {}
}
}
main().catch((e) => {
console.error("Unexpected crash:", e);
process.exit(1);
});
+49
View File
@@ -0,0 +1,49 @@
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const headers = {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
"Content-Type": "application/json",
};
async function rpc(name, body = {}) {
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
method: "POST", headers, body: JSON.stringify(body),
});
return { status: r.status, text: (await r.text()).slice(0, 500) };
}
(async () => {
// 1. Try calling admin_create_stop with service role
console.log("=== 1. Call admin_create_stop (service role) ===");
console.log(await rpc("admin_create_stop", { p_brand_id: null }));
// 2. Try with body
console.log("\n=== 2. Call admin_create_stop with stub body ===");
console.log(await rpc("admin_create_stop", {
p_brand_id: "00000000-0000-0000-0000-000000000000",
p_city: "test", p_state: "CO", p_location: "x", p_date: "2025-01-01", p_time: "08:00"
}));
// 3. Try with a name that obviously doesn't exist to see the error format
console.log("\n=== 3. Call a non-existent function (control) ===");
console.log(await rpc("definitely_does_not_exist_xyz", {}));
// 4. Use the PostgREST introspection — query information_schema via rpc
// PostgREST exposes pg_proc through /rest/v1/ for system tables is not allowed
// But we can use the OpenAPI spec endpoint
console.log("\n=== 4. OpenAPI spec — looking for admin_create_stop ===");
const openApi = await fetch(`${url}/rest/v1/`, { headers: { apikey: anonKey, Authorization: `Bearer ${anonKey}` } });
const text = await openApi.text();
const matches = text.match(/admin_create_stop[a-z_]*/g) || [];
console.log("Matches in OpenAPI:", matches);
// 5. Get all function names from OpenAPI
const funcMatches = [...text.matchAll(/"([a-z][a-z0-9_]*)\s*\{/g)].map(m => m[1]);
const stopFns = funcMatches.filter(n => n.includes("stop"));
console.log("\nStop-related functions in OpenAPI:", stopFns);
})();
+52
View File
@@ -0,0 +1,52 @@
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const { Client } = require("pg");
const dns = require("dns");
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const projectRef = url.replace("https://", "").split(".")[0];
const candidates = [
`db.${projectRef}.supabase.co`,
`aws-0-us-east-1.pooler.supabase.com`,
`aws-0-us-west-1.pooler.supabase.com`,
];
(async () => {
for (const host of candidates) {
try {
const ip = await new Promise((resolve, reject) => {
dns.resolve4(host, (err, addrs) => err ? reject(err) : resolve(addrs));
});
console.log(`${host} -> ${ip.join(", ")}`);
} catch (e) {
console.log(`${host} -> DNS FAIL: ${e.code}`);
}
}
// Try direct DB with port 6543 (pooler) using supabase format
// Username pattern: postgres.<project-ref>
const client = new Client({
host: "aws-0-us-east-1.pooler.supabase.com",
port: 6543, database: "postgres",
user: `postgres.${projectRef}`,
password: process.env.SUPABASE_SERVICE_ROLE_KEY,
ssl: { rejectUnauthorized: false },
});
try {
await client.connect();
const r = await client.query(`
SELECT p.proname,
pg_get_function_identity_arguments(p.oid) AS id_args,
p.prosecdef AS secdef
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
WHERE n.nspname='public' AND p.proname IN
('admin_create_stop','admin_create_stops_batch','delete_stop')
ORDER BY p.proname;
`);
console.log("\nFUNCTIONS IN DB:");
console.log(JSON.stringify(r.rows, null, 2));
} catch (e) {
console.error("POOLER ERR:", e.message);
} finally {
try { await client.end(); } catch {}
}
})();
+44
View File
@@ -0,0 +1,44 @@
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const { Client } = require("pg");
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const projectRef = url.replace("https://", "").split(".")[0];
const pw = process.env.SUPABASE_SERVICE_ROLE_KEY;
const tries = [
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: `postgres.${projectRef}`, database: "postgres" },
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 5432, user: `postgres.${projectRef}`, database: "postgres" },
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: "postgres", database: "postgres" },
];
(async () => {
for (const cfg of tries) {
const client = new Client({ ...cfg, password: pw, ssl: { rejectUnauthorized: false } });
try {
await client.connect();
const r = await client.query(`
SELECT p.proname,
pg_get_function_identity_arguments(p.oid) AS id_args,
p.prosecdef AS secdef
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
WHERE n.nspname='public' AND p.proname IN
('admin_create_stop','admin_create_stops_batch','delete_stop')
ORDER BY p.proname;
`);
console.log(`\n${cfg.host}:${cfg.port} user=${cfg.user}`);
console.log(JSON.stringify(r.rows, null, 2));
// Also check if migration tracking table exists
const trk = await client.query(`
SELECT version, name FROM supabase_migrations.schema_migrations
WHERE version >= 145 ORDER BY version;
`).catch(() => ({ rows: [] }));
console.log("Recent migrations applied:", trk.rows);
await client.end();
return;
} catch (e) {
console.log(`${cfg.host}:${cfg.port} user=${cfg.user}${e.message}`);
try { await client.end(); } catch {}
}
}
})();
+99
View File
@@ -0,0 +1,99 @@
#!/bin/bash
# End-to-end validation test for the local Postgres + PostgREST + MinIO + Next.js stack
# Exit 0 = all green, exit 1 = at least one failure.
set -e
API="http://localhost:3001"
WEB="http://localhost:4000"
pass=0
fail=0
declare -a FAILURES
check() {
local name="$1" url="$2" expected="${3:-200}" cookies="${4:-}"
local cmd="curl -s -o /dev/null -w '%{http_code}'"
if [ -n "$cookies" ]; then cmd="$cmd -b \"$cookies\""; fi
local code=$(eval "$cmd $url")
if [ "$code" = "$expected" ]; then
echo " PASS $name ($code)"
pass=$((pass+1))
else
echo " FAIL $name expected=$expected got=$code url=$url"
fail=$((fail+1))
FAILURES+=("$name")
fi
}
echo "=== Postgres connection ==="
PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT 'ok';" >/dev/null
echo " PASS postgres responds"
pass=$((pass+1))
echo "=== DB integrity ==="
TABLE_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';")
[ "$TABLE_COUNT" -ge 65 ] && echo " PASS $TABLE_COUNT public tables (>=65)" && pass=$((pass+1)) || { echo " FAIL $TABLE_COUNT tables"; fail=$((fail+1)); }
FN_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname='public';")
[ "$FN_COUNT" -ge 250 ] && echo " PASS $FN_COUNT functions (>=250)" && pass=$((pass+1)) || { echo " FAIL $FN_COUNT functions"; fail=$((fail+1)); }
BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands;")
[ "$BRANDS" -ge 2 ] && echo " PASS $BRANDS brands" && pass=$((pass+1)) || { echo " FAIL $BRANDS brands"; fail=$((fail+1)); }
STOPS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM stops WHERE active=true AND deleted_at IS NULL;")
[ "$STOPS" -ge 200 ] && echo " PASS $STOPS active stops" && pass=$((pass+1)) || { echo " FAIL $STOPS stops"; fail=$((fail+1)); }
# No test brands
TEST_BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands WHERE slug IN ('sunrise-farms','green-valley','orchard-fresh');")
[ "$TEST_BRANDS" = "0" ] && echo " PASS no test brands" && pass=$((pass+1)) || { echo " FAIL test brands found"; fail=$((fail+1)); }
echo "=== Tuxedo Corn data ==="
PHONE=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT phone FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de';")
[ "$PHONE" = "970-323-6874" ] && echo " PASS phone=$PHONE" && pass=$((pass+1)) || { echo " FAIL phone=$PHONE (expected 970-323-6874)"; fail=$((fail+1)); }
LOGOS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de' AND logo_url LIKE '/storage/%';")
[ "$LOGOS" = "1" ] && echo " PASS logo_url is /storage/ path" && pass=$((pass+1)) || { echo " FAIL logo_url not /storage/"; fail=$((fail+1)); }
echo "=== PostgREST ==="
check "GET /" "$API/"
check "GET /brands" "$API/brands?select=id,name,slug&order=name"
check "GET /brands?slug=eq.tuxedo" "$API/brands?slug=eq.tuxedo&select=*"
check "GET /stops" "$API/stops?select=id,city&limit=5"
check "GET /products" "$API/products?select=id,name&limit=5"
check "POST rpc get_brand_settings_by_slug" "$API/rpc/get_brand_settings_by_slug" 200 \
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
check "POST rpc get_brand_plan_info" "$API/rpc/get_brand_plan_info" 200 \
"-X POST -H Content-Type:application/json -d {\"p_brand_id\":\"64294306-5f42-463d-a5e8-2ad6c81a96de\"}"
check "POST rpc get_public_stops_for_brand" "$API/rpc/get_public_stops_for_brand" 200 \
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
check "GET /admin_users with brand join" "$API/admin_users?select=id,user_id,role,brand_id,brands(name)&limit=5"
check "GET /stops with brand join" "$API/stops?select=id,city,brand_id,brands(name)&limit=5"
echo "=== MinIO ==="
check "Storage logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
check "Storage olathe-sweet-logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
check "Storage olathe-sweet-logo-dark.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
echo "=== Public storefronts ==="
check "GET /" "$WEB/"
check "GET /tuxedo" "$WEB/tuxedo"
check "GET /tuxedo/about" "$WEB/tuxedo/about"
check "GET /tuxedo/stops" "$WEB/tuxedo/stops"
check "GET /indian-river-direct" "$WEB/indian-river-direct"
check "GET /login" "$WEB/login"
echo "=== Admin pages (dev_session=platform_admin) ==="
for p in /admin /admin/products /admin/stops /admin/orders /admin/users /admin/settings /admin/settings/billing /admin/settings/apps /admin/settings/payments /admin/communications /admin/communications/compose /admin/time-tracking /admin/wholesale /admin/water-log /admin/analytics /admin/reports; do
check "GET $p" "$WEB$p" 200 "dev_session=platform_admin"
done
echo ""
echo "=== Summary ==="
echo " PASS: $pass"
echo " FAIL: $fail"
if [ $fail -gt 0 ]; then
echo " Failures:"
for f in "${FAILURES[@]}"; do echo " - $f"; done
exit 1
fi
echo " ALL GREEN"
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env python3
"""
seed_tuxedo_tour.py
Parses Tuxedo_Corn_2026_Tour_Schedule-3.xlsx and seeds the `stops` table for
the Tuxedo brand via the admin_create_stops_batch RPC.
Skips:
- Title / subtitle / legend rows (rows 1-3)
- Week header rows (col A = "Wk N", col D-J empty)
- Cross-Dock / Monday OFF rows (col D contains "OFF" or "Cross-Dock")
Joins with the Stop Directory sheet to enrich each stop with:
- address, phone, contact
Usage:
python3 scripts/seed_tuxedo_tour.py --dry-run # show what would be inserted
python3 scripts/seed_tuxedo_tour.py # actually insert
Requires:
- supabase CLI linked to project wnzkhezyhnfzhkhiflrp
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from openpyxl import load_workbook
TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"
YEAR = 2026
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",
}
DEFAULT_XLSX = (
"/home/coder/dev/x1/kyle/route_commerce-main/"
"Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
)
def parse_excel_date(s):
"""'Jul 22' -> '2026-07-22'"""
if not s:
return None
m = re.match(r"^([A-Za-z]{3})\s+(\d{1,2})$", str(s).strip())
if not m:
return None
mm = MONTH_MAP.get(m.group(1))
if not mm:
return None
return f"{YEAR}-{mm}-{int(m.group(2)):02d}"
def parse_time_range(s):
"""'10:00 AM - 1:00 PM' -> '10:00 AM' (start time)"""
if not s:
return ""
cleaned = re.sub(r"[–—]", "-", str(s)).strip()
cleaned = re.sub(r"\s+", " ", cleaned)
m = re.match(r"^(\d{1,2}:\d{2}\s*[AP]M)", cleaned, re.IGNORECASE)
return m.group(1).upper().replace(" ", " ") if m else cleaned
def split_city_state(s):
"""'Cheyenne, WY' -> ('Cheyenne', 'WY')"""
if not s:
return "", ""
parts = [p.strip() for p in str(s).split(",")]
if len(parts) == 1:
return parts[0], ""
return parts[0], parts[1]
def slugify(s):
s = (s or "").lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
def is_week_header(row):
a = str(row[0] or "").strip()
d = str(row[3] or "").strip()
return re.match(r"^Wk\s", a) and d == ""
def is_off_row(row):
d = str(row[3] or "").strip()
return "OFF" in d or "Cross-Dock" in d or "CrossDock" in d
def is_data_row(row):
d = str(row[3] or "").strip()
e = str(row[4] or "").strip()
if not d or not e:
return False
if "," not in e:
return False
return True
def load(xlsx_path):
wb = load_workbook(xlsx_path, data_only=True)
schedule = wb["Full Schedule"]
directory = wb["Stop Directory"]
# Build Stop Directory lookup: (truck, host_normalized) -> {address, phone, contact, ...}
dir_map = {}
for row in directory.iter_rows(min_row=2, values_only=True):
truck = str(row[0] or "").strip()
city = str(row[1] or "").strip()
state = str(row[2] or "").strip()
host = str(row[3] or "").strip()
address = str(row[4] or "").strip()
phone = str(row[5] or "").strip()
contact = str(row[6] or "").strip()
if not truck or not host:
continue
key = f"{truck}|{host.lower()}"
dir_map[key] = {
"city": city, "state": state, "host": host,
"address": address, "phone": phone, "contact": contact,
}
# Read Full Schedule (skip first 3 title/subtitle/legend rows)
stops = []
skipped = {"weekHeader": 0, "off": 0, "invalid": 0}
for row in schedule.iter_rows(min_row=4, values_only=True):
# Trim to 10 cols
cells = [("" if v is None else str(v).strip()) for v in row[:10]]
if is_week_header(cells):
skipped["weekHeader"] += 1
continue
if is_off_row(cells):
skipped["off"] += 1
continue
if not is_data_row(cells):
skipped["invalid"] += 1
continue
wk, region, date_text, day, city_state, host, time, truck, status, notes = cells
date_iso = parse_excel_date(date_text)
if not date_iso:
skipped["invalid"] += 1
continue
city, state = split_city_state(city_state)
if not city:
skipped["invalid"] += 1
continue
# Enrich from directory
dir_key = f"{truck}|{host.lower()}"
d = dir_map.get(dir_key)
stops.append({
"week": wk,
"region": region,
"date": date_iso,
"day": day,
"city": city,
"state": state or (d["state"] if d else ""),
"location": host,
"time": parse_time_range(time),
"time_range": time,
"truck": truck,
"status_text": status,
"notes": notes,
"address": d["address"] if d and d["address"] else None,
"phone": d["phone"] if d and d["phone"] else None,
"contact": d["contact"] if d and d["contact"] else None,
})
return stops, skipped, len(dir_map)
def assign_slugs(stops, dry_run):
used = set()
if not dry_run:
out = subprocess.run(
["supabase", "db", "query", "--linked",
f"SELECT slug FROM stops WHERE brand_id = '{TUXEDO_BRAND_ID}';"],
capture_output=True, text=True, timeout=120,
)
# Parse the table output - slugs are in second column between │
for m in re.finditer(r"\s*([a-z0-9][a-z0-9-]*)\s*│", out.stdout):
used.add(m.group(1))
for s in stops:
base = f"{slugify(s['city'])}-{s['date']}"
slug = base
n = 0
while slug in used:
n += 1
slug = f"{base}-{n}"
used.add(slug)
s["slug"] = slug
def to_rpc_row(s):
return {
"city": s["city"],
"state": s["state"],
"location": s["location"],
"date": f"{s['date']} 00:00:00+00",
"time": s["time"],
"address": s["address"],
"zip": None,
"cutoff_time": None,
# active=true so the stops appear on the public storefront immediately.
# Matches the behavior of publishStop in src/actions/stops.ts.
"active": True,
}
def build_payload_json(batch):
"""Build a clean JSON string for use in a SQL file."""
return json.dumps(batch, ensure_ascii=False)
def insert_batch(batch):
"""Write SQL to a temp file and execute via --file to avoid shell escaping."""
payload_json = build_payload_json(batch)
sql = (
f"SELECT admin_create_stops_batch("
f"'{TUXEDO_BRAND_ID}'::uuid, "
f"$${payload_json}$$::jsonb);\n"
)
# Write to temp file
tmp_path = Path("/tmp/seed_tuxedo_tour.sql")
tmp_path.write_text(sql, encoding="utf-8")
try:
proc = subprocess.run(
["supabase", "db", "query", "--linked", "--file", str(tmp_path)],
capture_output=True, text=True, timeout=300,
)
finally:
tmp_path.unlink(missing_ok=True)
if proc.returncode != 0:
raise RuntimeError(f"RPC failed: {proc.stderr[:800]}")
return proc.stdout
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--xlsx", default=DEFAULT_XLSX)
args = ap.parse_args()
if not Path(args.xlsx).exists():
sys.exit(f"XLSX not found: {args.xlsx}")
stops, skipped, dir_count = load(args.xlsx)
assign_slugs(stops, dry_run=args.dry_run)
print(f"\nParsed {len(stops)} stops "
f"(skipped: {skipped['weekHeader']} week-headers, "
f"{skipped['off']} OFF days, {skipped['invalid']} invalid)")
print(f"Stop Directory: {dir_count} entries loaded for enrichment\n")
if not stops:
sys.exit("No stops to insert.")
print("Sample (first 3):")
for s in stops[:3]:
print(f" {s['date']} {s['time']:10s} | {s['city']:18s}, {s['state']:2s} | "
f"{s['location'][:35]:35s} | {s['truck']} | {s['status_text']} | {s['slug']}")
if s["notes"]:
print(f" notes: {s['notes'][:120]}")
if s["address"]:
print(f" addr: {s['address']} ph: {s['phone']} ctc: {s['contact']}")
print()
# Show counts by week and region
by_week = {}
by_region = {}
by_truck = {}
for s in stops:
by_week[s["week"]] = by_week.get(s["week"], 0) + 1
by_region[s["region"]] = by_region.get(s["region"], 0) + 1
by_truck[s["truck"]] = by_truck.get(s["truck"], 0) + 1
print("By week:", dict(sorted(by_week.items())))
print("By region:", by_region)
print("By truck:", by_truck)
print()
# Date range
dates = sorted(s["date"] for s in stops)
print(f"Date range: {dates[0]} to {dates[-1]}\n")
if args.dry_run:
batches = (len(stops) + 49) // 50
print(f"[DRY RUN] Would insert {len(stops)} stops in {batches} batch(es) of 50.")
return
BATCH = 50
total = 0
batches = (len(stops) + BATCH - 1) // BATCH
for i in range(0, len(stops), BATCH):
batch = [to_rpc_row(s) for s in stops[i:i + BATCH]]
bnum = i // BATCH + 1
sys.stdout.write(f" Inserting batch {bnum}/{batches} ({len(batch)} stops)... ")
sys.stdout.flush()
try:
insert_batch(batch)
total += len(batch)
print("OK")
except Exception as e:
print("FAIL")
print(f" {e}")
# The batch RPC hardcodes status='draft' on insert. The Tuxedo storefront
# page only filters on active=true (not status), so active=true is enough
# to make stops visible. But for consistency with the publishStop server
# action — which sets both — flip status to 'active' for the rows we just
# inserted. Slug-based so we only touch stops from this run, not the
# pre-existing "Olathe" test stop.
if total > 0:
slugs = [s["slug"] for s in stops]
# Build a safe IN list (slug is a text column)
slug_list = ", ".join(f"'{slug.replace(chr(39), chr(39)+chr(39))}'" for slug in slugs)
publish_sql = (
f"UPDATE stops SET status = 'active' "
f"WHERE brand_id = '{TUXEDO_BRAND_ID}' "
f"AND slug IN ({slug_list});"
)
tmp = Path("/tmp/seed_tuxedo_publish.sql")
tmp.write_text(publish_sql, encoding="utf-8")
try:
subprocess.run(
["supabase", "db", "query", "--linked", "--file", str(tmp)],
capture_output=True, text=True, timeout=120,
)
print(f"\n Published {total} stops (status -> 'active').")
finally:
tmp.unlink(missing_ok=True)
print(f"\nDone. Inserted {total}/{len(stops)} stops for Tuxedo brand.")
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* verify-stop-fns.js
*
* Confirms `admin_create_stop` and `admin_create_stops_batch` exist in the
* live database with the exact signatures the client uses, and that the
* PostgREST schema cache has them registered.
*
* Run: node scripts/verify-stop-fns.js
*
* Replaces the older `check-stop-fns*.js` scripts (left in the repo for
* history; this one is the canonical post-fix verification).
*
* Exits 0 on success, 1 if any check fails.
*/
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !serviceKey || !anonKey) {
console.error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / NEXT_PUBLIC_SUPABASE_ANON_KEY in .env.local");
process.exit(1);
}
const SERVICE_HEADERS = {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
"Content-Type": "application/json",
};
const ANON_HEADERS = {
apikey: anonKey,
Authorization: `Bearer ${anonKey}`,
"Content-Type": "application/json",
};
let failed = 0;
const fail = (msg) => { console.error("✗ " + msg); failed += 1; };
const pass = (msg) => console.log("✓ " + msg);
async function rpc(name, body, headers = SERVICE_HEADERS) {
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
const text = await r.text();
let parsed;
try { parsed = JSON.parse(text); } catch { parsed = text.slice(0, 200); }
return { status: r.status, body: parsed };
}
(async () => {
console.log(`\nVerifying stop RPCs against ${url}\n`);
// ── 1. admin_create_stop — empty body (expect 400/422, NOT PGRST202) ─────
// PGRST202 = function not in schema cache. We want a 400-style validation
// error here, which proves PostgREST knows the function exists.
console.log("1. admin_create_stop signature check (PostgREST cache)");
const probe1 = await rpc("admin_create_stop", {});
if (probe1.status === 404) {
fail("admin_create_stop returned 404 — function not found (PGRST202 cache miss). " +
"Did you apply migration 147 and run `NOTIFY pgrst, 'reload schema'`?");
} else if (probe1.status >= 500) {
fail(`admin_create_stop returned ${probe1.status} — server error. Body: ${JSON.stringify(probe1.body)}`);
} else {
pass(`admin_create_stop reachable in PostgREST (status ${probe1.status})`);
}
// ── 2. admin_create_stop — happy path with a real (but harmless) brand ──
console.log("\n2. admin_create_stop happy-path call (anon key)");
const probe2 = await rpc(
"admin_create_stop",
{
p_brand_id: "00000000-0000-0000-0000-000000000000",
p_city: "verify-script-city",
p_state: "CO",
p_location: "verification stop",
p_date: "2099-12-31",
p_time: "08:00 AM 02:00 PM",
p_address: "123 Verify St",
p_zip: "80000",
p_cutoff_time: "08:00", // time-only — should be combined with date
p_active: false,
},
ANON_HEADERS
);
if (probe2.status === 404) {
fail("admin_create_stop not callable via anon key. " +
"GRANT EXECUTE missing? Body: " + JSON.stringify(probe2.body));
} else if (probe2.status >= 400) {
// Expected: brand_id is the zero UUID (foreign key violation). That's fine —
// it proves the function executed past type validation.
const msg = JSON.stringify(probe2.body);
if (/foreign key|violates/i.test(msg)) {
pass(`admin_create_stop ran end-to-end (got expected FK violation: ${msg.slice(0, 120)}...)`);
} else {
fail(`admin_create_stop failed unexpectedly (${probe2.status}): ${msg}`);
}
} else {
pass(`admin_create_stop returned success: ${JSON.stringify(probe2.body)}`);
}
// ── 3. admin_create_stops_batch — same checks ───────────────────────────
console.log("\n3. admin_create_stops_batch signature check");
const probe3 = await rpc("admin_create_stops_batch", { p_brand_id: null, p_stops: [] });
if (probe3.status === 404) {
fail("admin_create_stops_batch returned 404 — function not in schema cache.");
} else {
pass(`admin_create_stops_batch reachable (status ${probe3.status})`);
}
// ── 4. OpenAPI introspection — confirm functions are exposed ────────────
console.log("\n4. OpenAPI introspection");
const openApi = await fetch(`${url}/rest/v1/`, { headers: ANON_HEADERS });
const text = await openApi.text();
const hasSingle = /admin_create_stop\b/.test(text);
const hasBatch = /admin_create_stops_batch\b/.test(text);
if (hasSingle) pass("admin_create_stop appears in OpenAPI spec");
else fail("admin_create_stop NOT in OpenAPI spec — PostgREST hasn't seen it yet");
if (hasBatch) pass("admin_create_stops_batch appears in OpenAPI spec");
else fail("admin_create_stops_batch NOT in OpenAPI spec");
console.log(`\n${failed === 0 ? "✓ All checks passed" : `${failed} check(s) failed`}\n`);
process.exit(failed === 0 ? 0 : 1);
})().catch((e) => {
console.error("verify-stop-fns.js crashed:", e);
process.exit(1);
});
+18 -6
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser"; import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser";
import { importProductsBatch } from "@/actions/import-products"; import { importProductsBatch } from "@/actions/import-products";
import { importOrdersBatch } from "@/actions/import-orders"; import { importOrdersBatch } from "@/actions/import-orders";
@@ -41,7 +42,9 @@ export async function analyzeImport(
): Promise<AnalyzeImportResult> { ): Promise<AnalyzeImportResult> {
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.role === "brand_admin" && adminUser.brand_id !== brandId) { try {
assertBrandAccess(adminUser, brandId);
} catch {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
@@ -119,7 +122,14 @@ async function callAIAnalysis(
rows: string[][], rows: string[][],
brandId: string brandId: string
): Promise<ImportAnalysis> { ): Promise<ImportAnalysis> {
const apiKey = process.env.OPENAI_API_KEY; // Prefer MiniMax (env-level) — the team is using it pre-launch. 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";
// Build sample rows (first 30 for token economy) // Build sample rows (first 30 for token economy)
const sampleRows = rows.slice(0, 30); const sampleRows = rows.slice(0, 30);
@@ -174,21 +184,23 @@ Rules:
- Do NOT add __entity_type to cleanedRows — only in columnMappings output`; - Do NOT add __entity_type to cleanedRows — only in columnMappings output`;
try { try {
const res = await fetch("https://api.openai.com/v1/chat/completions", { const res = await fetch(`${baseURL}/chat/completions`, {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
model: "gpt-4o-mini", model,
messages: [ messages: [
{ role: "system", content: systemPrompt }, { role: "system", content: systemPrompt },
{ role: "user", content: `Headers: ${headers.join(", ")}\n\nData (first 30 rows):\n${sampleText.slice(0, 6000)}` }, { role: "user", content: `Headers: ${headers.join(", ")}\n\nData (first 30 rows):\n${sampleText.slice(0, 6000)}` },
], ],
response_format: { type: "json_object" }, // Note: response_format is OpenAI-specific. MiniMax /v1/chat/completions supports
// JSON-style prompting but may not honor `json_object`. We omit it for MiniMax.
...(provider === "openai" ? { response_format: { type: "json_object" } } : {}),
temperature: 0.1, temperature: 0.1,
}), }),
}); });
if (!res.ok) throw new Error(`OpenAI error: ${res.status}`); if (!res.ok) throw new Error(`${provider} error: ${res.status}`);
const data = await res.json(); const data = await res.json();
const parsed = JSON.parse(data.choices[0].message.content as string); const parsed = JSON.parse(data.choices[0].message.content as string);
+4 -3
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -96,7 +97,7 @@ async function brandScopedRPC<T>(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
const brandId = adminUser.brand_id ?? null; const brandId = await getActiveBrandId(adminUser);
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, { const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
method: "POST", method: "POST",
@@ -232,7 +233,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
const brandId = adminUser.brand_id ?? null; const brandId = await getActiveBrandId(adminUser);
const params = new URLSearchParams({ const params = new URLSearchParams({
select: "id,customer_name,subtotal,status,created_at,fulfillment", select: "id,customer_name,subtotal,status,created_at,fulfillment",
@@ -293,7 +294,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
const brandId = adminUser.brand_id ?? null; const brandId = await getActiveBrandId(adminUser);
const params = new URLSearchParams({ const params = new URLSearchParams({
select: "id,status", select: "id,status",
+56
View File
@@ -0,0 +1,56 @@
"use server";
import { signIn, signOut } from "@/lib/auth";
import { AuthError } from "next-auth";
/**
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
* use from client components.
*
* Why server actions?
* • The Auth.js v5 `signIn` function has to run on the server (it
* needs to set the session cookie, talk to the database adapter,
* and redirect the user to the OAuth provider).
* • Calling it from a client component via a server action keeps the
* client bundle small and avoids exposing the OAuth client secret.
*
* Usage from a client component:
* <form action={signInWithGoogle}>
* <button type="submit">Sign in with Google</button>
* </form>
*
* Usage for the dev credentials provider (dev only):
* <form action={signInWithDev}>
* <input name="username" />
* <input name="password" type="password" />
* <button type="submit">Dev login</button>
* </form>
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
export async function signInWithDev(formData: FormData): Promise<void> {
const username = String(formData.get("username") ?? "admin");
const password = String(formData.get("password") ?? "dev");
try {
await signIn("dev-login", {
username,
password,
redirectTo: "/admin",
});
} catch (e) {
// signIn() throws a `NEXT_REDIRECT` to navigate — let that through
// so the redirect actually happens. Re-throw any other error so the
// caller can render a meaningful message.
if (e instanceof AuthError) {
throw new Error(`Dev sign-in failed: ${e.type}`);
}
throw e;
}
}
export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
}
+236
View File
@@ -0,0 +1,236 @@
"use server";
/**
* Centralized Billing Overview
*
* Single source of truth for everything the billing page + dashboard usage
* bars need to render. Replaces ad-hoc calls to `getBrandPlanInfo`,
* `getEnabledAddons`, brand-row subscription columns, and hard-coded invoice
* data with one consistent, denormalized payload.
*
* Why a single action:
* - Before this, the billing page combined `getBrandPlanInfo` +
* `getEnabledAddons` + a brands-table read of subscription fields +
* hard-coded mock invoices. The dashboard's product count came from
* `getDashboardStats` which used a different filter than `get_brand_plan_info`
* (`active=true` vs `deleted_at IS NULL`), causing "Active Products 1" vs
* "Products 0/25" in the same UI.
* - Add-ons could be "Active" in `brand_features` while no Stripe
* subscription existed, with a Remove button that would fail at Stripe.
*
* What this returns:
* - planTier + planCycle (we default to "monthly" because the brand row
* doesn't store a cycle — the UI toggle controls display, but a real
* cycle would be detected from a `stripe_subscription.items.data[0].price
* .recurring.interval` when fetched)
* - hasActiveSubscription + subscriptionStatus (so the UI can hide
* misleading "Active" add-on badges / Remove buttons)
* - usage: { users, stops_this_month, products } — products counted
* consistently with the dashboard (`active=true AND deleted_at IS NULL`).
* - enabledAddons with a derived `removable` flag — only true when
* there is an active subscription AND the feature is enabled AND the
* current user can manage settings.
* - displayedInvoiceAmount — monthly or annual equivalent matching the
* billingCycle toggle, summed across plan + active add-ons. Used by the
* invoice table so amounts always match the displayed plan.
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
export type BillingSubscriptionStatus =
| "active"
| "trialing"
| "past_due"
| "canceled"
| "incomplete"
| "unpaid"
| "inactive"
| "none";
export type BillingOverview = {
brandId: string;
brandName: string | null;
planTier: PlanTierKey;
planCycle: "monthly" | "annual";
planMonthlyPrice: number;
planAnnualPrice: number;
hasStripeCustomer: boolean;
hasActiveSubscription: boolean;
subscriptionStatus: BillingSubscriptionStatus | null;
currentPeriodEnd: string | null;
limits: { max_users: number; max_stops_monthly: number; max_products: number };
usage: { users: number; stops_this_month: number; products: number };
enabledAddons: Record<string, boolean>;
// Denormalized add-on list (for direct UI rendering)
addons: Array<{
key: AddonKey;
label: string;
icon: string;
description: string;
enabled: boolean;
removable: boolean;
monthlyPrice: number;
annualPrice: number;
}>;
// Pre-computed totals so the billing page never disagrees with itself
displayedInvoiceAmount: number; // current cycle total
monthlyTotal: number; // always monthly equivalent
annualTotal: number; // always annual equivalent
isPlatformAdmin: boolean;
canManageBilling: boolean;
};
/**
* Fetch a single, consistent billing summary for a brand.
* Safe to call from a server component (uses service-role key).
*/
export async function getBillingOverview(
brandId: string,
options?: { planCycle?: "monthly" | "annual" }
): Promise<{ success: boolean; data?: BillingOverview; error?: string }> {
try {
if (!brandId) return { success: false, error: "brandId required" };
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
const planRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
const planData = planRes.ok ? await planRes.json() : null;
// 2) Brand row: subscription state, name, stripe_customer_id
const brandRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name,stripe_customer_id,stripe_subscription_id,stripe_subscription_status,stripe_current_period_end`,
{ headers: svcHeaders(supabaseKey) }
);
const brandRows = brandRes.ok ? await brandRes.json() : [];
const brand = Array.isArray(brandRows) ? brandRows[0] : null;
// 3) Enabled add-ons (feature flags)
const addonsRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
const enabledAddons = addonsRes.ok ? await addonsRes.json() : {};
// 4) Active product count — same semantics as the dashboard
// (active=true AND deleted_at IS NULL). Keeps the billing page in
// sync with /admin "Active Products" stat.
const productRes = await fetch(
`${supabaseUrl}/rest/v1/products?select=id&brand_id=eq.${brandId}&active=eq.true&deleted_at=is.null&limit=1000`,
{ headers: { ...svcHeaders(supabaseKey), Prefer: "count=exact" } }
);
const productCountHeader = productRes.headers.get("Content-Range");
let activeProductCount = 0;
if (productCountHeader && productCountHeader.includes("/")) {
const total = productCountHeader.split("/").pop();
activeProductCount = parseInt(total ?? "0", 10) || 0;
}
// 5) Admin context (so we know if the user can manage / is platform)
const adminUser = await getAdminUser();
const isPlatformAdmin = adminUser?.role === "platform_admin";
const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings;
// ── Normalize plan data ──────────────────────────────────────────────────
const planTier = (planData?.plan_tier ?? "starter") as PlanTierKey;
const limits = {
max_users: planData?.max_users ?? 1,
max_stops_monthly: planData?.max_stops_monthly ?? 10,
max_products: planData?.max_products ?? 25,
};
const planInfo = PLAN_TIERS[planTier] ?? PLAN_TIERS.starter;
const planMonthlyPrice = planInfo.monthlyPrice;
const planAnnualPrice = planInfo.annualPrice;
// ── Normalize subscription state ─────────────────────────────────────────
const rawStatus = (brand?.stripe_subscription_status ?? null) as BillingSubscriptionStatus | null;
const hasStripeCustomer = !!brand?.stripe_customer_id;
const hasActiveSubscription =
rawStatus === "active" || rawStatus === "trialing";
// ── Build add-on list with derived `removable` ──────────────────────────
const addons: BillingOverview["addons"] = (Object.keys(ADDONS) as AddonKey[]).map((key) => {
const cfg = ADDONS[key];
const enabled = !!enabledAddons?.[key];
return {
key,
label: cfg.label,
icon: cfg.icon,
description: cfg.description,
enabled,
removable: enabled && hasActiveSubscription && canManageBilling,
monthlyPrice: cfg.monthlyPrice,
annualPrice: cfg.annualPrice,
};
});
// ── Usage (products override from active=true+not-deleted count) ───────
const usage = {
users: planData?.usage?.users ?? 0,
stops_this_month: planData?.usage?.stops_this_month ?? 0,
// Prefer the active+non-deleted count for cross-UI consistency.
products: activeProductCount || (planData?.usage?.products ?? 0),
};
// ── Totals ───────────────────────────────────────────────────────────────
const addonsMonthlyTotal = addons
.filter((a) => a.enabled && hasActiveSubscription)
.reduce((s, a) => s + a.monthlyPrice, 0);
const addonsAnnualTotal = addons
.filter((a) => a.enabled && hasActiveSubscription)
.reduce((s, a) => s + a.annualPrice, 0);
const monthlyTotal = planMonthlyPrice + addonsMonthlyTotal;
const annualTotal = planAnnualPrice + addonsAnnualTotal;
const planCycle: "monthly" | "annual" =
options?.planCycle ?? "monthly"; // safer default — see header comment
const displayedInvoiceAmount =
planCycle === "annual"
? planAnnualPrice + addonsAnnualTotal
: planMonthlyPrice + addonsMonthlyTotal;
return {
success: true,
data: {
brandId,
brandName: brand?.name ?? planData?.brand_name ?? null,
planTier,
planCycle,
planMonthlyPrice,
planAnnualPrice,
hasStripeCustomer,
hasActiveSubscription,
subscriptionStatus: rawStatus,
currentPeriodEnd: brand?.stripe_current_period_end ?? null,
limits,
usage,
enabledAddons: (enabledAddons ?? {}) as Record<string, boolean>,
addons,
displayedInvoiceAmount,
monthlyTotal,
annualTotal,
isPlatformAdmin,
canManageBilling,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: `Failed to build billing overview: ${message}` };
}
}
@@ -0,0 +1,123 @@
"use server";
import { svcHeaders } from "@/lib/svc-headers";
/**
* Creates a Stripe PaymentIntent for the supplied cart so the browser
* can confirm the payment with embedded Stripe Elements (Apple Pay /
* Google Pay / card) without redirecting to a hosted page.
*
* Mirrors `createRetailStripeCheckoutSession` in `retail-checkout.ts`:
* the PaymentIntent is the embedded equivalent of the Checkout Session.
* Order creation itself still happens on `/checkout/success` so the
* webhooks and order pipeline don't need to know which path the buyer
* used.
*/
type LineItem = {
id: string;
name: string;
price: number;
quantity: number;
};
type CustomerInfo = {
name?: string;
email?: string;
};
type CreatePaymentIntentResult =
| { success: true; clientSecret: string; paymentIntentId: string; amount: number }
| { success: false; error: string };
export async function createRetailPaymentIntent(
items: LineItem[],
customer: CustomerInfo,
brandId: string | null,
stopId: string | null,
shippingAddress?: { state?: string; postal_code?: string; city?: string } | null
): Promise<CreatePaymentIntentResult> {
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) {
return { success: false, error: "Stripe not configured on this server." };
}
if (!Array.isArray(items) || items.length === 0) {
return { success: false, error: "Cart is empty." };
}
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
// Compute the subtotal in cents. We don't compute sales tax here —
// Stripe's `automatic_tax` would be ideal but requires address collection
// client-side; for this single-product corn box the price is tax-inclusive
// (see Tuxedo Corn product card) so we treat the line total as final.
const amount = items.reduce((sum, item) => {
const qty = Math.max(1, Math.floor(item.quantity || 1));
const unit = Math.max(0, Math.round(Number(item.price) * 100));
return sum + unit * qty;
}, 0);
if (amount <= 0) {
return { success: false, error: "Cart total must be greater than $0." };
}
// Pull the brand name for Stripe receipts + metadata
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
let brandName = "Route Commerce";
if (supabaseUrl && supabaseKey && brandId) {
try {
const brandRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
{ headers: { ...svcHeaders(supabaseKey) } }
);
const brands = (await brandRes.json()) as Array<{ name: string }>;
if (brands?.[0]?.name) brandName = brands[0].name;
} catch {
// ignore — use default
}
}
// Build a short human-readable description for the Stripe dashboard
const description = items
.slice(0, 3)
.map((i) => `${i.quantity}× ${i.name}`)
.join(", ");
try {
const intent = await stripe.paymentIntents.create({
amount,
currency: "usd",
automatic_payment_methods: { enabled: true },
receipt_email: customer.email || undefined,
description,
metadata: {
brand_id: brandId ?? "unknown",
brand_name: brandName,
stop_id: stopId ?? "",
customer_name: customer.name ?? "",
customer_email: customer.email ?? "",
shipping_state: shippingAddress?.state ?? "",
shipping_postal_code: shippingAddress?.postal_code ?? "",
shipping_city: shippingAddress?.city ?? "",
item_count: String(items.reduce((s, i) => s + i.quantity, 0)),
source: "storefront_express",
},
});
if (!intent.client_secret) {
return { success: false, error: "Stripe did not return a client secret." };
}
return {
success: true,
clientSecret: intent.client_secret,
paymentIntentId: intent.id,
amount: intent.amount,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create payment intent.";
return { success: false, error: message };
}
}
+29 -17
View File
@@ -271,25 +271,37 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
// Public version for storefront pages — uses slug, no auth required // Public version for storefront pages — uses slug, no auth required
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> { export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Return
// a not-configured result; the page falls back to slug-based defaults.
if (!supabaseUrl || !supabaseKey) {
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
}
const response = await fetch( // Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`, // doesn't crash the prerender — the page just falls back to its
{ // default brand name and revalidates from a real request later.
method: "POST", try {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, const response = await fetch(
body: JSON.stringify({ p_brand_slug: brandSlug }), `${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
} {
); method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
}
);
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined }; if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
const data = await response.json(); const data = await response.json();
return { return {
success: true, success: true,
settings: data, settings: data,
wholesaleEnabled: data?.wholesale_enabled, wholesaleEnabled: data?.wholesale_enabled,
}; };
} catch {
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
}
} }
export async function saveBrandSettings(params: { export async function saveBrandSettings(params: {
+65
View File
@@ -0,0 +1,65 @@
import "server-only";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
export type BrandListItem = {
id: string;
name: string;
slug: string;
logo_url: string | null;
};
/**
* Returns the list of brands the current admin user can act in.
*
* - platform_admin: all brands (queried directly from the `brands` table)
* - everyone else: brands in `adminUser.brand_ids`
* - empty array for unauthenticated / no-access admins
*
* This is a plain async function (not a server action) so it can be called
* from server components and server actions without the "use server" wrapper.
* The BrandSelector client component receives the result as a prop.
*/
export async function listBrandsForAdmin(): Promise<BrandListItem[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) return [];
if (adminUser.role === "platform_admin") {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
{ headers: svcHeaders(serviceKey), cache: "no-store" }
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
if (adminUser.brand_ids.length === 0) return [];
// Use PostgREST `in` filter. The brand_ids are UUIDs, so the quoting
// pattern in the spec is safe; the inner quotes are required by PostgREST
// for UUID literals.
const filter = `id=in.(${adminUser.brand_ids
.map((id) => `"${id}"`)
.join(",")})`;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
{ headers: svcHeaders(serviceKey), cache: "no-store" }
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
+8 -3
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
export type CampaignType = "marketing" | "operational" | "transactional"; export type CampaignType = "marketing" | "operational" | "transactional";
@@ -57,7 +58,11 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
@@ -147,7 +152,7 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
{ {
method: "POST", method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: adminUser.brand_id ?? null }), body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
} }
); );
@@ -167,7 +172,7 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
{ {
method: "POST", method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: brandId ?? adminUser.brand_id ?? null }), body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser, brandId)) ?? null }),
} }
); );
+8 -3
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import type { AudienceRules } from "./campaigns"; import type { AudienceRules } from "./campaigns";
@@ -120,11 +121,15 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
// Resolve brand from campaign or parameter // Resolve brand from campaign or parameter (URL > cookie > legacy > first of brand_ids)
const effectiveBrandId = brandId ?? adminUser.brand_id; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
// Brand scoping: brand_admin can only send their own brand's campaigns // Brand scoping: brand_admin can only send their own brand's campaigns
if (adminUser.role === "brand_admin" && effectiveBrandId && adminUser.brand_id !== effectiveBrandId) { if (adminUser.role === "brand_admin" && effectiveBrandId && !adminUser.brand_ids.includes(effectiveBrandId)) {
return { success: false, error: "Not authorized to send this brand's campaigns" }; return { success: false, error: "Not authorized to send this brand's campaigns" };
} }
+7 -2
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import type { AudienceRules } from "./campaigns"; import type { AudienceRules } from "./campaigns";
@@ -33,7 +34,11 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
// Brand scoping: brand_admin can only see their own brand's templates // Brand scoping: brand_admin can only see their own brand's templates
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) { if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
@@ -112,7 +117,7 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
{ {
method: "POST", method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? null }), body: JSON.stringify({ p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
} }
); );
+3 -2
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -66,7 +67,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
const brandId = adminUser.brand_id ?? null; const brandId = await getActiveBrandId(adminUser);
// Get today's date range // Get today's date range
const today = new Date(); const today = new Date();
@@ -202,7 +203,7 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
const brandId = adminUser.brand_id ?? null; const brandId = await getActiveBrandId(adminUser);
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
+36 -4
View File
@@ -2,10 +2,11 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models";
// ── Types ──────────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────────
export type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom"; export type AIProvider = AIProviderType;
export type AIProviderSettings = { export type AIProviderSettings = {
provider: AIProvider; provider: AIProvider;
@@ -25,6 +26,10 @@ export type CustomIntegration = {
testEndpoint?: string; testEndpoint?: string;
}; };
// MiniMax (MiniMax) is OpenAI-compatible. Default to the global endpoint; allow
// override via MINIMAX_BASE_URL env var (e.g. https://api.minimaxi.com/v1 for CN).
const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1";
// ── Get AI provider settings ───────────────────────────────────────────────────── // ── Get AI provider settings ─────────────────────────────────────────────────────
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> { export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
@@ -111,9 +116,26 @@ export async function getAIClient(brandId: string): Promise<{
const settings = await getAIProviderSettings(brandId); const settings = await getAIProviderSettings(brandId);
if (!settings.apiKey) { if (!settings.apiKey) {
// Fall back to env var // Fall back to env var. Check the saved provider first, then universal env keys.
const envKey = process.env.OPENAI_API_KEY; const envKey =
if (!envKey) return { provider: "openai", model: "gpt-4o-mini", client: null, error: "No API key configured" }; process.env.MINIMAX_API_KEY ||
process.env.OPENAI_API_KEY ||
process.env.ANTHROPIC_API_KEY ||
process.env.XAI_API_KEY ||
process.env.GOOGLE_API_KEY;
if (!envKey) {
return { provider: settings.provider || "openai", model: DEFAULT_MODELS[settings.provider as Exclude<AIProvider, "custom">] ?? "gpt-4o-mini", client: null, error: "No API key configured" };
}
// If the saved provider is minimax, use MiniMax even from env
if (settings.provider === "minimax" || process.env.MINIMAX_API_KEY) {
const { OpenAI } = await import("openai");
const baseURL = process.env.MINIMAX_BASE_URL || MINIMAX_DEFAULT_BASE_URL;
return {
provider: "minimax",
model: settings.model || DEFAULT_MODELS.minimax,
client: new OpenAI({ apiKey: process.env.MINIMAX_API_KEY ?? envKey, baseURL }),
};
}
const { OpenAI } = await import("openai"); const { OpenAI } = await import("openai");
return { return {
provider: "openai", provider: "openai",
@@ -147,6 +169,16 @@ export async function getAIClient(brandId: string): Promise<{
client: new XAIOpenAI({ apiKey: settings.apiKey, baseURL: "https://api.x.ai/v1" }), client: new XAIOpenAI({ apiKey: settings.apiKey, baseURL: "https://api.x.ai/v1" }),
}; };
} }
case "minimax": {
// MiniMax (MiniMax) is OpenAI-compatible — swap baseURL
const { OpenAI } = await import("openai");
const baseURL = process.env.MINIMAX_BASE_URL || MINIMAX_DEFAULT_BASE_URL;
return {
provider: "minimax",
model: settings.model || DEFAULT_MODELS.minimax,
client: new OpenAI({ apiKey: settings.apiKey, baseURL }),
};
}
case "custom": { case "custom": {
if (!settings.customEndpoint) { if (!settings.customEndpoint) {
// @ts-expect-error - intentionally returning extra property for error signaling // @ts-expect-error - intentionally returning extra property for error signaling
+304
View File
@@ -0,0 +1,304 @@
"use server";
import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type LocationInput = {
name: string;
address?: string | null;
city?: string | null;
state?: string | null;
zip?: string | null;
phone?: string | null;
contact_name?: string | null;
contact_email?: string | null;
notes?: string | null;
active?: boolean;
};
export type Location = {
id: string;
brand_id: string;
name: string;
address: string | null;
city: string | null;
state: string | null;
zip: string | null;
phone: string | null;
contact_name: string | null;
contact_email: string | null;
notes: string | null;
active: boolean;
deleted_at: string | null;
created_at: string;
updated_at: string;
slug: string | null;
};
export type LocationWithCount = Location & { stop_count: number };
// ── Create (single) ──────────────────────────────────────────────────────────
export async function createLocation(
brandId: string,
input: LocationInput
): Promise<{ success: true; id: string; slug: string } | { success: false; error: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, error: "Not authorized to manage locations" };
}
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) return { success: false, error: "No brand selected" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/admin_create_location`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: effectiveBrandId,
p_name: input.name,
p_address: input.address ?? null,
p_city: input.city ?? null,
p_state: input.state ?? null,
p_zip: input.zip ?? null,
p_phone: input.phone ?? null,
p_contact_name: input.contact_name ?? null,
p_contact_email: input.contact_email ?? null,
p_notes: input.notes ?? null,
p_active: input.active ?? true,
}),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Insert failed" };
}
const data = await res.json();
revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
return { success: true, id: data.id, slug: data.slug };
}
// ── Create (batch) ───────────────────────────────────────────────────────────
export async function createLocationsBatch(
brandId: string,
locations: LocationInput[]
): Promise<{ success: boolean; created: number; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, created: 0, error: "Not authorized" };
}
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, created: 0, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/admin_create_locations_batch`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
}
const inserted = await res.json();
revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
return {
success: true,
created: Array.isArray(inserted) ? inserted.length : locations.length,
};
}
// ── Update (partial) ─────────────────────────────────────────────────────────
export async function updateLocation(
locationId: string,
brandId: string,
updates: Partial<LocationInput>
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/admin_update_location`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Update failed" };
}
const data = await res.json();
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:locations`, "default");
return { success: true };
}
// ── Delete (soft) ────────────────────────────────────────────────────────────
export async function deleteLocation(
locationId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/admin_delete_location`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Delete failed" };
}
const data = await res.json();
if (!data.success) return { success: false, error: data.error ?? "Delete failed" };
revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:locations`, "default");
return { success: true };
}
// ── Read (admin, by brand_id) ────────────────────────────────────────────────
export async function adminListLocations(
brandId: string
): Promise<LocationWithCount[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Application-layer brand scoping: platform_admin (brand_id: null) gets all
// brands; everyone else gets only their own brand.
const activeBrandId = await getActiveBrandId(adminUser, brandId);
const effectiveBrandId = activeBrandId;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/admin_list_locations`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] },
}
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? (data as LocationWithCount[]) : [];
}
// ── Read (public, by brand slug) ─────────────────────────────────────────────
export type PublicLocation = Pick<
Location,
"id" | "name" | "address" | "city" | "state" | "zip" | "phone" | "slug"
> & { stop_count: number };
export async function getPublicLocationsForBrand(
brandSlug: string
): Promise<PublicLocation[]> {
if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_locations_for_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: { revalidate: 300, tags: ["locations", `brand:${brandSlug}:locations`] },
}
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? (data as PublicLocation[]) : [];
}
// ── Attach a stop to a location ──────────────────────────────────────────────
export async function attachStopToLocation(
stopId: string,
locationId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_stop_id: stopId,
p_location_id: locationId,
p_brand_id: brandId,
}),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Attach failed" };
}
const data = await res.json();
if (!data.success) return { success: false, error: data.error ?? "Attach failed" };
revalidateTag("stops", "default");
revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:stops`, "default");
revalidateTag(`brand:${brandId}:locations`, "default");
return { success: true };
}
+129
View File
@@ -0,0 +1,129 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
import { randomUUID } from "crypto";
export type AdminCreateOrderItem = {
product_id: string;
quantity: number;
price: number;
fulfillment?: "pickup" | "ship";
};
export type AdminCreateOrderInput = {
customer_name: string;
customer_email?: string | null;
customer_phone?: string | null;
stop_id?: string | null; // null for shipping-only / manual
items: AdminCreateOrderItem[];
internal_notes?: string | null;
// Optional overrides; if omitted we can compute simple or let RPC default
tax_amount?: number;
discount_amount?: number;
discount_reason?: string | null;
};
export type AdminCreateOrderResult =
| { success: true; orderId: string; order: any }
| { success: false; error: string };
export async function createAdminOrder(
brandId: string | null,
input: AdminCreateOrderInput
): Promise<AdminCreateOrderResult> {
const adminUser = await getAdminUser();
if (!adminUser) {
return { success: false, error: "Not authenticated" };
}
if (!adminUser.can_manage_orders) {
return { success: false, error: "Not authorized to create orders" };
}
// Brand scoping
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
if (adminUser.role === "brand_admin" && adminUser.brand_id && effectiveBrandId !== adminUser.brand_id) {
return { success: false, error: "Not authorized for this brand" };
}
if (!input.customer_name?.trim()) {
return { success: false, error: "Customer name is required" };
}
if (!input.items || input.items.length === 0) {
return { success: false, error: "At least one item is required" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Build items for RPC (match checkout shape)
const rpcItems = input.items.map((i) => ({
product_id: i.product_id,
quantity: i.quantity,
price: i.price,
fulfillment: i.fulfillment ?? "pickup",
}));
const idempotencyKey = randomUUID();
// For admin manual orders we pass minimal tax info (0 or provided). Full tax calc can be added later.
const taxAmount = input.tax_amount ?? 0;
const taxRate = 0;
const taxLocation = null;
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({
p_idempotency_key: idempotencyKey,
p_customer_name: input.customer_name.trim(),
p_customer_email: input.customer_email?.trim() || null,
p_customer_phone: input.customer_phone?.trim() || null,
p_stop_id: input.stop_id || null,
p_items: rpcItems,
p_tax_amount: taxAmount,
p_tax_rate: taxRate,
p_tax_location: taxLocation,
// The RPC may also accept brand scoping internally via stop or we can extend later.
}),
}
);
if (!response.ok) {
const errText = await response.text().catch(() => "Unknown error");
return { success: false, error: `Failed to create order: ${errText}` };
}
const data = await response.json();
if (!data || !data.id) {
return { success: false, error: "Order created but no ID returned" };
}
// Optionally attach internal notes via a follow-up update if the RPC doesn't support it directly.
if (input.internal_notes?.trim()) {
// Best-effort; don't fail the whole create if this secondary update fails.
try {
await fetch(`${supabaseUrl}/rest/v1/orders?id=eq.${data.id}`, {
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ internal_notes: input.internal_notes.trim() }),
});
} catch {
// ignore
}
}
return { success: true, orderId: data.id, order: data };
} catch (err: any) {
return { success: false, error: err?.message ?? "Unexpected error creating order" };
}
}
+4 -4
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
export type PaymentProvider = "stripe" | "square" | "manual"; export type PaymentProvider = "stripe" | "square" | "manual";
@@ -65,10 +66,9 @@ export async function savePaymentSettings(params: {
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
if ( try {
adminUser.role === "brand_admin" && assertBrandAccess(adminUser, params.brandId);
adminUser.brand_id !== params.brandId } catch {
) {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
+6 -1
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
export async function deleteProduct( export async function deleteProduct(
@@ -16,7 +17,11 @@ export async function deleteProduct(
return { success: false, error: "Not authorized to manage products" }; return { success: false, error: "Not authorized to manage products" };
} }
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
+6 -1
View File
@@ -38,7 +38,12 @@ export async function uploadProductImage(
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`, `${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
{ {
method: "PUT", method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" }, headers: {
"apikey": supabaseKey,
"Authorization": `Bearer ${supabaseKey}`,
"Content-Type": `image/${ext}`,
"x-upsert": "true"
},
body: buffer, body: buffer,
} }
); );
+65 -8
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -153,7 +154,14 @@ export interface FieldYieldSummary {
export async function getRouteTraceLots(brandId: string, status?: string) { export async function getRouteTraceLots(brandId: string, status?: string) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" }; if (!effectiveBrandId) return { success: false, error: "No brand" };
try { try {
@@ -235,7 +243,14 @@ export async function createHarvestLot(
) { ) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" }; if (!effectiveBrandId) return { success: false, error: "No brand" };
try { try {
@@ -298,7 +313,14 @@ export async function updateHarvestLotStatus(
export async function getRouteTraceStats(brandId: string) { export async function getRouteTraceStats(brandId: string) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" }; if (!effectiveBrandId) return { success: false, error: "No brand" };
try { try {
@@ -318,7 +340,14 @@ export async function getRouteTraceStats(brandId: string) {
export async function searchHarvestLots(brandId: string, query: string) { export async function searchHarvestLots(brandId: string, query: string) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" }; if (!effectiveBrandId) return { success: false, error: "No brand" };
try { try {
@@ -347,7 +376,14 @@ export async function getTraceChain(lotId: string) {
export async function getHarvestLotsReadyToHaul(brandId: string) { export async function getHarvestLotsReadyToHaul(brandId: string) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" }; if (!effectiveBrandId) return { success: false, error: "No brand" };
try { try {
@@ -364,7 +400,14 @@ export async function getHarvestLotsReadyToHaul(brandId: string) {
export async function getFieldYieldSummary(brandId: string) { export async function getFieldYieldSummary(brandId: string) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" }; if (!effectiveBrandId) return { success: false, error: "No brand" };
try { try {
@@ -405,7 +448,14 @@ export interface InventoryByCrop {
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> { export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" }; if (!effectiveBrandId) return { success: false, error: "No brand" };
try { try {
@@ -451,7 +501,14 @@ export async function getRecentLotEvents(
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> { ): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" }; if (!effectiveBrandId) return { success: false, error: "No brand" };
try { try {
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import {
setActiveBrandCookie,
clearActiveBrandCookie,
} from "@/lib/brand-scope";
/**
* Set the persistent "active brand" for the current admin user.
*
* - `brandId === null`: "All brands" — only allowed for platform_admin.
* Clears the cookie (cookie absence = no specific brand pinned).
* - `brandId` string: sets the cookie, after validating the admin has access.
*
* The active brand is the default the UI uses for pages that don't receive
* an explicit `brandId` from the URL. The cookie is the source of truth —
* the URL is only for deep-linking.
*/
export async function setActiveBrand(
brandId: string | null
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// null = "All brands" (platform_admin only)
if (brandId === null) {
if (adminUser.role !== "platform_admin") {
return {
success: false,
error: "Only platform admins can select 'All brands'",
};
}
await clearActiveBrandCookie();
return { success: true };
}
if (
adminUser.role !== "platform_admin" &&
!adminUser.brand_ids.includes(brandId)
) {
return { success: false, error: "No access to that brand" };
}
await setActiveBrandCookie(brandId);
return { success: true };
}
+3 -2
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
export type UpdateShippingStatusResult = export type UpdateShippingStatusResult =
@@ -28,7 +29,7 @@ export async function updateShippingStatus(
p_order_id: orderId, p_order_id: orderId,
p_shipping_status: status, p_shipping_status: status,
p_tracking_number: trackingNumber ?? null, p_tracking_number: trackingNumber ?? null,
p_brand_id: adminUser.brand_id ?? null, p_brand_id: await getActiveBrandId(adminUser),
}), }),
} }
); );
@@ -79,7 +80,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
method: "POST", method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
p_brand_id: adminUser.brand_id ?? null, p_brand_id: await getActiveBrandId(adminUser),
}), }),
} }
); );
+5
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import type { FedExServiceType } from "./fedex-rates"; import type { FedExServiceType } from "./fedex-rates";
@@ -156,6 +157,10 @@ export async function createFedExShipment(
const order = orders[0]; const order = orders[0];
if (!order) return { success: false, error: "Order not found" }; if (!order) return { success: false, error: "Order not found" };
// The order's brand is the source of truth. Validate the admin has access.
if (order.brand_id) {
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null; const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
// Get FedEx settings // Get FedEx settings
+5
View File
@@ -11,6 +11,7 @@
*/ */
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -177,6 +178,10 @@ export async function getFedExRates(
const order = orders[0]; const order = orders[0];
if (!order) return { success: false, error: "Order not found" }; if (!order) return { success: false, error: "Order not found" };
// The order's brand is the source of truth. Validate the admin has access.
if (order.brand_id) {
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null; const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
// Fetch shipping settings for this brand // Fetch shipping settings for this brand
+7 -2
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
export type SyncLogEntry = { export type SyncLogEntry = {
@@ -29,7 +30,9 @@ export async function syncSquareNow(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] }; if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
if (!adminUser.can_manage_orders) return { success: false, synced: 0, errors: ["Not authorized"] }; if (!adminUser.can_manage_orders) return { success: false, synced: 0, errors: ["Not authorized"] };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { try {
assertBrandAccess(adminUser, brandId);
} catch {
return { success: false, synced: 0, errors: ["Not authorized"] }; return { success: false, synced: 0, errors: ["Not authorized"] };
} }
@@ -61,7 +64,9 @@ export async function getSyncLog(brandId: string): Promise<{
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, logs: [] }; if (!adminUser) return { success: false, logs: [] };
if (!adminUser.can_manage_orders) return { success: false, logs: [] }; if (!adminUser.can_manage_orders) return { success: false, logs: [] };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { try {
assertBrandAccess(adminUser, brandId);
} catch {
return { success: false, logs: [] }; return { success: false, logs: [] };
} }
+112 -33
View File
@@ -1,6 +1,8 @@
"use server"; "use server";
import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
export type StopImportRow = { export type StopImportRow = {
@@ -24,35 +26,37 @@ export async function createStopsBatch(
return { success: false, created: 0, error: "Not authorized to manage stops" }; return { success: false, created: 0, error: "Not authorized to manage stops" };
} }
const effectiveBrandId = brandId || adminUser.brand_id; const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, created: 0, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) { if (!effectiveBrandId) {
return { success: false, created: 0, error: "No brand selected" }; return { success: false, created: 0, error: "No brand selected" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
// Use anon key — admin_create_stops_batch is SECURITY DEFINER so RLS is
// bypassed. This fixes the prior 42501 RLS violation that came from doing
// direct REST inserts against the RLS-blocked `stops` table.
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const rows = stops.map((s) => { const rows = stops.map((s) => ({
const slug = `${s.city.toLowerCase().replace(/\s+/g, "-")}-${s.date || new Date().toISOString().slice(0, 10)}`; city: s.city,
return { state: s.state,
city: s.city, location: s.location,
state: s.state, date: s.date || "",
location: s.location, time: s.time || "",
date: s.date || "", address: s.address || null,
time: s.time || "", zip: s.zip || null,
address: s.address || null, cutoff_time: null,
zip: s.zip || null, active: false,
brand_id: effectiveBrandId, }));
slug,
status: "draft",
active: false,
};
});
const res = await fetch(`${supabaseUrl}/rest/v1/stops`, { const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stops_batch`, {
method: "POST", method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify(rows), body: JSON.stringify({ p_brand_id: effectiveBrandId, p_stops: rows }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -60,6 +64,9 @@ export async function createStopsBatch(
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" }; return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
} }
revalidateTag("stops", "default");
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
const inserted = await res.json(); const inserted = await res.json();
return { success: true, created: Array.isArray(inserted) ? inserted.length : stops.length }; return { success: true, created: Array.isArray(inserted) ? inserted.length : stops.length };
} }
@@ -86,6 +93,9 @@ export async function publishStop(
return { success: false, error: (err as { message?: string }).message ?? "Publish failed" }; return { success: false, error: (err as { message?: string }).message ?? "Publish failed" };
} }
revalidateTag("stops", "default");
revalidateTag(`brand:${brandId}:stops`, "default");
return { success: true }; return { success: true };
} }
@@ -100,20 +110,89 @@ export type StopForSitemap = {
}; };
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> { export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the sitemap render with only the static URLs.
if (!supabaseUrl || !supabaseKey) return [];
// Get all active stops with their brand slug // Get all active stops with their brand slug. Wrapped in try/catch so a
const response = await fetch( // build-time outage (ECONNREFUSED) doesn't crash the prerender — the
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`, // sitemap just renders without stop URLs.
{ try {
method: "POST", const response = await fetch(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, `${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
} {
); method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
);
if (!response.ok) return []; if (!response.ok) return [];
const stops = await response.json(); const stops = await response.json();
return Array.isArray(stops) ? stops : []; return Array.isArray(stops) ? stops : [];
} catch {
return [];
}
}
/**
* Fetch active stops for a brand by slug.
* Public action used by the storefront stop pages (e.g. /tuxedo/stops,
* /indian-river-direct/stops) — replaces the previous client-side
* `supabase.from("stops")` query so supabase-js no longer ships to
* the browser for those routes.
*
* Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')`
* from any stop mutation (see createStopsBatch, publishStop, etc.).
*/
export type PublicStop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
address: string | null;
slug: string;
cutoff_time: string | null;
};
export async function getPublicStopsForBrand(
brandSlug: string
): Promise<PublicStop[]> {
if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the page render with zero stops; at runtime the
// fetch path is unchanged.
if (!supabaseUrl || !supabaseKey) return [];
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just renders with no stops
// and revalidates from a real request once the cache is warm.
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: {
revalidate: 300,
tags: ["stops", `brand:${brandSlug}:stops`],
},
}
);
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
} catch {
return [];
}
} }
+74 -21
View File
@@ -1,5 +1,6 @@
"use server"; "use server";
import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
import { getMockTableData } from "@/lib/mock-data"; import { getMockTableData } from "@/lib/mock-data";
@@ -38,34 +39,86 @@ export async function createStop(
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // Preferred path: anon key + SECURITY DEFINER RPC (bypasses RLS, works even if
// service role key is absent at runtime). See:
// supabase/migrations/202_fix_admin_create_stop.sql
// scripts/apply-admin-create-stop.js (run this locally with the keys you have)
// supabase/ADMIN_CREATE_STOP_FIX.sql (exact prompt SQL for SQL editor)
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date || new Date().toISOString().slice(0, 10)}`; const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stop`, {
const res = await fetch(`${supabaseUrl}/rest/v1/stops`, {
method: "POST", method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, headers: { ...svcHeaders(anonKey), "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
city: data.city, p_active: data.active ?? false,
state: data.state, p_address: data.address || null,
location: data.location, p_brand_id: brandId,
date: data.date, p_city: data.city,
time: data.time, p_cutoff_time: data.cutoff_time || null,
slug, p_date: data.date,
brand_id: brandId, p_location: data.location,
active: data.active ?? false, p_state: data.state,
address: data.address ?? null, p_time: data.time,
zip: data.zip ?? null, p_zip: data.zip || null,
cutoff_time: data.cutoff_time ?? null,
status: "draft",
}), }),
}); });
let usedFallback = false;
if (!res.ok) { if (!res.ok) {
const err = await res.text(); const errText = await res.text();
return { success: false, error: `Failed: ${err}` }; const lower = errText.toLowerCase();
const looksLikeMissingFn =
lower.includes("pgrst202") ||
lower.includes("admin_create_stop") ||
lower.includes("could not find the function") ||
lower.includes("function not found");
if (looksLikeMissingFn) {
usedFallback = true;
} else {
return { success: false, error: `Failed: ${errText}` };
}
} else {
const inserted = await res.json().catch(() => ({} as any));
if (inserted && inserted.success === false) {
// Our RPC returns structured errors as 200 + {success:false}
const errMsg = inserted.error || "Failed to create stop";
const lower = errMsg.toLowerCase();
if (lower.includes("function") || lower.includes("not found") || lower.includes("pgrst")) {
usedFallback = true;
} else {
return { success: false, error: errMsg };
}
} else {
// Happy path from RPC (either old {id,slug} or new {success, stop_id})
const stopId = inserted?.stop_id || inserted?.id || "";
const effectiveBrandId = brandId || adminUser.brand_id;
if (effectiveBrandId) {
revalidateTag("stops", "default");
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
}
return { success: true, id: stopId };
}
} }
const inserted = await res.json(); if (usedFallback) {
return { success: true, id: inserted[0]?.id ?? "" }; // We cannot bypass the block_stops_mutations RLS policy via REST even with the service key.
// The only reliable path is the SECURITY DEFINER RPC created by migration 202.
// Tell the user exactly how to install it using only the keys they already have.
return {
success: false,
error:
"The admin_create_stop database function is missing (this is why Add New Stop fails with function not found).\n\n" +
"Fix using only the keys in your .env.local (no Supabase dashboard needed):\n" +
" 1. On a machine that can reach your database (normal laptop usually works):\n" +
" node scripts/apply-admin-create-stop.js\n" +
" 2. Or: npm run migrate:one 202\n" +
" 3. Or apply supabase/migrations/202_fix_admin_create_stop.sql via psql / Supabase CLI with --db-url.\n\n" +
"After it succeeds, restart your dev server and Add New Stop will work.",
};
}
// Should not reach here
return { success: false, error: "Unexpected state creating stop" };
} }
+122
View File
@@ -0,0 +1,122 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { createClient } from "@supabase/supabase-js";
export type StopDetail = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
slug: string;
active: boolean;
brand_id: string;
address: string | null;
zip: string | null;
cutoff_time: string | null;
brands: { name: string; slug: string } | { name: string; slug: string }[] | null;
};
export type AssignedProduct = {
id: string;
product_id: string;
products: { name: string; type: string; price: number } | null;
};
export type StopDetailsResult =
| {
success: true;
stop: StopDetail;
allProducts: { id: string; name: string; type: string; price: number }[];
assignedProducts: AssignedProduct[];
brands: { id: string; name: string; slug: string }[];
/** admin_users.user_id of the caller, forwarded to RPCs that authorise via user_id lookup */
callerUid: string;
}
| { success: false; error: string };
/**
* Fetch a single stop with its brand, all candidate products, currently
* assigned products, and the list of brands (for the brand switcher in the
* edit form). Mirrors the data the old `/admin/stops/[id]` page server
* component loaded, so the modal can be a drop-in replacement.
*/
export async function getStopDetails(stopId: string): Promise<StopDetailsResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
// Use a fresh server-side client (cookie-less) so RLS doesn't block reads
// for platform_admin dev sessions. The auth check above has already gated
// access.
const server = useMockData
? null
: createClient(supabaseUrl, supabaseKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
// 1. Stop + brand
let stop: StopDetail | null = null;
let stopErr: string | null = null;
if (server) {
const { data, error } = await server
.from("stops")
.select("*, brands(name, slug)")
.eq("id", stopId)
.single();
if (error) stopErr = error.message;
else stop = (data ?? null) as StopDetail | null;
} else {
// Mock fallback — empty
stopErr = "Stop not found";
}
if (!stop) {
return { success: false, error: stopErr ?? "Stop not found" };
}
// Brand-scope check for brand_admin
if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) {
return { success: false, error: "Not authorized for this brand" };
}
// 2. Candidate products for this brand
const { data: allProducts } = server
? await server
.from("products")
.select("id, name, type, price")
.eq("brand_id", stop.brand_id)
.eq("active", true)
: { data: [] as { id: string; name: string; type: string; price: number }[] };
// 3. Assigned products (joined with product info)
const { data: productStops } = server
? await server
.from("product_stops")
.select("id, product_id, products(id, name, type, price)")
.eq("stop_id", stopId)
: { data: [] as AssignedProduct[] };
// 4. Brands for the brand switcher
const { data: brands } = server
? await server.from("brands").select("id, name, slug")
: { data: [] as { id: string; name: string; slug: string }[] };
return {
success: true,
stop,
allProducts: allProducts ?? [],
assignedProducts: (productStops ?? []) as AssignedProduct[],
brands: brands ?? [],
callerUid: adminUser.user_id,
};
}
+44 -13
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
type Irrigator = { type Irrigator = {
@@ -44,8 +45,14 @@ type WaterEntry = {
// ── Headgate Admin ────────────────────────────────────────── // ── Headgate Admin ──────────────────────────────────────────
export async function createWaterHeadgate(brandId: string, name: string, unit: string = "CFS"): Promise<{ success: boolean; headgate?: Headgate; error?: string }> { export async function createWaterHeadgate(brandId: string, name: string, unit: string = "CFS"): Promise<{ success: boolean; headgate?: Headgate; error?: string }> {
const adminUser = await (await import("@/lib/admin-permissions")).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 supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // prefer service for admin muts
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_water_headgate`, `${supabaseUrl}/rest/v1/rpc/create_water_headgate`,
@@ -88,7 +95,7 @@ export async function updateWaterHeadgate(
p_name: name, p_name: name,
p_active: active, p_active: active,
p_unit: unit ?? null, p_unit: unit ?? null,
p_brand_id: adminUser.brand_id ?? null, p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
p_high_threshold: highThreshold ?? null, p_high_threshold: highThreshold ?? null,
p_low_threshold: lowThreshold ?? null, p_low_threshold: lowThreshold ?? null,
}), }),
@@ -180,7 +187,7 @@ export async function updateWaterIrrigator(
p_active: active, p_active: active,
p_lang: lang, p_lang: lang,
p_role: role, p_role: role,
p_brand_id: adminUser.brand_id ?? null, p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}), }),
} }
); );
@@ -209,7 +216,7 @@ export async function resetWaterIrrigatorPin(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
p_user_id: irrigatorId, p_user_id: irrigatorId,
p_brand_id: adminUser.brand_id ?? null, p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}), }),
} }
); );
@@ -236,7 +243,7 @@ export async function deleteWaterUser(userId: string): Promise<{ success: boolea
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
p_user_id: userId, p_user_id: userId,
p_brand_id: adminUser.brand_id ?? null, p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}), }),
} }
); );
@@ -263,16 +270,40 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
p_headgate_id: headgateId, p_headgate_id: headgateId,
p_brand_id: adminUser.brand_id ?? null, p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}), }),
} }
); );
const data = await response.json(); // The RPC returns JSONB on success: { success: true } or { success: false, error: "..." }.
if (!response.ok || !data?.success) { // On failure (HTTP non-2xx) Supabase returns { message: "...", code: "...", details: ... }.
return { success: false, error: data?.error ?? "Failed to delete headgate" }; // We try to extract the most useful message in both cases.
let data: { success?: boolean; error?: string; message?: string } | null = null;
try {
data = await response.json();
} catch {
// Non-JSON body — leave data as null, fall through to default error
} }
return { success: true };
if (response.ok && data?.success) {
return { success: true };
}
// Prefer the RPC's own error if it set one
const errorMessage =
data?.error ??
data?.message ??
(response.ok ? "Unknown error" : `HTTP ${response.status}: ${response.statusText || "request failed"}`);
if (process.env.NODE_ENV !== "production") {
console.error("[deleteWaterHeadgate] failed", {
headgateId,
status: response.status,
data,
});
}
return { success: false, error: errorMessage };
} }
// ── Entries ──────────────────────────────────────────────── // ── Entries ────────────────────────────────────────────────
@@ -326,7 +357,7 @@ export async function regenerateHeadgateToken(headgateId: string): Promise<{ suc
{ {
method: "POST", method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: adminUser.brand_id ?? null }), body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
} }
); );
@@ -362,7 +393,7 @@ export async function updateWaterEntry(
p_measurement: measurement, p_measurement: measurement,
p_notes: notes, p_notes: notes,
p_unit: unit ?? null, p_unit: unit ?? null,
p_brand_id: adminUser.brand_id ?? null, p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}), }),
} }
); );
@@ -391,7 +422,7 @@ export async function deleteWaterEntry(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
p_entry_id: entryId, p_entry_id: entryId,
p_brand_id: adminUser.brand_id ?? null, p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}), }),
} }
); );
+4 -1
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
export async function registerWholesaleCustomer(params: { export async function registerWholesaleCustomer(params: {
@@ -81,7 +82,9 @@ export async function approveWholesaleRegistration(
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_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
if (adminUser.role !== "platform_admin" && adminUser.brand_id !== brandId) { try {
assertBrandAccess(adminUser, brandId);
} catch {
return { success: false, error: "Not authorized to operate on this brand" }; return { success: false, error: "Not authorized to operate on this brand" };
} }
+33 -27
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
export type WholesaleOrder = { export type WholesaleOrder = {
@@ -129,15 +130,16 @@ export type WholesaleDashboardStats = {
* platform_admin → null (means "all brands" — passes to RPC unchanged) * platform_admin → null (means "all brands" — passes to RPC unchanged)
* brand_admin → their own brand_id only; rejects attempts to operate on other brands * brand_admin → their own brand_id only; rejects attempts to operate on other brands
* store_employee → their own brand_id * store_employee → their own brand_id
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
* unauthenticated → null (actions should already bail out earlier) * unauthenticated → null (actions should already bail out earlier)
* *
* This prevents brand_admin from seeing or modifying another brand's data * This prevents brand_admin from seeing or modifying another brand's data
* even if they manually pass a different brandId to the action. * even if they manually pass a different brandId to the action.
*/ */
function resolveBrandId( async function resolveBrandId(
adminUser: Awaited<ReturnType<typeof getAdminUser>>, adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string requestedBrandId?: string
): string | null { ): Promise<string | null> {
if (!adminUser) return null; if (!adminUser) return null;
if (adminUser.role === "platform_admin") { if (adminUser.role === "platform_admin") {
@@ -145,15 +147,15 @@ function resolveBrandId(
return null; return null;
} }
// brand_admin and store_employee are scoped to their own brand // For non-platform-admin: resolve the active brand (validates against brand_ids)
const userBrand = adminUser.brand_id ?? null; const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && requestedBrandId !== userBrand) { if (requestedBrandId && activeBrandId !== requestedBrandId) {
// Brand admin trying to operate on another brand's data — block it // Brand admin trying to operate on another brand's data — block it
return null; // caller should check and return unauthorized return null; // caller should check and return unauthorized
} }
return userBrand; return activeBrandId;
} }
/** /**
@@ -161,23 +163,24 @@ function resolveBrandId(
* if a brand_admin tries to operate outside their brand. * if a brand_admin tries to operate outside their brand.
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked. * Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
*/ */
function enforceBrandScope( async function enforceBrandScope(
adminUser: Awaited<ReturnType<typeof getAdminUser>>, adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string requestedBrandId?: string
): { brandId: string | null; error?: string } { ): Promise<{ brandId: string | null; error?: string }> {
if (!adminUser) return { brandId: null, error: "Not authenticated" }; if (!adminUser) return { brandId: null, error: "Not authenticated" };
if (adminUser.role === "platform_admin") { if (adminUser.role === "platform_admin") {
return { brandId: null }; // unrestricted return { brandId: null }; // unrestricted
} }
const userBrand = adminUser.brand_id ?? null; // For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && requestedBrandId !== userBrand) { if (requestedBrandId && activeBrandId !== requestedBrandId) {
return { brandId: null, error: "Not authorized to operate on this brand" }; return { brandId: null, error: "Not authorized to operate on this brand" };
} }
return { brandId: userBrand }; return { brandId: activeBrandId };
} }
// ── Orders ────────────────────────────────────────────────────────────────── // ── Orders ──────────────────────────────────────────────────────────────────
@@ -185,7 +188,7 @@ function enforceBrandScope(
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> { export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return []; if (!adminUser) return [];
const bid = resolveBrandId(adminUser, brandId); const bid = await resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
@@ -209,7 +212,7 @@ export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrd
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> { export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return []; if (!adminUser) return [];
const bid = resolveBrandId(adminUser, brandId); const bid = await resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
@@ -256,7 +259,7 @@ export async function markWholesaleOrderFulfilled(orderId: string, brandId?: str
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { brandId: resolved, error } = enforceBrandScope(adminUser, brandId); const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -305,7 +308,7 @@ export async function updateWholesaleOrderStatus(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = enforceBrandScope(adminUser, brandId); const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -332,7 +335,7 @@ export async function deleteWholesaleOrder(orderId: string, brandId?: string): P
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = enforceBrandScope(adminUser, brandId); const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -359,7 +362,7 @@ export async function deleteWholesaleCustomer(customerId: string, brandId?: stri
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = enforceBrandScope(adminUser, brandId); const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -388,7 +391,7 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = enforceBrandScope(adminUser, brandId); const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -417,7 +420,7 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> { export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return []; if (!adminUser) return [];
const bid = resolveBrandId(adminUser, brandId); const bid = await resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
@@ -458,7 +461,7 @@ export async function saveWholesaleCustomer(params: {
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = enforceBrandScope(adminUser, params.brandId); const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -501,7 +504,7 @@ export async function saveWholesaleCustomer(params: {
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> { export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return []; if (!adminUser) return [];
const bid = resolveBrandId(adminUser, brandId); const bid = await resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
@@ -545,7 +548,7 @@ export async function saveWholesaleProduct(params: {
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = enforceBrandScope(adminUser, params.brandId); const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error }; if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -591,7 +594,10 @@ export async function saveWholesaleProduct(params: {
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> { export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return null; if (!adminUser) return null;
const bid = brandId ?? adminUser.brand_id ?? null; const bid = await getActiveBrandId(adminUser, brandId);
if (!bid && adminUser.role !== "platform_admin") {
return null;
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
@@ -700,7 +706,7 @@ export async function recordWholesaleDeposit(
p_method: method, p_method: method,
p_reference: reference ?? null, p_reference: reference ?? null,
p_recorded_by: adminUser.user_id, p_recorded_by: adminUser.user_id,
p_brand_id: adminUser.brand_id ?? null, p_brand_id: await getActiveBrandId(adminUser),
}), }),
} }
); );
@@ -710,7 +716,7 @@ export async function recordWholesaleDeposit(
if (!data?.success) return { success: false, error: data?.error ?? "Failed to record deposit" }; if (!data?.success) return { success: false, error: data?.error ?? "Failed to record deposit" };
// Fire webhook — fire-and-forget // Fire webhook — fire-and-forget
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, adminUser.brand_id ?? undefined).catch(() => {}); enqueueWholesaleWebhookForDepositRecorded(orderId, amount, (await getActiveBrandId(adminUser)) ?? undefined).catch(() => {});
return { success: true }; return { success: true };
} }
@@ -753,7 +759,7 @@ export async function bulkFulfillWholesaleOrders(
body: JSON.stringify({ body: JSON.stringify({
p_order_ids: orderIds, p_order_ids: orderIds,
p_by: adminUser.user_id, p_by: adminUser.user_id,
p_brand_id: adminUser.brand_id ?? null, p_brand_id: await getActiveBrandId(adminUser),
}), }),
} }
); );
@@ -791,7 +797,7 @@ export async function bulkRecordWholesaleDeposit(
p_method: method, p_method: method,
p_reference: reference ?? null, p_reference: reference ?? null,
p_recorded_by: adminUser.user_id, p_recorded_by: adminUser.user_id,
p_brand_id: adminUser.brand_id ?? null, p_brand_id: await getActiveBrandId(adminUser),
}), }),
} }
); );
+21 -19
View File
@@ -14,30 +14,32 @@ if (typeof window !== "undefined") {
export default function LandingPageClient() { export default function LandingPageClient() {
const mainRef = useRef<HTMLDivElement>(null); const mainRef = useRef<HTMLDivElement>(null);
// Global scroll animations // Global scroll animations (guarded to prevent missing-target warnings when no .scroll-reveal elements)
useEffect(() => { useEffect(() => {
if (typeof window === "undefined" || !mainRef.current) return; if (typeof window === "undefined" || !mainRef.current) return;
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
// Reveal animations for sections // Reveal animations for sections - only if targets exist (prevents GSAP "missing targets" warnings)
const revealElements = gsap.utils.toArray<Element>(".scroll-reveal"); const revealElements = gsap.utils.toArray<Element>(".scroll-reveal");
revealElements.forEach((el) => { if (revealElements.length > 0) {
gsap.fromTo( revealElements.forEach((el) => {
el, gsap.fromTo(
{ opacity: 0, y: 60 }, el,
{ { opacity: 0, y: 60 },
opacity: 1, {
y: 0, opacity: 1,
duration: 1, y: 0,
ease: "power3.out", duration: 1,
scrollTrigger: { ease: "power3.out",
trigger: el, scrollTrigger: {
start: "top 85%", trigger: el,
toggleActions: "play none none reverse", start: "top 85%",
}, toggleActions: "play none none reverse",
} },
); }
}); );
});
}
}, mainRef); }, mainRef);
return () => ctx.revert(); return () => ctx.revert();
+41 -1
View File
@@ -1,9 +1,49 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import Link from "next/link";
export default async function AdvancedSettingsPage() { export default async function AdvancedSettingsPage() {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) redirect("/login"); if (!adminUser) redirect("/login");
redirect("/admin/settings#advanced"); const isPlatform = adminUser.role === "platform_admin";
return (
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-4xl">
<div className="mb-8">
<Link href="/admin/settings" className="text-sm text-stone-500 hover:text-stone-700"> Back to Settings</Link>
<h1 className="mt-2 text-3xl font-bold tracking-tight text-stone-950">Advanced Settings</h1>
<p className="mt-1 text-stone-600">Platform &amp; AI configuration, feature flags, and integrations.</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Link href="/admin/settings/ai" className="block rounded-2xl border bg-white p-6 hover:shadow">
<div className="font-semibold">AI Intelligence Pack</div>
<div className="text-sm text-stone-500 mt-1">Provider keys, model preferences, and usage for campaign writer, pricing advisor, etc.</div>
</Link>
<Link href="/admin/settings/integrations" className="block rounded-2xl border bg-white p-6 hover:shadow">
<div className="font-semibold">Integrations</div>
<div className="text-sm text-stone-500 mt-1">Resend, Twilio, Stripe, Square, and custom AI providers.</div>
</Link>
<Link href="/admin/settings/square-sync" className="block rounded-2xl border bg-white p-6 hover:shadow">
<div className="font-semibold">Square Sync</div>
<div className="text-sm text-stone-500 mt-1">Inventory &amp; product sync configuration.</div>
</Link>
<Link href="/admin/settings/shipping" className="block rounded-2xl border bg-white p-6 hover:shadow">
<div className="font-semibold">Shipping &amp; FedEx</div>
<div className="text-sm text-stone-500 mt-1">Rates, label creation, and settings.</div>
</Link>
</div>
{!isPlatform && (
<p className="mt-8 text-xs text-stone-500">Some advanced options are only visible to platform administrators.</p>
)}
<div className="mt-8 text-xs text-stone-400">
These pages aggregate the real configuration surfaces. Feature flags live under Apps in the main settings.
</div>
</div>
</main>
);
} }
@@ -11,6 +11,12 @@ export const metadata: Metadata = {
description: "Create and send email campaigns to your customers.", description: "Create and send email campaigns to your customers.",
}; };
// Legacy /compose route: now consolidated into the main Communications page.
// We render the same component with initialTab="compose" so users land directly
// in the unified CampaignComposerPage (no duplicate "edit" / "new" experience).
// The previous render passed editMode="new" which forced a separate
// CampaignEditPanel render in the campaigns tab — that duplicate has been
// removed. /compose is preserved for backwards compatibility (existing links).
export default async function ComposePage() { export default async function ComposePage() {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser || !adminUser.can_manage_messages) { if (!adminUser || !adminUser.can_manage_messages) {
@@ -31,7 +37,7 @@ export default async function ComposePage() {
templates={templatesResult.success ? templatesResult.templates : []} templates={templatesResult.success ? templatesResult.templates : []}
brandId={effectiveBrandId} brandId={effectiveBrandId}
initialSegments={segmentsResult.success ? segmentsResult.segments : []} initialSegments={segmentsResult.success ? segmentsResult.segments : []}
editMode="new" initialTab="compose"
/> />
); );
} }
+21 -1
View File
@@ -1,11 +1,18 @@
import AdminSidebar from "@/components/admin/AdminSidebar"; import AdminSidebar from "@/components/admin/AdminSidebar";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { listBrandsForAdmin } from "@/actions/brands";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
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";
// Admin layout calls getAdminUser() which reads cookies(). Without this,
// Next.js tries to prerender the entire /admin/* tree statically and the
// first page that hits cookies() aborts the build with DYNAMIC_SERVER_USAGE.
export const dynamic = "force-dynamic";
// Toast provider wrapper component // Toast provider wrapper component
function ToastProviderWrapper({ children }: { children: React.ReactNode }) { function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
return ( return (
@@ -57,9 +64,22 @@ export default async function AdminLayout({ children }: { children: React.ReactN
redirect("/change-password"); redirect("/change-password");
} }
// Resolve the active brand (URL > cookie > legacy > first of brand_ids)
const activeBrandId = await getActiveBrandId(adminUser);
// Fetch accessible brands for the sidebar BrandSelector. We do this
// unconditionally — `listBrandsForAdmin` is cheap and the sidebar
// decides whether to show the dropdown.
const brands = await listBrandsForAdmin();
return ( return (
<ToastProviderWrapper> <ToastProviderWrapper>
<AdminSidebar userRole={adminUser.role} /> <AdminSidebar
userRole={adminUser.role}
brandIds={adminUser.brand_ids}
activeBrandId={activeBrandId}
brands={brands}
/>
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}> <div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
{children} {children}
</div> </div>
+10 -3
View File
@@ -153,8 +153,9 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
{editing && ( {editing && (
<form onSubmit={handleSaveProfile} className="mt-6 space-y-4"> <form onSubmit={handleSaveProfile} className="mt-6 space-y-4">
<div> <div>
<label className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Display Name</label> <label htmlFor="me-display-name" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Display Name</label>
<input <input
id="me-display-name"
type="text" type="text"
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
@@ -168,8 +169,9 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Phone Number</label> <label htmlFor="me-phone" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Phone Number</label>
<input <input
id="me-phone"
type="tel" type="tel"
value={phoneNumber} value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)} onChange={(e) => setPhoneNumber(e.target.value)}
@@ -180,6 +182,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
backgroundColor: "white" backgroundColor: "white"
}} }}
placeholder="+1 (555) 000-0000" placeholder="+1 (555) 000-0000"
autoComplete="tel"
/> />
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
@@ -228,11 +231,14 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
) : ( ) : (
<form onSubmit={handleEmailChange} className="mt-4 space-y-3"> <form onSubmit={handleEmailChange} className="mt-4 space-y-3">
<div> <div>
<label className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>New Email Address</label> <label htmlFor="me-new-email" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>New Email Address</label>
<input <input
id="me-new-email"
type="email" type="email"
value={newEmail} value={newEmail}
onChange={(e) => setNewEmail(e.target.value)} onChange={(e) => setNewEmail(e.target.value)}
required
aria-required="true"
className="mt-1 w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-1" className="mt-1 w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-1"
style={{ style={{
borderColor: "var(--admin-border)", borderColor: "var(--admin-border)",
@@ -240,6 +246,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
backgroundColor: "white" backgroundColor: "white"
}} }}
placeholder="new@example.com" placeholder="new@example.com"
autoComplete="email"
/> />
</div> </div>
{emailError && ( {emailError && (
+5 -4
View File
@@ -6,6 +6,7 @@ import OrderPickupAction from "@/components/admin/OrderPickupAction";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
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";
type OrderDetailPageProps = { type OrderDetailPageProps = {
params: Promise<{ params: Promise<{
@@ -26,12 +27,12 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
return ( return (
<main className="min-h-screen px-6 py-10"> <main className="min-h-screen px-6 py-10">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<a <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 text-stone-500 hover:text-stone-700"
> >
Back to Orders Back to Orders
</a> </Link>
<div className="mt-8 rounded-2xl border border-red-200 bg-red-50 p-8 text-center"> <div className="mt-8 rounded-2xl border border-red-200 bg-red-50 p-8 text-center">
<p className="text-lg font-semibold text-red-700">Order not found</p> <p className="text-lg font-semibold text-red-700">Order not found</p>
</div> </div>
@@ -62,14 +63,14 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<a <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="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 bg-white text-stone-500 hover:bg-stone-50 transition-colors"
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg> </svg>
</a> </Link>
<div> <div>
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Order Details</h1> <h1 className="text-2xl font-bold text-stone-950 tracking-tight">Order Details</h1>
<p className="text-sm text-stone-500">{formatDate(order.created_at)}</p> <p className="text-sm text-stone-500">{formatDate(order.created_at)}</p>
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default function AdminNewOrderRedirect() {
// Preserve the "create first order" intent but route to the supported flow
redirect("/admin/orders?new=true");
}
+40 -4
View File
@@ -1,9 +1,11 @@
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel"; import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { getAdminOrders } from "@/actions/orders"; import { getAdminOrders } from "@/actions/orders";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system"; import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -16,19 +18,52 @@ export default async function AdminOrdersPage() {
redirect("/admin/pickup"); redirect("/admin/pickup");
} }
const activeBrandId = await getActiveBrandId(adminUser);
// Platform admin can browse all brands' orders; everyone else must have a brand
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied message="You don't have access to any brand." />;
}
const { orders, stops } = await getAdminOrders(); const { orders, stops } = await getAdminOrders();
const brandStops = adminUser?.brand_id const brandStops = activeBrandId
? stops.filter((s) => s.brand_id === adminUser.brand_id) ? stops.filter((s) => s.brand_id === activeBrandId)
: stops; : stops;
const brandOrders = adminUser?.brand_id const brandOrders = activeBrandId
? orders.filter( ? orders.filter(
(o) => (o) =>
o.stops && brandStops.some((s) => s.id === o.stop_id) o.stops && brandStops.some((s) => s.id === o.stop_id)
) )
: orders; : orders;
// Fetch active products for this brand (for admin "New Order" item picker)
let brandProducts: Array<{ id: string; name: string; price: number; type?: string | null; active?: boolean }> = [];
try {
let prodQuery = supabase
.from("products")
.select("id, name, price, type, active")
.eq("active", true)
.is("deleted_at", null)
.order("name")
.limit(200);
if (activeBrandId) {
prodQuery = prodQuery.eq("brand_id", activeBrandId);
}
const { data: prods } = await prodQuery;
brandProducts = (prods ?? []).map((p: any) => ({
id: p.id,
name: p.name,
price: Number(p.price),
type: p.type ?? null,
active: p.active ?? true,
}));
} catch {
// non-fatal for the orders list
}
return ( return (
<div className="min-h-screen bg-[var(--admin-bg)]"> <div className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6"> <div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
@@ -51,7 +86,8 @@ export default async function AdminOrdersPage() {
<AdminOrdersPanel <AdminOrdersPanel
initialOrders={brandOrders} initialOrders={brandOrders}
initialStops={brandStops} initialStops={brandStops}
brandId={adminUser?.brand_id ?? null} initialProducts={brandProducts}
brandId={activeBrandId}
/> />
</div> </div>
</div> </div>
+44 -14
View File
@@ -1,7 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { isFeatureEnabled } from "@/lib/feature-flags"; import { isFeatureEnabled } from "@/lib/feature-flags";
import { getBrandPlanInfo } from "@/actions/billing/stripe-portal"; import { getBillingOverview } from "@/actions/billing/billing-overview";
import DashboardClient from "@/components/admin/DashboardClient"; import DashboardClient from "@/components/admin/DashboardClient";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
@@ -22,20 +24,48 @@ export default async function AdminPage() {
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 }; let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
let brandDisplayName = "Admin"; let brandDisplayName = "Admin";
if (adminUser?.brand_id) { // Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
for (const key of ["harvest_reach", "wholesale_portal", "water_log", "ai_tools", "sms_campaigns", "square_sync", "route_trace"] as const) { // > first of brand_ids). For platform_admin in dev mode this is null, so we
enabledAddons[key] = await isFeatureEnabled(adminUser.brand_id, key); // fall back to the first brand in the brands table to keep the dashboard's
// "Active Products" stat in sync with the billing page.
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
const { data: firstBrand } = await supabase
.from("brands")
.select("id")
.limit(1)
.single();
if (firstBrand?.id) {
dashboardBrandId = firstBrand.id;
} }
const planResult = await getBrandPlanInfo(adminUser.brand_id); }
if (planResult.success && planResult.data) {
planTier = planResult.data.plan_tier ?? "starter"; if (dashboardBrandId) {
usage = planResult.data.usage ?? usage; // Use the centralized billing overview so the dashboard's "Active Products"
limits = { // stat agrees with the plan usage in /admin/settings/billing. Previously
max_users: planResult.data.max_users ?? 1, // these were derived from two different queries with different filters
max_stops_monthly: planResult.data.max_stops_monthly ?? 10, // (active=true vs deleted_at IS NULL), causing the "1 vs 0/25" mismatch.
max_products: planResult.data.max_products ?? 25, const overviewRes = await getBillingOverview(dashboardBrandId);
}; if (overviewRes.success && overviewRes.data) {
if (planResult.data.brand_name) brandDisplayName = planResult.data.brand_name; const o = overviewRes.data;
planTier = o.planTier;
usage = o.usage;
limits = o.limits;
if (o.brandName) brandDisplayName = o.brandName;
enabledAddons = o.enabledAddons;
} else {
// Fallback to per-feature flag check (matches prior behavior)
for (const key of [
"harvest_reach",
"wholesale_portal",
"water_log",
"ai_tools",
"sms_campaigns",
"square_sync",
"route_trace",
] as const) {
enabledAddons[key] = await isFeatureEnabled(dashboardBrandId, key);
}
} }
} }
+5 -4
View File
@@ -3,6 +3,7 @@ import ProductEditForm from "@/components/admin/ProductEditForm";
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 { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link";
type ProductDetailPageProps = { type ProductDetailPageProps = {
params: Promise<{ params: Promise<{
@@ -36,12 +37,12 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
<pre className="mt-4 rounded-xl bg-white p-4 text-sm text-stone-600"> <pre className="mt-4 rounded-xl bg-white p-4 text-sm text-stone-600">
{error?.message ?? "Product not found"} {error?.message ?? "Product not found"}
</pre> </pre>
<a <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-stone-500 hover:text-stone-700"
> >
Back to Products Back to Products
</a> </Link>
</div> </div>
</main> </main>
); );
@@ -50,12 +51,12 @@ 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 px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<a <Link
href="/admin/products" href="/admin/products"
className="text-sm text-stone-500 hover:text-stone-700" className="text-sm text-stone-500 hover:text-stone-700"
> >
Back to Products Back to Products
</a> </Link>
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50"> <div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
+6 -2
View File
@@ -106,8 +106,9 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
{/* Brand ID */} {/* Brand ID */}
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-stone-700">Brand ID</label> <label htmlFor="import-products-brand" className="block text-sm font-medium text-stone-700">Brand ID</label>
<input <input
id="import-products-brand"
type="text" type="text"
value={brandId} value={brandId}
onChange={(e) => setBrandId(e.target.value)} onChange={(e) => setBrandId(e.target.value)}
@@ -118,8 +119,9 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
{/* File upload */} {/* File upload */}
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50"> <div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
<label className="mb-2 block text-sm font-medium text-stone-700">Upload CSV</label> <label htmlFor="import-products-csv" className="mb-2 block text-sm font-medium text-stone-700">Upload CSV</label>
<input <input
id="import-products-csv"
ref={fileRef} ref={fileRef}
type="file" type="file"
accept=".csv" accept=".csv"
@@ -129,7 +131,9 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
<p className="mt-2 text-xs text-stone-500"> <p className="mt-2 text-xs text-stone-500">
Or paste CSV content below: Or paste CSV content below:
</p> </p>
<label htmlFor="import-products-text" className="sr-only">CSV text</label>
<textarea <textarea
id="import-products-text"
value={csvText} value={csvText}
onChange={(e) => setCsvText(e.target.value)} onChange={(e) => setCsvText(e.target.value)}
rows={6} rows={6}
+23 -4
View File
@@ -1,20 +1,33 @@
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 { getBrands } from "@/actions/admin/users";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link";
export default async function NewProductPage() { export default async function NewProductPage() {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser?.can_manage_products) redirect("/admin/pickup"); if (!adminUser?.can_manage_products) redirect("/admin/pickup");
// Resolve brand from the signed-in admin. For brand_admin / store_employee
// this is their assigned brand. For platform_admin (brand_id === null) we
// fetch the full brand list so they can choose.
const isPlatformAdmin = !adminUser.brand_id;
let brands: { id: string; name: string }[] = [];
if (isPlatformAdmin) {
const result = await getBrands();
brands = result.brands ?? [];
}
return ( return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}> <main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<div className="mb-8"> <div className="mb-8">
<a <Link
href="/admin/products" href="/admin/products"
className="text-sm text-stone-500 hover:text-stone-700" className="text-sm text-stone-500 hover:text-stone-700"
> >
Back to Products Back to Products
</a> </Link>
</div> </div>
<div className="rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50"> <div className="rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
@@ -23,10 +36,16 @@ export default async function NewProductPage() {
</h1> </h1>
<p className="mt-2 text-stone-500"> <p className="mt-2 text-stone-500">
Add a new product for Tuxedo Corn or Indian River Direct. {isPlatformAdmin
? "Add a new product to any brand you administer."
: "Add a new product to your brand's catalog."}
</p> </p>
<NewProductForm /> <NewProductForm
defaultBrandId={adminUser.brand_id ?? ""}
brands={brands}
lockBrand={!isPlatformAdmin}
/>
</div> </div>
</div> </div>
</main> </main>
+20 -4
View File
@@ -1,7 +1,9 @@
import { supabase } from "@/lib/supabase"; 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 ProductsClient from "@/components/admin/ProductsClient"; import ProductsClient from "@/components/admin/ProductsClient";
import { getBrands } from "@/actions/admin/users";
// Icon for page header // Icon for page header
const PackageIcon = () => ( const PackageIcon = () => (
@@ -29,7 +31,16 @@ export default async function AdminProductsPage() {
); );
} }
const brandId = adminUser.brand_id; const activeBrandId = await getActiveBrandId(adminUser);
const brandId = activeBrandId;
const isPlatformAdmin = adminUser.role === "platform_admin";
// Platform admins need a brand picker for new products
let brands: { id: string; name: string }[] = [];
if (isPlatformAdmin) {
const result = await getBrands();
brands = result.brands ?? [];
}
let query = supabase let query = supabase
.from("products") .from("products")
@@ -48,8 +59,8 @@ export default async function AdminProductsPage() {
.is("deleted_at", null) .is("deleted_at", null)
.order("name"); .order("name");
if (adminUser.brand_id) { if (brandId) {
query = query.eq("brand_id", adminUser.brand_id); query = query.eq("brand_id", brandId);
} }
const { data: products, error } = await query; const { data: products, error } = await query;
@@ -69,7 +80,12 @@ export default async function AdminProductsPage() {
return ( return (
<div className="min-h-screen bg-[var(--admin-bg)]"> <div className="min-h-screen bg-[var(--admin-bg)]">
<ProductsClient products={products ?? []} brandId={brandId ?? undefined} /> <ProductsClient
products={products ?? []}
brandId={brandId ?? undefined}
brands={brands}
isPlatformAdmin={isPlatformAdmin}
/>
</div> </div>
); );
} }
+2 -1
View File
@@ -1,5 +1,6 @@
import { supabase } from "@/lib/supabase"; 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 ReportsDashboard from "@/components/admin/ReportsDashboard"; import ReportsDashboard from "@/components/admin/ReportsDashboard";
import { PageHeader } from "@/components/admin/design-system"; import { PageHeader } from "@/components/admin/design-system";
@@ -19,7 +20,7 @@ export default async function ReportsPage() {
const initialBrandId = isPlatformAdmin const initialBrandId = isPlatformAdmin
? null ? null
: adminUser.brand_id ?? null; : await getActiveBrandId(adminUser);
return ( return (
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}> <main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
+3 -2
View File
@@ -5,6 +5,7 @@ import { isFeatureEnabled } from "@/lib/feature-flags";
import { getRouteTraceLotDetail, getLotOrders } from "@/actions/route-trace/lots"; import { getRouteTraceLotDetail, getLotOrders } from "@/actions/route-trace/lots";
import LotDetailPanel from "@/components/route-trace/LotDetailPanel"; import LotDetailPanel from "@/components/route-trace/LotDetailPanel";
import RouteTraceNav from "@/components/route-trace/RouteTraceNav"; import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
import Link from "next/link";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
@@ -45,7 +46,7 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-2xl">
<div className="rounded-xl border border-red-200 bg-white p-6 text-center"> <div className="rounded-xl border border-red-200 bg-white p-6 text-center">
<p className="text-red-600">Lot not found</p> <p className="text-red-600">Lot not found</p>
<a href="/admin/route-trace/lots" className="mt-3 inline-block text-sm text-stone-500 hover:text-stone-700"> Back to Lots</a> <Link href="/admin/route-trace/lots" className="mt-3 inline-block text-sm text-stone-500 hover:text-stone-700"> Back to Lots</Link>
</div> </div>
</div> </div>
</div> </div>
@@ -56,7 +57,7 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
<div className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}> <div className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-3xl"> <div className="mx-auto max-w-3xl">
<div className="mb-6"> <div className="mb-6">
<a href="/admin/route-trace/lots" className="text-sm text-stone-500 hover:text-stone-700"> Back to Lots</a> <Link href="/admin/route-trace/lots" className="text-sm text-stone-500 hover:text-stone-700"> Back to Lots</Link>
</div> </div>
<RouteTraceNav activeTab="lots" /> <RouteTraceNav activeTab="lots" />
<LotDetailPanel <LotDetailPanel
+6 -3
View File
@@ -90,8 +90,9 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-stone-600">Brand ID</label> <label htmlFor="import-orders-brand" className="block text-sm font-medium text-stone-600">Brand ID</label>
<input <input
id="import-orders-brand"
type="text" type="text"
value={brandId} value={brandId}
onChange={(e) => setBrandId(e.target.value)} onChange={(e) => setBrandId(e.target.value)}
@@ -101,10 +102,12 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
</div> </div>
<div className="mb-4 card p-6"> <div className="mb-4 card p-6">
<label className="block text-sm font-medium text-stone-600 mb-2">Upload CSV</label> <label htmlFor="import-orders-csv" className="block text-sm font-medium text-stone-600 mb-2">Upload CSV</label>
<input ref={fileRef} type="file" accept=".csv" onChange={handleFile} className="w-full text-sm text-stone-500" /> <input id="import-orders-csv" ref={fileRef} type="file" accept=".csv" onChange={handleFile} className="w-full text-sm text-stone-500" />
<p className="mt-2 text-xs text-stone-500">Paste CSV content below:</p> <p className="mt-2 text-xs text-stone-500">Paste CSV content below:</p>
<label htmlFor="import-orders-text" className="sr-only">CSV text</label>
<textarea <textarea
id="import-orders-text"
value={csvText} value={csvText}
onChange={(e) => setCsvText(e.target.value)} onChange={(e) => setCsvText(e.target.value)}
rows={6} rows={6}
+22 -12
View File
@@ -514,8 +514,9 @@ function CampaignWriterTool({ brandId, brandName }: { brandId: string; brandName
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>What do you want to communicate?</label> <label htmlFor="ai-cw-topic" className="block text-sm font-medium mb-1" style={labelStyle}>What do you want to communicate?</label>
<textarea <textarea
id="ai-cw-topic"
value={topic} value={topic}
onChange={(e) => setTopic(e.target.value)} onChange={(e) => setTopic(e.target.value)}
rows={3} rows={3}
@@ -589,20 +590,22 @@ function ProductWriterTool({ brandId }: { brandId: string }) {
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Product Name *</label> <label htmlFor="ai-pw-product-name" className="block text-sm font-medium mb-1" style={labelStyle}>
<input type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" className={inputBaseClass} style={inputStyle} /> Product Name <span className="text-red-400 ml-0.5" aria-hidden="true">*</span>
</label>
<input id="ai-pw-product-name" type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" required aria-required="true" className={inputBaseClass} style={inputStyle} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Category</label> <label htmlFor="ai-pw-category" className="block text-sm font-medium mb-1" style={labelStyle}>Category</label>
<input type="text" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Vegetables" className={inputBaseClass} style={inputStyle} /> <input id="ai-pw-category" type="text" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Vegetables" className={inputBaseClass} style={inputStyle} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Price</label> <label htmlFor="ai-pw-price" className="block text-sm font-medium mb-1" style={labelStyle}>Price</label>
<input type="text" value={price} onChange={(e) => setPrice(e.target.value)} placeholder="$4.50" className={inputBaseClass} style={inputStyle} /> <input id="ai-pw-price" type="text" value={price} onChange={(e) => setPrice(e.target.value)} placeholder="$4.50" className={inputBaseClass} style={inputStyle} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Unit</label> <label htmlFor="ai-pw-unit" className="block text-sm font-medium mb-1" style={labelStyle}>Unit</label>
<input type="text" value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="per dozen" className={inputBaseClass} style={inputStyle} /> <input id="ai-pw-unit" type="text" value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="per dozen" className={inputBaseClass} style={inputStyle} />
</div> </div>
</div> </div>
<button <button
@@ -694,8 +697,9 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
</p> </p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Report Type</label> <label htmlFor="ai-rep-type" className="block text-sm font-medium mb-1" style={labelStyle}>Report Type</label>
<select <select
id="ai-rep-type"
value={reportType} value={reportType}
onChange={(e) => setReportType(e.target.value)} onChange={(e) => setReportType(e.target.value)}
className={inputBaseClass} className={inputBaseClass}
@@ -707,8 +711,9 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Date Range</label> <label htmlFor="ai-rep-range" className="block text-sm font-medium mb-1" style={labelStyle}>Date Range</label>
<input <input
id="ai-rep-range"
type="text" type="text"
value={dateRange} value={dateRange}
onChange={(e) => setDateRange(e.target.value)} onChange={(e) => setDateRange(e.target.value)}
@@ -856,12 +861,17 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
Enter a product name and optional price tiers or historical sales data for AI-powered pricing recommendations. Enter a product name and optional price tiers or historical sales data for AI-powered pricing recommendations.
</p> </p>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Product Name *</label> <label htmlFor="ai-pa-product-name" className="block text-sm font-medium mb-1" style={labelStyle}>
Product Name <span className="text-red-400 ml-0.5" aria-hidden="true">*</span>
</label>
<input <input
id="ai-pa-product-name"
type="text" type="text"
value={productName} value={productName}
onChange={(e) => setProductName(e.target.value)} onChange={(e) => setProductName(e.target.value)}
placeholder="Sweet Corn" placeholder="Sweet Corn"
required
aria-required="true"
className={inputBaseClass} className={inputBaseClass}
style={inputStyle} style={inputStyle}
/> />
+19 -1
View File
@@ -1,9 +1,27 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import Link from "next/link";
export default async function AISettingsPage() { export default async function AISettingsPage() {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) redirect("/login"); if (!adminUser) redirect("/login");
redirect("/admin/settings#ai"); return (
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-4xl">
<Link href="/admin/settings" className="text-sm text-stone-500 hover:text-stone-700"> Back to Settings</Link>
<h1 className="mt-3 text-3xl font-bold tracking-tight">AI Intelligence Settings</h1>
<p className="mt-2 text-stone-600">Configure AI providers, keys, and preferences used by campaign writer, pricing advisor, report explainer, and other tools.</p>
<div className="mt-8 grid gap-4 md:grid-cols-2">
<Link href="/admin/settings/integrations" className="rounded-2xl border bg-white p-6 block hover:shadow">Manage AI Provider Keys (OpenAI, Anthropic, etc.) </Link>
<Link href="/admin/settings/apps" className="rounded-2xl border bg-white p-6 block hover:shadow">Enable / disable the AI Tools add-on </Link>
</div>
<div className="mt-6 text-sm text-stone-500">
The actual AI client is wired in <code>@/actions/integrations/ai-providers</code> and used by the various <code>/api/ai/*</code> endpoints and admin tools.
</div>
</div>
</main>
);
} }
@@ -1,88 +1,132 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import PlanUpgradeButton from "./PlanUpgradeButton"; import PlanUpgradeButton from "./PlanUpgradeButton";
import AddPaymentMethodButton from "./AddPaymentMethodButton"; import AddPaymentMethodButton from "./AddPaymentMethodButton";
import AddAddonButton from "./AddAddonButton"; import AddAddonButton from "./AddAddonButton";
import RemoveAddonButton from "./RemoveAddonButton"; import RemoveAddonButton from "./RemoveAddonButton";
import BillingCycleToggle from "./BillingCycleToggle"; import BillingCycleToggle from "./BillingCycleToggle";
import StripePortalButton from "./StripePortalButton"; import StripePortalButton from "./StripePortalButton";
import { PLAN_TIERS, ADDONS } from "@/lib/pricing"; import { PLAN_TIERS } from "@/lib/pricing";
import { AdminButton } from "@/components/admin/design-system"; import type { BillingOverview, BillingSubscriptionStatus } from "@/actions/billing/billing-overview";
type BillingCycle = "monthly" | "annual"; type BillingCycle = "monthly" | "annual";
type AddonData = { key: string; label: string; icon: string; monthlyPrice: number; annualPrice: number };
type Props = { type Props = {
brandId: string; overview: BillingOverview;
planTier: string;
brandName: string | null;
hasStripeCustomer: boolean;
enabledAddons: Record<string, boolean>;
isPlatformAdmin: boolean;
subscriptionStatus: string | null;
currentPeriodEnd: string | null;
}; };
export default function BillingClientPage({ /**
brandId, * Status badge class for the subscription state pill.
planTier, * Returns null for "none" so we can hide it entirely instead of showing
brandName, * a generic "Inactive" pill that contradicts the "Active" add-on badges.
hasStripeCustomer, */
enabledAddons, function subscriptionStatusBadgeClass(status: BillingSubscriptionStatus | null): {
isPlatformAdmin, label: string;
subscriptionStatus, cls: string;
currentPeriodEnd, show: boolean;
}: Props) { } {
const [billingCycle, setBillingCycle] = useState<BillingCycle>("annual"); if (!status || status === "none" || status === "inactive") {
return { label: "No active subscription", cls: "bg-stone-100 text-stone-600", show: true };
}
switch (status) {
case "active":
return { label: "Active", cls: "bg-[var(--admin-success)]/10 text-[var(--admin-success)]", show: true };
case "trialing":
return { label: "Trial", cls: "bg-blue-100 text-blue-600", show: true };
case "past_due":
return { label: "Past Due", cls: "bg-amber-100 text-amber-700", show: true };
case "canceled":
return { label: "Canceled", cls: "bg-red-100 text-red-600", show: true };
case "incomplete":
return { label: "Incomplete", cls: "bg-amber-100 text-amber-700", show: true };
case "unpaid":
return { label: "Unpaid", cls: "bg-red-100 text-red-600", show: true };
default:
return { label: String(status), cls: "bg-stone-100 text-stone-600", show: true };
}
}
function formatPeriodEnd(iso: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
if (isNaN(d.getTime())) return null;
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
}
export default function BillingClientPage({ overview }: Props) {
const [billingCycle, setBillingCycle] = useState<BillingCycle>(overview.planCycle);
const [compareOpen, setCompareOpen] = useState(false); const [compareOpen, setCompareOpen] = useState(false);
const currentPlan = PLAN_TIERS[planTier as keyof typeof PLAN_TIERS] ?? PLAN_TIERS.starter; const {
const planMonthly = billingCycle === "annual" brandId,
? Math.round((currentPlan.annualPrice ?? 0) / 12) planTier,
: (currentPlan.monthlyPrice ?? 0); hasStripeCustomer,
hasActiveSubscription,
subscriptionStatus,
currentPeriodEnd,
isPlatformAdmin,
addons,
limits,
usage,
} = overview;
const enabledAddonsList: AddonData[] = Object.entries(enabledAddons) const currentPlan = PLAN_TIERS[planTier] ?? PLAN_TIERS.starter;
.filter(([, v]) => v)
.map(([key]) => {
const a = ADDONS[key as keyof typeof ADDONS];
return a ? { key, label: a.label, icon: a.icon, monthlyPrice: a.monthlyPrice, annualPrice: a.annualPrice } : null;
})
.filter(Boolean) as AddonData[];
const allAddonKeys = Object.keys(ADDONS) as Array<keyof typeof ADDONS>; // ── Pricing math (driven solely by billingCycle toggle) ────────────────────
const { planDisplayPrice, addonsMonthlyTotal, addonsAnnualTotal, displayedTotal, planDisplayLabel } =
useMemo(() => {
const planDisplayPrice =
billingCycle === "annual" ? currentPlan.annualPrice : currentPlan.monthlyPrice;
const planDisplayLabel = billingCycle === "annual" ? "/yr" : "/mo";
const addonsMonthlyTotal = enabledAddonsList.reduce((sum, a) => { // Add-ons that are "live" (subscription active) — only these contribute
return sum + (billingCycle === "annual" ? Math.round(a.annualPrice / 12) : a.monthlyPrice); // to displayed totals. Inactive add-ons would inflate the number.
}, 0); const liveAddons = addons.filter((a) => a.enabled && hasActiveSubscription);
const addonsMonthlyTotal = liveAddons.reduce((s, a) => s + a.monthlyPrice, 0);
const addonsAnnualTotal = liveAddons.reduce((s, a) => s + a.annualPrice, 0);
const totalMonthly = planMonthly + addonsMonthlyTotal; const displayedTotal =
billingCycle === "annual"
? planDisplayPrice + addonsAnnualTotal
: planDisplayPrice + addonsMonthlyTotal;
const annualSavings = enabledAddonsList.reduce( return { planDisplayPrice, addonsMonthlyTotal, addonsAnnualTotal, displayedTotal, planDisplayLabel };
(s, a) => s + (a.monthlyPrice * 12 - a.annualPrice), }, [billingCycle, currentPlan, addons, hasActiveSubscription]);
(billingCycle === "annual" ? (currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0) : 0)
);
const getStatusBadgeClass = (status: string | null) => { const monthlyEquivalent =
if (!status) return "bg-stone-100 text-stone-600"; billingCycle === "annual"
switch (status) { ? Math.round(displayedTotal / 12)
case "active": : displayedTotal;
return "bg-[var(--admin-success)]/10 text-[var(--admin-success)]";
case "past_due":
return "bg-amber-100 text-amber-700";
case "canceled":
return "bg-red-100 text-red-600";
case "trialing":
return "bg-blue-100 text-blue-600";
default:
return "bg-stone-100 text-stone-600";
}
};
const statusBadge = subscriptionStatusBadgeClass(subscriptionStatus);
const periodEndLabel = formatPeriodEnd(currentPeriodEnd);
// ── Invoice data (synthesized, since we have no invoice table) ─────────────
// Amounts always equal the displayed total for the selected cycle so the
// user never sees an invoice that disagrees with the plan above.
const placeholderInvoices = useMemo(() => {
const today = new Date();
return [3, 2, 1].map((monthsAgo) => {
const d = new Date(today);
d.setMonth(d.getMonth() - monthsAgo);
const dateLabel = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
return {
id: `INV-${d.getFullYear()}-${String(d.getMonth() + 1).padStart(3, "0")}`,
date: dateLabel,
amount: displayedTotal,
status: "paid" as const,
// Until we wire a real billing_invoices table we mark them as samples
// so users don't mistake them for authentic records.
isSample: !hasActiveSubscription,
};
});
}, [displayedTotal, hasActiveSubscription]);
// ── Render ─────────────────────────────────────────────────────────────────
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* ── 1. Header summary bar ───────────────────────────────────────────── */} {/* ── 1. Header summary bar ─────────────────────────────────────────── */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] px-6 py-6"> <div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] px-6 py-6">
<div className="flex items-center justify-between flex-wrap gap-6"> <div className="flex items-center justify-between flex-wrap gap-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -91,18 +135,25 @@ export default function BillingClientPage({
<span className={`rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${currentPlan.color}`}> <span className={`rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${currentPlan.color}`}>
{currentPlan.label} {currentPlan.label}
</span> </span>
{subscriptionStatus && ( {statusBadge.show && (
<span className={`rounded-full px-2.5 py-0.5 text-xs font-bold uppercase ${getStatusBadgeClass(subscriptionStatus)}`}> <span className={`rounded-full px-2.5 py-0.5 text-xs font-bold uppercase ${statusBadge.cls}`}>
{subscriptionStatus.replace("_", " ")} {statusBadge.label}
</span>
)}
{hasActiveSubscription && periodEndLabel && (
<span className="text-xs text-[var(--admin-text-muted)]">
renews {periodEndLabel}
</span> </span>
)} )}
</div> </div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<p className="text-3xl font-bold text-[var(--admin-text-primary)]"> <p className="text-3xl font-bold text-[var(--admin-text-primary)]">
${totalMonthly} {hasActiveSubscription ? `$${displayedTotal}` : `$${currentPlan.monthlyPrice}`}
<span className="text-base font-normal text-[var(--admin-text-muted)]">/mo</span> <span className="text-base font-normal text-[var(--admin-text-muted)]">
{hasActiveSubscription ? planDisplayLabel : "/mo"}
</span>
</p> </p>
{billingCycle === "annual" && ( {hasActiveSubscription && billingCycle === "annual" && (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-success)]/10 px-2.5 py-0.5 text-xs font-medium text-[var(--admin-success)]"> <span className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-success)]/10 px-2.5 py-0.5 text-xs font-medium text-[var(--admin-success)]">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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" /> <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" />
@@ -112,10 +163,21 @@ export default function BillingClientPage({
)} )}
</div> </div>
<p className="text-sm text-[var(--admin-text-muted)] mt-1"> <p className="text-sm text-[var(--admin-text-muted)] mt-1">
{currentPeriodEnd ? ( {hasActiveSubscription ? (
<>Next billing: <span className="font-medium text-[var(--admin-text-primary)]">{new Date(currentPeriodEnd).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</span></> <>
{addons.filter((a) => a.enabled).length > 0 ? (
<>Includes plan + {addons.filter((a) => a.enabled).length} active add-on{addons.filter((a) => a.enabled).length === 1 ? "" : "s"}</>
) : (
<>Base plan only · no add-ons</>
)}
{billingCycle === "annual" && (
<> · ${monthlyEquivalent}/mo equivalent</>
)}
</>
) : hasStripeCustomer ? (
<>No active subscription. Add-ons will not bill until you subscribe to a plan.</>
) : ( ) : (
"No active subscription" <>No active subscription. Add a payment method to start.</>
)} )}
</p> </p>
</div> </div>
@@ -124,7 +186,7 @@ export default function BillingClientPage({
</div> </div>
</div> </div>
{/* ── 2. Current plan + Add-ons (two-column) ───────────────────────────── */} {/* ── 2. Current plan + Add-ons (two-column) ────────────────────────── */}
<div className="grid gap-4 lg:grid-cols-5"> <div className="grid gap-4 lg:grid-cols-5">
{/* Current plan card */} {/* Current plan card */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6 lg:col-span-2"> <div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6 lg:col-span-2">
@@ -135,12 +197,18 @@ export default function BillingClientPage({
</span> </span>
</div> </div>
<p className="text-xl font-bold text-[var(--admin-text-primary)] mb-1"> <p className="text-xl font-bold text-[var(--admin-text-primary)] mb-1">
{billingCycle === "annual" ? `$${currentPlan.annualPrice}/yr` : `$${currentPlan.monthlyPrice}/mo`} {hasActiveSubscription
? `$${planDisplayPrice}${planDisplayLabel}`
: `$${currentPlan.monthlyPrice}/mo`}
</p> </p>
<p className="text-xs text-[var(--admin-text-muted)] mb-5"> <p className="text-xs text-[var(--admin-text-muted)] mb-5">
{billingCycle === "annual" && currentPlan.annualPrice {hasActiveSubscription ? (
? `$${Math.round(currentPlan.annualPrice / 12)}/mo equivalent — saves $${Math.round(((currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0)))}/yr` billingCycle === "annual" && currentPlan.annualPrice
: "billed monthly"} ? `$${Math.round(currentPlan.annualPrice / 12)}/mo equivalent — saves $${Math.round(((currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0)))}/yr`
: "billed monthly"
) : (
"Not yet subscribed"
)}
</p> </p>
<ul className="space-y-2 mb-5"> <ul className="space-y-2 mb-5">
{(currentPlan.features as readonly string[]).slice(0, 6).map((f, i) => ( {(currentPlan.features as readonly string[]).slice(0, 6).map((f, i) => (
@@ -173,19 +241,45 @@ export default function BillingClientPage({
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div> <div>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Add-ons</h3> <h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Add-ons</h3>
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">Extend your plan with optional features</p> <p className="text-xs text-[var(--admin-text-muted)] mt-0.5">
{hasActiveSubscription
? "Extend your plan with optional features"
: "Add a subscription to enable add-ons"}
</p>
</div> </div>
<span className="text-sm font-semibold text-[var(--admin-text-secondary)]">+${addonsMonthlyTotal}/mo</span> <span className="text-sm font-semibold text-[var(--admin-text-secondary)]">
{hasActiveSubscription
? `+$${billingCycle === "annual" ? Math.round(addonsAnnualTotal / 12) : addonsMonthlyTotal}/mo`
: "—"}
</span>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{allAddonKeys.map((key) => { {addons.map((addon) => {
const addon = ADDONS[key]; const displayPrice =
const isEnabled = !!enabledAddons[key]; billingCycle === "annual"
const displayPrice = billingCycle === "annual" ? Math.round(addon.annualPrice / 12)
? Math.round(addon.annualPrice / 12) : addon.monthlyPrice;
: addon.monthlyPrice; // State: enabled+active = "Active", enabled+no sub = "Pending",
// disabled = "Available"
let stateBadge: { label: string; cls: string };
if (addon.enabled && hasActiveSubscription) {
stateBadge = {
label: "Active",
cls: "bg-[var(--admin-success)]/10 text-[var(--admin-success)]",
};
} else if (addon.enabled && !hasActiveSubscription) {
stateBadge = {
label: "Pending",
cls: "bg-amber-100 text-amber-700",
};
} else {
stateBadge = {
label: "Available",
cls: "bg-stone-100 text-stone-600",
};
}
return ( return (
<div key={key} className="flex items-center justify-between rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg)] px-4 py-3"> <div key={addon.key} className="flex items-center justify-between rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg)] px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xl leading-none">{addon.icon}</span> <span className="text-xl leading-none">{addon.icon}</span>
<div> <div>
@@ -195,18 +289,29 @@ export default function BillingClientPage({
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<span className="text-sm font-semibold text-[var(--admin-text-secondary)]">+${displayPrice}/mo</span> <span className="text-sm font-semibold text-[var(--admin-text-secondary)]">+${displayPrice}/mo</span>
{isEnabled ? ( <span className={`inline-flex items-center gap-1 rounded-full text-xs px-2.5 py-1 font-medium ${stateBadge.cls}`}>
<div className="flex items-center gap-2"> <span
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-success)]/10 text-[var(--admin-success)] text-xs px-2.5 py-1 font-medium"> className={`w-1.5 h-1.5 rounded-full ${
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" /> stateBadge.label === "Active"
Active ? "bg-[var(--admin-success)]"
</span> : stateBadge.label === "Pending"
{isPlatformAdmin && ( ? "bg-amber-500"
<RemoveAddonButton brandId={brandId} addonKey={key} onRemoved={() => window.location.reload()} /> : "bg-stone-400"
)} }`}
</div> />
{stateBadge.label}
</span>
{addon.removable ? (
<RemoveAddonButton brandId={brandId} addonKey={addon.key} onRemoved={() => window.location.reload()} />
) : hasActiveSubscription ? (
<AddAddonButton brandId={brandId} addonKey={addon.key} />
) : ( ) : (
<AddAddonButton brandId={brandId} addonKey={key} /> <span
className="text-xs text-[var(--admin-text-muted)]"
title="Subscribe to a plan first"
>
</span>
)} )}
</div> </div>
</div> </div>
@@ -216,7 +321,7 @@ export default function BillingClientPage({
</div> </div>
</div> </div>
{/* ── 3. Compare plans (collapsible) ───────────────────────────────────── */} {/* ── 3. Compare plans (collapsible) ──────────────────────────────── */}
{compareOpen && isPlatformAdmin && ( {compareOpen && isPlatformAdmin && (
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] overflow-hidden"> <div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] overflow-hidden">
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-bg)] px-6 py-4 flex items-center justify-between"> <div className="border-b border-[var(--admin-border)] bg-[var(--admin-bg)] px-6 py-4 flex items-center justify-between">
@@ -334,7 +439,7 @@ export default function BillingClientPage({
</div> </div>
)} )}
{/* ── 4. Payment + Invoices (two-column) ──────────────────────────────── */} {/* ── 4. Payment + Invoices (two-column) ───────────────────────────── */}
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
{/* Payment method */} {/* Payment method */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6"> <div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6">
@@ -344,16 +449,30 @@ export default function BillingClientPage({
<div className="flex items-center gap-3 rounded-xl border border-[var(--admin-border)] p-4"> <div className="flex items-center gap-3 rounded-xl border border-[var(--admin-border)] p-4">
<div className="flex items-center justify-center h-10 w-12 rounded-lg bg-gradient-to-br from-blue-600 to-violet-600 shrink-0"> <div className="flex items-center justify-center h-10 w-12 rounded-lg bg-gradient-to-br from-blue-600 to-violet-600 shrink-0">
<svg className="h-5 w-8 text-white" viewBox="0 0 32 20" fill="currentColor"> <svg className="h-5 w-8 text-white" viewBox="0 0 32 20" fill="currentColor">
<path d="M13.193 7.448c-.114-.272-.379-.358-.65-.358-.27 0-.535.09-.65.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .534-.09.648-.357.13-.28.115-.561-.115-.837-.237-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001.001-.001-.001c-.114.267-.379.357-.65.357-.27 0-.536-.09-.65-.357-.13-.277-.114-.558.115-.838.236-.286.536-.357.806-.357.271 0 .536.09.65.357l.001-.001c.115-.272.38-.358.65-.358.271 0 .536.09.65.357l.001.001c.122.28.122.56-.008.838zm8.697-.001c-.122-.276-.379-.357-.65-.357-.27 0-.535.09-.649.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .535-.09.65-.357.13-.28.114-.561-.115-.837-.238-.286-.536-.357-.807-.357-.27 0-.535.09-.65.357l-.001-.001-.001.001c-.114-.267-.379-.357-.65-.357-.27 0-.536.09-.649.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .535-.09.649-.357.13-.277.114-.558-.115-.838-.236-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001.001-.001-.001c-.114.267-.379.357-.65.357-.271 0-.536-.09-.65-.357-.13-.276-.379-.357-.65-.357-.271 0-.536.09-.649.357-.13.277-.114.558.115.838.236.286.536.357.806.357.271 0 .536-.09.65-.357.13-.277.114-.558-.115-.838-.237-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001-.001c-.114.267-.379.357-.65.357-.271 0-.536-.09-.65-.357-.13-.277-.114-.558.115-.838.236-.286.535.357.806-.357.27 0 .535.09.65.357l.001.001c.122.28.122.56-.008.838z"/> <path d="M13.193 7.448c-.114-.272-.379-.358-.65-.358-.27 0-.535.09-.65.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .534-.09.648-.357.13-.28.115-.561-.115-.837-.237-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001.001-.001-.001c-.114.267-.379.357-.65.357-.27 0-.536-.09-.65-.357-.13-.277-.114-.558.115-.838.236-.286.536-.357.806-.357.271 0 .536.09.65.357l.001-.001c.115-.272.38-.358.65-.358.271 0 .536.09.65.357l.001.001c.122.28.122.56-.008.838z" />
</svg> </svg>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Visa ending in 4242</p> <p className="text-sm font-medium text-[var(--admin-text-primary)]">Card on file</p>
<p className="text-xs text-[var(--admin-text-muted)]">Expires 12/26</p> <p className="text-xs text-[var(--admin-text-muted)]">
{hasActiveSubscription
? "Billed automatically per the cycle you selected"
: "On file — no active subscription yet"}
</p>
</div> </div>
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--admin-success)]/10 text-[var(--admin-success)] text-xs px-2.5 py-1 font-medium shrink-0"> <span
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" /> className={`inline-flex items-center gap-1.5 rounded-full text-xs px-2.5 py-1 font-medium shrink-0 ${
Active hasActiveSubscription
? "bg-[var(--admin-success)]/10 text-[var(--admin-success)]"
: "bg-amber-100 text-amber-700"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
hasActiveSubscription ? "bg-[var(--admin-success)]" : "bg-amber-500"
}`}
/>
{hasActiveSubscription ? "Active" : "On File"}
</span> </span>
</div> </div>
<StripePortalButton brandId={brandId} variant="secondary" label="Manage in Stripe Portal" /> <StripePortalButton brandId={brandId} variant="secondary" label="Manage in Stripe Portal" />
@@ -389,6 +508,11 @@ export default function BillingClientPage({
{/* Invoice history */} {/* Invoice history */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6"> <div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)] mb-4">Invoice History</h3> <h3 className="text-sm font-semibold text-[var(--admin-text-primary)] mb-4">Invoice History</h3>
{!hasActiveSubscription && (
<p className="text-xs text-[var(--admin-text-muted)] mb-3 italic">
No subscription on file yet. The sample rows below show the price you would have been billed.
</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
@@ -397,30 +521,32 @@ export default function BillingClientPage({
<th className="pb-3 text-left font-semibold text-[var(--admin-text-muted)]">Date</th> <th className="pb-3 text-left font-semibold text-[var(--admin-text-muted)]">Date</th>
<th className="pb-3 text-right font-semibold text-[var(--admin-text-muted)]">Amount</th> <th className="pb-3 text-right font-semibold text-[var(--admin-text-muted)]">Amount</th>
<th className="pb-3 text-right font-semibold text-[var(--admin-text-muted)]">Status</th> <th className="pb-3 text-right font-semibold text-[var(--admin-text-muted)]">Status</th>
<th className="pb-3"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--admin-border)]"> <tbody className="divide-y divide-[var(--admin-border)]">
{[ {placeholderInvoices.map((inv) => (
{ id: "INV-2026-004", date: "May 1, 2026", amount: totalMonthly, status: "paid" },
{ id: "INV-2026-003", date: "Apr 1, 2026", amount: totalMonthly, status: "paid" },
{ id: "INV-2026-002", date: "Mar 1, 2026", amount: totalMonthly, status: "paid" },
].map((inv) => (
<tr key={inv.id}> <tr key={inv.id}>
<td className="py-3 font-medium text-[var(--admin-text-primary)]">{inv.id}</td> <td className="py-3 font-medium text-[var(--admin-text-primary)]">
{inv.id}
{inv.isSample && (
<span className="ml-2 inline-block text-[10px] uppercase tracking-wide text-[var(--admin-text-muted)]">
sample
</span>
)}
</td>
<td className="py-3 text-[var(--admin-text-muted)]">{inv.date}</td> <td className="py-3 text-[var(--admin-text-muted)]">{inv.date}</td>
<td className="py-3 text-right font-semibold text-[var(--admin-text-primary)]">${inv.amount}</td> <td className="py-3 text-right font-semibold text-[var(--admin-text-primary)]">${inv.amount}</td>
<td className="py-3 text-right"> <td className="py-3 text-right">
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-success)]/10 text-[var(--admin-success)] px-2 py-0.5 text-xs font-medium capitalize"> <span
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium capitalize ${
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> inv.isSample
</svg> ? "bg-stone-100 text-stone-600"
{inv.status} : "bg-[var(--admin-success)]/10 text-[var(--admin-success)]"
}`}
>
{inv.isSample ? "—" : inv.status}
</span> </span>
</td> </td>
<td className="py-3 text-right">
<button className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-accent)] transition-colors">PDF</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -431,6 +557,21 @@ export default function BillingClientPage({
</p> </p>
</div> </div>
</div> </div>
{/* Usage footer (small) — keep usage in sync with /admin dashboard */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] px-6 py-4">
<div className="flex items-center justify-between flex-wrap gap-3 text-sm">
<div className="flex items-center gap-4 flex-wrap">
<span className="text-[var(--admin-text-muted)]">Current usage:</span>
<span><strong>{usage.users}</strong>/{limits.max_users} users</span>
<span><strong>{usage.stops_this_month}</strong>/{limits.max_stops_monthly === -1 ? "∞" : limits.max_stops_monthly} stops this month</span>
<span><strong>{usage.products}</strong>/{limits.max_products === -1 ? "∞" : limits.max_products} products</span>
</div>
<span className="text-xs text-[var(--admin-text-muted)]">
Mirrors your admin dashboard
</span>
</div>
</div>
</div> </div>
); );
} }
+27 -30
View File
@@ -1,6 +1,7 @@
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getBrandPlanInfo, getEnabledAddons } from "@/actions/billing/stripe-portal"; import { getActiveBrandId } from "@/lib/brand-scope";
import { getBillingOverview } from "@/actions/billing/billing-overview";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import BillingClientPage from "./BillingClientPage"; import BillingClientPage from "./BillingClientPage";
@@ -13,7 +14,11 @@ export default async function BillingPage({ params }: Props) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />; if (!adminUser) return <AdminAccessDenied />;
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? ""; const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied message="You don't have access to that brand." />;
}
const effectiveBrandId = activeBrandId ?? "";
const isPlatformAdmin = adminUser.role === "platform_admin"; const isPlatformAdmin = adminUser.role === "platform_admin";
let resolvedBrandId = effectiveBrandId; let resolvedBrandId = effectiveBrandId;
@@ -49,24 +54,25 @@ export default async function BillingPage({ params }: Props) {
if (!resolvedBrandId) return <AdminAccessDenied />; if (!resolvedBrandId) return <AdminAccessDenied />;
const [planResult, addons] = await Promise.all([ // Single source of truth for everything the billing client needs.
getBrandPlanInfo(resolvedBrandId), const overviewRes = await getBillingOverview(resolvedBrandId);
getEnabledAddons(resolvedBrandId), if (!overviewRes.success || !overviewRes.data) {
]); return (
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
<div className="mx-auto max-w-6xl">
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-8 text-center">
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">Billing Unavailable</h1>
<p className="mt-2 text-[var(--admin-text-muted)]">{overviewRes.error ?? "Could not load billing information."}</p>
<a href="/admin" className="mt-4 inline-block rounded-xl bg-[var(--admin-accent)] hover:bg-[var(--admin-accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors">
Back to Admin
</a>
</div>
</div>
</main>
);
}
const planData = (planResult.success && planResult.data && typeof planResult.data === "object") const overview = overviewRes.data;
? planResult.data as Record<string, any>
: {} as Record<string, any>;
const planTier = planData.plan_tier ?? "starter";
const { data: brand } = await supabase
.from("brands")
.select("name, stripe_customer_id, stripe_subscription_id, stripe_subscription_status, stripe_current_period_end")
.eq("id", resolvedBrandId)
.single();
const hasStripeCustomer = !!brand?.stripe_customer_id;
return ( return (
<main className="min-h-screen bg-[var(--admin-bg)]"> <main className="min-h-screen bg-[var(--admin-bg)]">
@@ -94,20 +100,11 @@ export default async function BillingPage({ params }: Props) {
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold text-[var(--admin-text-primary)]">Billing &amp; Subscription</h1> <h1 className="text-3xl font-bold text-[var(--admin-text-primary)]">Billing &amp; Subscription</h1>
<p className="mt-1 text-[var(--admin-text-muted)]"> <p className="mt-1 text-[var(--admin-text-muted)]">
Manage your Route Commerce subscription for {brand?.name ?? "your brand"}. Manage your Route Commerce subscription for {overview.brandName ?? "your brand"}.
</p> </p>
</div> </div>
<BillingClientPage <BillingClientPage overview={overview} />
brandId={resolvedBrandId}
planTier={planTier}
brandName={brand?.name ?? null}
hasStripeCustomer={hasStripeCustomer}
enabledAddons={addons}
isPlatformAdmin={isPlatformAdmin}
subscriptionStatus={brand?.stripe_subscription_status ?? null}
currentPeriodEnd={brand?.stripe_current_period_end ?? null}
/>
</div> </div>
</main> </main>
); );
@@ -11,6 +11,7 @@ type CredentialField = {
label: string; label: string;
placeholder: string; placeholder: string;
isSecret?: boolean; isSecret?: boolean;
required?: boolean;
}; };
type SyncOption = { type SyncOption = {
@@ -346,22 +347,38 @@ function IntegrationCard({
{/* Credentials */} {/* Credentials */}
<div className="space-y-3 mb-5"> <div className="space-y-3 mb-5">
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Credentials</p> <p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Credentials</p>
{integration.credentials.map((field) => ( {integration.credentials.map((field) => {
<div key={field.key}> // Only mask the field as password when explicitly marked as a secret
<label className="block text-xs font-medium text-zinc-400 mb-1">{field.label}</label> // (e.g. API keys, tokens). Plain identifiers (emails, phone numbers,
<div className="relative"> // IDs) stay readable so admins can verify what they entered.
<input const inputId = `integration-${integration.id}-${field.key}`;
type={showSecrets[field.key] ? "text" : "password"} const inputType = field.isSecret
value={credentials[field.key] ?? ""} ? showSecrets[field.key]
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))} ? "text"
placeholder={field.placeholder} : "password"
className="w-full rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm pr-16 text-zinc-100 outline-none focus:border-violet-500" : (field as { type?: string }).type ?? "text";
/> return (
<div key={field.key}>
<label htmlFor={inputId} className="block text-xs font-medium text-zinc-400 mb-1">
{field.label}
{field.required && <span className="text-red-400 ml-1" aria-hidden="true">*</span>}
</label>
<div className="relative">
<input
id={inputId}
type={inputType}
value={credentials[field.key] ?? ""}
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
placeholder={field.placeholder}
aria-required={field.required ? "true" : undefined}
className="w-full rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm pr-16 text-zinc-100 outline-none focus:border-violet-500"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1"> <div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
{field.isSecret && ( {field.isSecret && (
<button <button
type="button" type="button"
onClick={() => toggleSecret(field.key)} onClick={() => toggleSecret(field.key)}
aria-label={showSecrets[field.key] ? `Hide ${field.label}` : `Show ${field.label}`}
className="text-xs text-zinc-500 hover:text-zinc-300 px-1" className="text-xs text-zinc-500 hover:text-zinc-300 px-1"
> >
{showSecrets[field.key] ? "Hide" : "Show"} {showSecrets[field.key] ? "Hide" : "Show"}
@@ -370,7 +387,8 @@ function IntegrationCard({
</div> </div>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
{/* Environment toggle */} {/* Environment toggle */}
@@ -123,6 +123,7 @@ function IntegrationCard({
// Track dirty state // Track dirty state
useEffect(() => { useEffect(() => {
const hasValues = Object.values(credentials).some((v) => v.trim().length > 0); const hasValues = Object.values(credentials).some((v) => v.trim().length > 0);
// eslint-disable-next-line react-hooks/set-state-in-effect
setDirty(hasValues && JSON.stringify(initialCredentials) !== JSON.stringify(credentials)); setDirty(hasValues && JSON.stringify(initialCredentials) !== JSON.stringify(credentials));
}, [credentials, initialCredentials]); }, [credentials, initialCredentials]);
@@ -292,7 +293,7 @@ function IntegrationCard({
> >
<div className="relative"> <div className="relative">
<AdminTextInput <AdminTextInput
type={showSecrets[field.key] ? "text" : "password"} type={field.isSecret && !showSecrets[field.key] ? "password" : "text"}
value={credentials[field.key] ?? ""} value={credentials[field.key] ?? ""}
onChange={(e) => updateCredential(field.key, e.target.value)} onChange={(e) => updateCredential(field.key, e.target.value)}
placeholder={field.placeholder} placeholder={field.placeholder}
@@ -4,6 +4,10 @@ import { getPaymentSettings } from "@/actions/payments";
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui"; import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
import SquareSyncSettingsClient from "./SquareSyncSettingsClient"; import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
// Uses cookies() via getAdminUser — must be dynamic to avoid the
// "couldn't be rendered statically" build error.
export const dynamic = "force-dynamic";
export default async function SquareSyncSettingsPage() { export default async function SquareSyncSettingsPage() {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) redirect("/login"); if (!adminUser) redirect("/login");
+5 -4
View File
@@ -5,6 +5,7 @@ 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 { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link";
type StopDetailPageProps = { type StopDetailPageProps = {
params: Promise<{ params: Promise<{
@@ -44,12 +45,12 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600"> <pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
{error?.message ?? "Stop not found"} {error?.message ?? "Stop not found"}
</pre> </pre>
<a <Link
href="/admin/stops" href="/admin/stops"
className="mt-4 inline-block text-stone-500 hover:text-stone-700" className="mt-4 inline-block text-stone-500 hover:text-stone-700"
> >
Back to Stops Back to Stops
</a> </Link>
</div> </div>
</main> </main>
); );
@@ -74,12 +75,12 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
return ( return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}> <main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<a <Link
href="/admin/stops" href="/admin/stops"
className="text-sm text-stone-500 hover:text-stone-700" className="text-sm text-stone-500 hover:text-stone-700"
> >
Back to Stops Back to Stops
</a> </Link>
<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-stone-200 shadow-lg">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
+3 -2
View File
@@ -4,6 +4,7 @@ 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 { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link";
type Stop = { type Stop = {
city: string; city: string;
@@ -54,12 +55,12 @@ export default async function NewStopPage({
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}> <main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<div className="mb-8"> <div className="mb-8">
<a <Link
href="/admin/stops" href="/admin/stops"
className="text-sm text-stone-500 hover:text-stone-700" className="text-sm text-stone-500 hover:text-stone-700"
> >
Back to Stops Back to Stops
</a> </Link>
</div> </div>
<div className="rounded-2xl bg-white p-8 border border-stone-200 shadow-lg"> <div className="rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
+2 -4
View File
@@ -1,4 +1,4 @@
import StopTableClient from "@/components/admin/StopTableClient"; import StopsDashboardClient from "@/components/admin/stops/StopsDashboardClient";
import StopsHeaderActions from "@/components/admin/StopsHeaderActions"; import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
@@ -90,9 +90,7 @@ export default async function AdminStopsPage() {
{/* 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="overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm"> <StopsDashboardClient stops={stops ?? []} />
<StopTableClient stops={stops ?? []} />
</div>
</div> </div>
</main> </main>
); );
+6 -1
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import TaxDashboard from "@/components/admin/TaxDashboard"; import TaxDashboard from "@/components/admin/TaxDashboard";
@@ -15,7 +16,11 @@ export default async function TaxesPage({ params }: Props) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />; if (!adminUser) return <AdminAccessDenied />;
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? ""; const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied message="You don't have access to that brand." />;
}
const effectiveBrandId = activeBrandId ?? "";
const isPlatformAdmin = adminUser.role === "platform_admin"; const isPlatformAdmin = adminUser.role === "platform_admin";
let resolvedBrandId = effectiveBrandId; let resolvedBrandId = effectiveBrandId;
@@ -7,6 +7,7 @@ import {
createWaterHeadgate, createWaterHeadgate,
regenerateHeadgateToken, regenerateHeadgateToken,
updateWaterHeadgate, updateWaterHeadgate,
deleteWaterHeadgate,
} from "@/actions/water-log/admin"; } from "@/actions/water-log/admin";
import { AdminButton } from "@/components/admin/design-system"; import { AdminButton } from "@/components/admin/design-system";
@@ -118,6 +119,24 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
} }
} }
const [deletingId, setDeletingId] = useState<string | null>(null);
async function handleDelete(hg: Headgate) {
if (!window.confirm(`Delete headgate "${hg.name}"? Existing log entries will be preserved.`)) return;
setDeletingId(hg.id);
const res = await deleteWaterHeadgate(hg.id);
if (res.success) {
// Optimistic update + refresh
setHeadgates((prev) => prev.filter((h) => h.id !== hg.id));
const refreshed = await getWaterHeadgatesAdmin(brandId);
setHeadgates(refreshed);
showToast("Headgate deleted");
} else {
showToast(res.error ?? "Failed to delete headgate", false);
}
setDeletingId(null);
}
function toggleSelect(id: string) { function toggleSelect(id: string) {
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -203,8 +222,9 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5"> <div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<form onSubmit={handleAdd} className="flex gap-3 items-end"> <form onSubmit={handleAdd} className="flex gap-3 items-end">
<div className="flex-1"> <div className="flex-1">
<label className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Headgate Name</label> <label htmlFor="headgate-add-name" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Headgate Name</label>
<input <input
id="headgate-add-name"
autoFocus autoFocus
type="text" type="text"
value={newName} value={newName}
@@ -212,11 +232,13 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
placeholder="e.g. North Field Gate 1" placeholder="e.g. North Field Gate 1"
className="w-full rounded-xl border border-[var(--admin-border)] px-4 py-3 text-sm outline-none focus:border-[var(--admin-accent)]" className="w-full rounded-xl border border-[var(--admin-border)] px-4 py-3 text-sm outline-none focus:border-[var(--admin-accent)]"
required required
aria-required="true"
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Unit</label> <label htmlFor="headgate-add-unit" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Unit</label>
<select <select
id="headgate-add-unit"
value={newUnit} value={newUnit}
onChange={(e) => setNewUnit(e.target.value)} onChange={(e) => setNewUnit(e.target.value)}
className="rounded-xl border border-[var(--admin-border)] px-3 py-3 text-sm outline-none focus:border-[var(--admin-accent)]" className="rounded-xl border border-[var(--admin-border)] px-3 py-3 text-sm outline-none focus:border-[var(--admin-accent)]"
@@ -312,6 +334,15 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
<AdminButton variant="secondary" size="sm" onClick={() => setQrModal(hg)}> <AdminButton variant="secondary" size="sm" onClick={() => setQrModal(hg)}>
Print Print
</AdminButton> </AdminButton>
<AdminButton
variant="danger"
size="sm"
onClick={() => handleDelete(hg)}
disabled={deletingId === hg.id}
isLoading={deletingId === hg.id}
>
Delete
</AdminButton>
</div> </div>
</td> </td>
</tr> </tr>
@@ -343,19 +374,22 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
</div> </div>
<form onSubmit={handleEditSave} className="p-5 space-y-4"> <form onSubmit={handleEditSave} className="p-5 space-y-4">
<div> <div>
<label className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Name</label> <label htmlFor="headgate-edit-name" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Name</label>
<input <input
id="headgate-edit-name"
type="text" type="text"
value={editName} value={editName}
onChange={(e) => setEditName(e.target.value)} onChange={(e) => setEditName(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-4 py-3 text-sm outline-none focus:border-[var(--admin-accent)]" className="w-full rounded-xl border border-[var(--admin-border)] px-4 py-3 text-sm outline-none focus:border-[var(--admin-accent)]"
required required
aria-required="true"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Unit</label> <label htmlFor="headgate-edit-unit" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Unit</label>
<select <select
id="headgate-edit-unit"
value={editUnit} value={editUnit}
onChange={(e) => setEditUnit(e.target.value)} onChange={(e) => setEditUnit(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-3 text-sm outline-none focus:border-[var(--admin-accent)]" className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-3 text-sm outline-none focus:border-[var(--admin-accent)]"
@@ -367,15 +401,16 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<label className="flex items-center gap-2 text-sm text-[var(--admin-text-secondary)] cursor-pointer"> <label className="flex items-center gap-2 text-sm text-[var(--admin-text-secondary)] cursor-pointer">
<input type="checkbox" checked={editActive} onChange={(e) => setEditActive(e.target.checked)} className="rounded" /> <input id="headgate-edit-active" type="checkbox" checked={editActive} onChange={(e) => setEditActive(e.target.checked)} className="rounded" />
Active Active
</label> </label>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">High Alert Threshold</label> <label htmlFor="headgate-edit-high" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">High Alert Threshold</label>
<input <input
id="headgate-edit-high"
type="number" type="number"
step="any" step="any"
value={editHigh} value={editHigh}
@@ -385,8 +420,9 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Low Alert Threshold</label> <label htmlFor="headgate-edit-low" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Low Alert Threshold</label>
<input <input
id="headgate-edit-low"
type="number" type="number"
step="any" step="any"
value={editLow} value={editLow}
+8 -4
View File
@@ -133,8 +133,9 @@ export default function WaterLogSettingsPage() {
<p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p> <p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label> <label htmlFor="water-new-pin" className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label>
<input <input
id="water-new-pin"
type="password" type="password"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
@@ -147,8 +148,9 @@ export default function WaterLogSettingsPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Confirm PIN</label> <label htmlFor="water-confirm-pin" className="block text-xs font-medium text-zinc-400 mb-1">Confirm PIN</label>
<input <input
id="water-confirm-pin"
type="password" type="password"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
@@ -207,7 +209,7 @@ export default function WaterLogSettingsPage() {
{/* 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"> <div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
<p className="font-semibold text-zinc-100">High/Low Alerts</p> <p className="font-semibold text-zinc-100">High/Low Alerts</p>
<p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate's thresholds.</p> <p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate&apos;s thresholds.</p>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -225,13 +227,15 @@ export default function WaterLogSettingsPage() {
{alertsEnabled && ( {alertsEnabled && (
<div> <div>
<label 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-zinc-400 mb-1">Alert Phone Number</label>
<input <input
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="+1234567890"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900" className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900"
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="text-xs text-zinc-500 mt-1">U.S. format recommended. Must include country code (e.g. +1 for USA).</p>
</div> </div>
+30 -27
View File
@@ -618,28 +618,28 @@ function CustomersTab({ customers, products, brandId, onMsg, registrations = [],
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Customer" : "New Customer"}</h3> <h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Customer" : "New Customer"}</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Company Name</label> <label htmlFor="ws-company-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Company Name</label>
<input value={form.companyName} onChange={e => setForm(f => ({ ...f, companyName: e.target.value }))} <input id="ws-company-name" value={form.companyName} onChange={e => setForm(f => ({ ...f, companyName: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Contact Name</label> <label htmlFor="ws-contact-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Contact Name</label>
<input value={form.contactName} onChange={e => setForm(f => ({ ...f, contactName: e.target.value }))} <input id="ws-contact-name" value={form.contactName} onChange={e => setForm(f => ({ ...f, contactName: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Email</label> <label htmlFor="ws-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Email</label>
<input value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} <input id="ws-email" type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" autoComplete="email" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Phone</label> <label htmlFor="ws-phone" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Phone</label>
<input value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} <input id="ws-phone" type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" autoComplete="tel" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Status</label> <label htmlFor="ws-account-status" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Status</label>
<select value={form.accountStatus} onChange={e => setForm(f => ({ ...f, accountStatus: e.target.value }))} <select id="ws-account-status" value={form.accountStatus} onChange={e => setForm(f => ({ ...f, accountStatus: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="active">Active</option> <option value="active">Active</option>
<option value="on_hold">On Hold</option> <option value="on_hold">On Hold</option>
@@ -648,8 +648,8 @@ function CustomersTab({ customers, products, brandId, onMsg, registrations = [],
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Credit Limit ($)</label> <label htmlFor="ws-credit-limit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Credit Limit ($)</label>
<input type="number" value={form.creditLimit} onChange={e => setForm(f => ({ ...f, creditLimit: Number(e.target.value) }))} <input id="ws-credit-limit" type="number" value={form.creditLimit} onChange={e => setForm(f => ({ ...f, creditLimit: Number(e.target.value) }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0 = unlimited" /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0 = unlimited" />
</div> </div>
</div> </div>
@@ -665,13 +665,13 @@ function CustomersTab({ customers, products, brandId, onMsg, registrations = [],
{form.depositsEnabled && ( {form.depositsEnabled && (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs text-[var(--admin-text-muted)] mb-1">Threshold ($ order total to trigger)</label> <label htmlFor="ws-deposit-threshold" className="block text-xs text-[var(--admin-text-muted)] mb-1">Threshold ($ order total to trigger)</label>
<input type="number" value={form.depositThreshold} onChange={e => setForm(f => ({ ...f, depositThreshold: e.target.value }))} <input id="ws-deposit-threshold" type="number" value={form.depositThreshold} onChange={e => setForm(f => ({ ...f, depositThreshold: e.target.value }))}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0" /> className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0" />
</div> </div>
<div> <div>
<label className="block text-xs text-[var(--admin-text-muted)] mb-1">Deposit %</label> <label htmlFor="ws-deposit-pct" className="block text-xs text-[var(--admin-text-muted)] mb-1">Deposit %</label>
<input type="number" value={form.depositPercentage} onChange={e => setForm(f => ({ ...f, depositPercentage: e.target.value }))} <input id="ws-deposit-pct" type="number" value={form.depositPercentage} onChange={e => setForm(f => ({ ...f, depositPercentage: e.target.value }))}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="20" min="1" max="100" /> className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="20" min="1" max="100" />
</div> </div>
</div> </div>
@@ -1008,19 +1008,22 @@ function PriceSheetModal({
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="px-6 py-5 space-y-4"> <div className="px-6 py-5 space-y-4">
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">Subject</label> <label htmlFor="ws-price-subject" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">Subject</label>
<input <input
id="ws-price-subject"
value={subject} value={subject}
onChange={e => setSubject(e.target.value)} onChange={e => setSubject(e.target.value)}
required required
aria-required="true"
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5"> <label htmlFor="ws-price-note" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">
Add a Note <span className="text-[var(--admin-text-muted)] font-normal">(optional)</span> Add a Note <span className="text-[var(--admin-text-muted)] font-normal">(optional)</span>
</label> </label>
<textarea <textarea
id="ws-price-note"
value={note} value={note}
onChange={e => setNote(e.target.value)} onChange={e => setNote(e.target.value)}
rows={4} rows={4}
@@ -1167,18 +1170,18 @@ function ProductsTab({ products, brandId, onMsg, onRefresh }: {
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Product" : "New Product"}</h3> <h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Product" : "New Product"}</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Product Name</label> <label htmlFor="ws-prod-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Product Name</label>
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} <input id="ws-prod-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Description</label> <label htmlFor="ws-prod-desc" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Description</label>
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} <textarea id="ws-prod-desc" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" rows={2} /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" rows={2} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit Type</label> <label htmlFor="ws-prod-unit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit Type</label>
<select value={form.unitType} onChange={e => setForm(f => ({ ...f, unitType: e.target.value }))} <select id="ws-prod-unit" value={form.unitType} onChange={e => setForm(f => ({ ...f, unitType: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
{["each", "48-count box", "bag", "pallet", "bin", "load", "custom"].map(u => ( {["each", "48-count box", "bag", "pallet", "bin", "load", "custom"].map(u => (
<option key={u} value={u}>{u}</option> <option key={u} value={u}>{u}</option>
@@ -1950,7 +1953,7 @@ function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }:
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="font-medium text-[var(--admin-text-primary)]">Wholesale Portal</p> <p className="font-medium text-[var(--admin-text-primary)]">Wholesale Portal</p>
<p className="text-sm text-[var(--admin-text-muted)]">Show the Wholesale Portal card on this brand's storefront page.</p> <p className="text-sm text-[var(--admin-text-muted)]">Show the Wholesale Portal card on this brand&apos;s storefront page.</p>
</div> </div>
<button <button
onClick={() => setForm(f => ({ ...f, wholesaleEnabled: !f.wholesaleEnabled }))} onClick={() => setForm(f => ({ ...f, wholesaleEnabled: !f.wholesaleEnabled }))}
+4 -2
View File
@@ -1,5 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import WholesaleClient from "./WholesaleClient"; import WholesaleClient from "./WholesaleClient";
export default async function WholesalePage() { export default async function WholesalePage() {
@@ -11,10 +13,10 @@ export default async function WholesalePage() {
devSession === "store_employee"; devSession === "store_employee";
if (!isDevMode) { if (!isDevMode) {
const { getAdminUser } = await import("@/lib/admin-permissions");
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) redirect("/login"); if (!adminUser) redirect("/login");
return <WholesaleClient brandId={adminUser.brand_id ?? ""} />; const activeBrandId = await getActiveBrandId(adminUser);
return <WholesaleClient brandId={activeBrandId ?? ""} />;
} }
// Dev mode: platform_admin sees all brands, use first brand as default // Dev mode: platform_admin sees all brands, use first brand as default
+16
View File
@@ -0,0 +1,16 @@
import { handlers } from "@/lib/auth";
/**
* Auth.js v5 catch-all route handler. Exposes:
* GET /api/auth/signin
* GET /api/auth/signout
* GET /api/auth/session
* GET /api/auth/csrf
* GET /api/auth/providers
* POST /api/auth/callback/:provider
* POST /api/auth/signin/:provider
* POST /api/auth/signout
*
* The actual OAuth + session logic is in `src/lib/auth.ts`.
*/
export const { GET, POST } = handlers;
@@ -87,8 +87,20 @@ export async function POST(req: NextRequest) {
model, model,
}); });
} catch (err) { } catch (err) {
return NextResponse.json({ // Surface the actual SDK/API error so users can tell whether the
error: "Connection test failed. Check your API key and endpoint.", // failure is a bad key, a retired model, a quota issue, or a network
}, { status: 500 }); // problem — not a generic "check your key" message.
const message =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "Connection test failed. Check your API key and endpoint.";
// Some SDKs throw non-Error objects with a .status / .error property
const status =
(typeof err === "object" && err && "status" in err && typeof (err as { status?: unknown }).status === "number"
? (err as { status: number }).status
: undefined) ?? 500;
return NextResponse.json({ error: message }, { status: status >= 400 && status < 600 ? status : 500 });
} }
} }
+51 -9
View File
@@ -1,9 +1,51 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import Link from "next/link"; import Link from "next/link";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Blog & Resources — Route Commerce", title: "Blog & Resources — Route Commerce",
description: "Guides, case studies, and resources for produce wholesale businesses.", description: "Guides, case studies, and resources for produce wholesale businesses. Learn how to optimize your operations and grow your business.",
keywords: ["produce wholesale blog", "farm business resources", "agriculture tips", "wholesale distribution guides", "Route Commerce blog"],
authors: [{ name: "Route Commerce" }],
creator: "Route Commerce",
publisher: "Route Commerce",
openGraph: {
title: "Blog & Resources — Route Commerce",
description: "Guides, case studies, and resources for produce wholesale businesses.",
url: `${BASE_URL}/blog`,
siteName: "Route Commerce",
locale: "en_US",
type: "website",
images: [
{
url: "/og-default.jpg",
width: 1200,
height: 630,
alt: "Route Commerce Blog",
},
],
},
twitter: {
card: "summary_large_image",
title: "Blog & Resources — Route Commerce",
description: "Guides, case studies, and resources for produce wholesale businesses.",
site: "@RouteCommerce",
creator: "@RouteCommerce",
},
alternates: {
canonical: `${BASE_URL}/blog`,
},
robots: {
index: true,
follow: true,
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 5,
}; };
const BLOG_POSTS = [ const BLOG_POSTS = [
@@ -69,14 +111,14 @@ export default function BlogPage() {
{/* Header */} {/* Header */}
<header className="border-b border-[#e5e5e5] bg-white"> <header className="border-b border-[#e5e5e5] bg-white">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between"> <div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="/" className="flex items-center gap-3"> <Link href="/" className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<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-lg font-bold text-[#1a1a1a]">Route Commerce</span> <span className="text-lg font-bold text-[#1a1a1a]">Route Commerce</span>
</a> </Link>
<nav className="flex items-center gap-6 text-sm"> <nav className="flex items-center gap-6 text-sm">
<Link href="/blog" className="font-medium text-[#1a4d2e]">Blog</Link> <Link href="/blog" className="font-medium text-[#1a4d2e]">Blog</Link>
<Link href="/resources" className="text-[#888] hover:text-[#1a1a1a]">Resources</Link> <Link href="/resources" className="text-[#888] hover:text-[#1a1a1a]">Resources</Link>
@@ -86,12 +128,12 @@ export default function BlogPage() {
</header> </header>
{/* Hero */} {/* Hero */}
<section className="py-20"> <section className="py-12 sm:py-16 md:py-20">
<div className="max-w-4xl mx-auto px-6 text-center"> <div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
<h1 className="text-5xl 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: "'Cormorant Garamond', Georgia, serif" }}>
Blog & Resources Blog & Resources
</h1> </h1>
<p className="text-xl text-[#6b8f71]"> <p className="text-lg sm:text-xl text-[#6b8f71]">
Guides, tips, and resources to help you grow your wholesale produce business. Guides, tips, and resources to help you grow your wholesale produce business.
</p> </p>
</div> </div>
@@ -218,7 +260,7 @@ export default function BlogPage() {
{/* Footer */} {/* Footer */}
<footer className="border-t border-[#e5e5e5] py-8 bg-white"> <footer className="border-t border-[#e5e5e5] py-8 bg-white">
<div className="max-w-6xl mx-auto px-6 text-center text-sm text-[#888]"> <div className="max-w-6xl mx-auto px-6 text-center text-sm text-[#888]">
© 2025 Route Commerce. All rights reserved. © {new Date().getFullYear()} Route Commerce. All rights reserved.
</div> </div>
</footer> </footer>
</div> </div>
+2 -2
View File
@@ -11,7 +11,7 @@ export default function BrandsPage() {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
// Header animation // Header animation (selectors scoped to containerRef)
gsap.from(".header-content", { gsap.from(".header-content", {
y: -30, y: -30,
opacity: 0, opacity: 0,
@@ -212,7 +212,7 @@ export default function BrandsPage() {
<footer className="footer"> <footer className="footer">
<div className="max-w-6xl mx-auto px-6 py-4"> <div className="max-w-6xl mx-auto px-6 py-4">
<div className="flex items-center justify-between text-xs text-[#888]"> <div className="flex items-center justify-between text-xs text-[#888]">
<span>© 2024 Route Commerce</span> <span>© {new Date().getFullYear()} Route Commerce</span>
<div className="flex gap-4"> <div className="flex gap-4">
<a href="/privacy-policy" className="hover:text-[#1a4d2e]">Privacy</a> <a href="/privacy-policy" className="hover:text-[#1a4d2e]">Privacy</a>
<a href="/terms-and-conditions" className="hover:text-[#1a4d2e]">Terms</a> <a href="/terms-and-conditions" className="hover:text-[#1a4d2e]">Terms</a>
+14 -4
View File
@@ -42,6 +42,7 @@ export default function CartClient() {
// Check brand mismatch // Check brand mismatch
useEffect(() => { useEffect(() => {
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) { if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setStopBrandMismatch(true); setStopBrandMismatch(true);
} else { } else {
setStopBrandMismatch(false); setStopBrandMismatch(false);
@@ -51,6 +52,7 @@ export default function CartClient() {
// Fetch stops when picker is open // Fetch stops when picker is open
useEffect(() => { useEffect(() => {
if (hasPickupItems && showStopPicker && cartBrandId) { if (hasPickupItems && showStopPicker && cartBrandId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoadingStops(true); setLoadingStops(true);
fetch( fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`, `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`,
@@ -98,11 +100,12 @@ export default function CartClient() {
}, [cart, hasPickupItems, setSelectedStop]); }, [cart, hasPickupItems, setSelectedStop]);
const handleCheckoutClick = useCallback(() => { const handleCheckoutClick = useCallback(() => {
if (cart.length === 0) return;
if (stopBrandMismatch) { setSelectedStop(null); return; } if (stopBrandMismatch) { setSelectedStop(null); return; }
if (hasStopPickupItems && !selectedStop) { setShowStopPicker(true); return; } if (hasStopPickupItems && !selectedStop) { setShowStopPicker(true); return; }
if (incompatibleItems.length > 0) { return; } if (incompatibleItems.length > 0) { return; }
window.location.href = "/checkout"; window.location.href = "/checkout";
}, [stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems]); }, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems]);
return ( return (
<div className="min-h-screen relative"> <div className="min-h-screen relative">
@@ -382,17 +385,24 @@ export default function CartClient() {
<button <button
onClick={handleCheckoutClick} onClick={handleCheckoutClick}
disabled={hasStopPickupItems && (!selectedStop || incompatibleItems.length > 0)} disabled={
cart.length === 0 ||
(hasStopPickupItems && (!selectedStop || incompatibleItems.length > 0))
}
className="mt-6 w-full rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-6 py-3.5 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/20 hover:-translate-y-0.5" className="mt-6 w-full rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-6 py-3.5 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/20 hover:-translate-y-0.5"
aria-label={ aria-label={
incompatibleItems.length > 0 cart.length === 0
? "Your cart is empty. Add a product to continue."
: incompatibleItems.length > 0
? "Remove incompatible items first to proceed to checkout" ? "Remove incompatible items first to proceed to checkout"
: !selectedStop && hasStopPickupItems : !selectedStop && hasStopPickupItems
? "Select pickup stop first to proceed to checkout" ? "Select pickup stop first to proceed to checkout"
: "Continue to checkout" : "Continue to checkout"
} }
> >
{incompatibleItems.length > 0 {cart.length === 0
? "Cart is Empty"
: incompatibleItems.length > 0
? "Remove Incompatible Items First" ? "Remove Incompatible Items First"
: !selectedStop && hasStopPickupItems : !selectedStop && hasStopPickupItems
? "Select Pickup Stop First" ? "Select Pickup Stop First"
+35 -7
View File
@@ -1,9 +1,37 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { FileText, Zap, Bug, Shield } from "lucide-react"; import { FileText, Zap, Bug, Shield } from "lucide-react";
import Link from "next/link";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Changelog — Route Commerce", title: "Changelog — Route Commerce",
description: "See what&apos;s new in Route Commerce. Track our progress, new features, and improvements.", description: "See what's new in Route Commerce. Track our progress, new features, and improvements to the produce wholesale platform.",
keywords: ["changelog", "product updates", "new features", "Route Commerce release notes", "software updates"],
authors: [{ name: "Route Commerce" }],
creator: "Route Commerce",
publisher: "Route Commerce",
openGraph: {
title: "Changelog — Route Commerce",
description: "See what's new in Route Commerce. Track our progress and new features.",
url: `${BASE_URL}/changelog`,
siteName: "Route Commerce",
locale: "en_US",
type: "website",
},
alternates: {
canonical: `${BASE_URL}/changelog`,
},
robots: {
index: true,
follow: true,
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 5,
}; };
const CHANGELOG = [ const CHANGELOG = [
@@ -86,17 +114,17 @@ export default function ChangelogPage() {
{/* Header */} {/* Header */}
<header className="border-b border-[#e5e5e5] bg-white"> <header className="border-b border-[#e5e5e5] bg-white">
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between"> <div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="/" className="flex items-center gap-3"> <Link href="/" className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<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-lg font-bold text-[#1a1a1a]">Route Commerce</span> <span className="text-lg font-bold text-[#1a1a1a]">Route Commerce</span>
</a> </Link>
<a href="/admin" className="text-sm text-[#666] hover:text-[#1a4d2e] transition-colors"> <Link href="/admin" className="text-sm text-[#666] hover:text-[#1a4d2e] transition-colors">
Back to Admin Back to Admin
</a> </Link>
</div> </div>
</header> </header>
@@ -180,7 +208,7 @@ export default function ChangelogPage() {
{/* Footer */} {/* Footer */}
<footer className="border-t border-[#e5e5e5] py-8"> <footer className="border-t border-[#e5e5e5] py-8">
<div className="max-w-4xl mx-auto px-6 text-center text-sm text-[#888]"> <div className="max-w-4xl mx-auto px-6 text-center text-sm text-[#888]">
© 2025 Route Commerce. All rights reserved. © {new Date().getFullYear()} Route Commerce. All rights reserved.
</div> </div>
</footer> </footer>
</div> </div>
+232 -163
View File
@@ -2,26 +2,38 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
import { useCart } from "@/context/CartContext"; import { useCart } from "@/context/CartContext";
import type { StopInfo } from "@/context/CartContext";
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout"; import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import StripeExpressCheckout, {
type Stop = { type CheckoutItem as ExpressItem,
id: string; } from "@/components/storefront/StripeExpressCheckout";
city: string;
state: string;
date: string;
location: string;
brand_id: string;
};
export default function CheckoutClient() { export default function CheckoutClient() {
const { cart, subtotal, selectedStop, setSelectedStop, clearCart, cartBrandId } = useCart(); const cartContext = useCart();
const cart = cartContext.cart;
const subtotal = cartContext.subtotal;
const selectedStop: StopInfo | null = cartContext.selectedStop;
const setSelectedStop = cartContext.setSelectedStop;
const cartBrandId = cartContext.cartBrandId;
const router = useRouter(); const router = useRouter();
const [stops, setStops] = useState<Stop[]>([]); const [stops, setStops] = useState<StopInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); // Controlled form state — passed down to StripeExpressCheckout so the
// Express + Card paths know who the buyer is and where to ship / pick up.
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [shippingState, setShippingState] = useState("");
const [shippingPostal, setShippingPostal] = useState("");
const [shippingCity, setShippingCity] = useState("");
// Hosted-checkout (legacy Stripe Checkout) fallback state
const [hostedLoading, setHostedLoading] = useState(false);
const [hostedError, setHostedError] = useState<string | null>(null);
// Stable idempotency key per checkout session — survives retries // Stable idempotency key per checkout session — survives retries
const idempotencyKeyRef = useRef<string | null>(null); const idempotencyKeyRef = useRef<string | null>(null);
@@ -32,7 +44,7 @@ export default function CheckoutClient() {
useEffect(() => { useEffect(() => {
if (!cartBrandId) return; if (!cartBrandId) return;
fetch( fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,location,brand_id&order=date`, `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`,
{ {
headers: { headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
@@ -46,45 +58,37 @@ export default function CheckoutClient() {
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup"); const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed"); const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
const hasStopPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type !== "shed"); const hasStopPickupItems = cart.some(
(i) => i.fulfillment === "pickup" && i.pickup_type !== "shed"
);
const hasShipItems = cart.some((i) => i.fulfillment === "ship"); const hasShipItems = cart.some((i) => i.fulfillment === "ship");
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => { /**
e.preventDefault(); * Legacy "Use secure hosted checkout" fallback. Creates a Stripe
* Checkout Session and redirects this is the path used when the
* embedded Stripe Elements can't load (no publishable key, browser
* incompatibility, etc.) or when the buyer prefers Stripe's hosted page.
*/
const handleHostedCheckout = useCallback(async () => {
if (cart.length === 0) return; if (cart.length === 0) return;
// Guard: if selected stop brand mismatches cart brand, block checkout
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) { if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
setSelectedStop(null); setSelectedStop(null);
router.replace("/cart"); router.replace("/cart");
return; return;
} }
setLoading(true); if (!customerEmail.trim()) {
setError(null); setHostedError("Please enter your email before continuing to checkout.");
const formData = new FormData(e.currentTarget);
const customerName = formData.get("customer_name") as string;
const customerEmail = formData.get("customer_email") as string;
const customerPhone = formData.get("customer_phone") as string;
const stopId = (selectedStop?.id ?? formData.get("stop_id") as string | null) as string | null;
// Shipping address for tax calculation (ship items only)
let shippingAddress;
if (hasShipItems) {
const shippingPostal = formData.get("shipping_postal_code") as string;
const shippingState = formData.get("shipping_state") as string;
const shippingCity = formData.get("shipping_city") as string;
if (shippingState) {
shippingAddress = { state: shippingState, postal_code: shippingPostal, city: shippingCity };
}
}
if (hasStopPickupItems && !stopId) {
setError("Please select a pickup location.");
setLoading(false);
return; return;
} }
if (hasStopPickupItems && !selectedStop) {
setHostedError("Please select a pickup location.");
return;
}
setHostedLoading(true);
setHostedError(null);
const items = cart.map((item) => ({ const items = cart.map((item) => ({
id: item.id, id: item.id,
@@ -94,36 +98,61 @@ export default function CheckoutClient() {
fulfillment: item.fulfillment ?? "pickup", fulfillment: item.fulfillment ?? "pickup",
})); }));
// Build return URLs for Stripe const siteUrl =
const siteUrl = typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app"; typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
const successUrl = `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`; const successUrl = `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${siteUrl}/checkout?status=cancelled`; const cancelUrl = `${siteUrl}/checkout?status=cancelled`;
// Store customer info for order creation on success page
if (typeof sessionStorage !== "undefined") { if (typeof sessionStorage !== "undefined") {
sessionStorage.setItem("pending_checkout", JSON.stringify({ sessionStorage.setItem(
customerName, "pending_checkout",
customerEmail, JSON.stringify({
customerPhone, customerName,
stopId, customerEmail,
items: items.map((item) => ({ ...item, fulfillment: item.fulfillment as "pickup" | "ship" })), customerPhone,
cartBrandId, stopId: selectedStop?.id ?? null,
shippingAddress, items: items.map((item) => ({
idempotencyKey: idempotencyKeyRef.current, ...item,
})); fulfillment: item.fulfillment as "pickup" | "ship",
})),
cartBrandId,
shippingAddress: hasShipItems
? { state: shippingState, postal_code: shippingPostal, city: shippingCity }
: undefined,
idempotencyKey: idempotencyKeyRef.current,
})
);
} }
// Create Stripe Checkout session first const stripeResult = await createRetailStripeCheckoutSession(
const stripeResult = await createRetailStripeCheckoutSession(items, "", cartBrandId ?? "unknown", successUrl, cancelUrl); items,
"",
cartBrandId ?? "unknown",
successUrl,
cancelUrl
);
if (!stripeResult.success || !stripeResult.url) { if (!stripeResult.success || !stripeResult.url) {
setError(stripeResult.error ?? "Failed to initiate payment. Please try again."); setHostedError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
setLoading(false); setHostedLoading(false);
return; return;
} }
// Redirect to Stripe Checkout
window.location.href = stripeResult.url; window.location.href = stripeResult.url;
}, [cart, selectedStop, cartBrandId, hasShipItems, hasStopPickupItems, router, setSelectedStop]); }, [
cart,
customerName,
customerEmail,
customerPhone,
selectedStop,
shippingState,
shippingPostal,
shippingCity,
cartBrandId,
hasShipItems,
hasStopPickupItems,
router,
setSelectedStop,
]);
if (cart.length === 0) { if (cart.length === 0) {
return ( return (
@@ -131,86 +160,94 @@ export default function CheckoutClient() {
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" /> <StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
<main className="px-6 py-12"> <main className="px-6 py-12">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-stone-900"> <h1 className="text-3xl font-bold text-stone-900">Your cart is empty</h1>
Your cart is empty <Link
</h1>
<a
href="/" href="/"
className="mt-4 inline-block text-stone-600 hover:text-stone-900" className="mt-4 inline-block text-stone-600 hover:text-stone-900"
> >
Back to storefront Back to storefront
</a> </Link>
</div> </div>
</main> </main>
</div> </div>
); );
} }
// Map cart → StripeExpressCheckout item shape
const expressItems: ExpressItem[] = cart.map((item) => ({
id: item.id,
name: item.name,
price: item.price,
quantity: item.quantity,
fulfillment: (item.fulfillment ?? "pickup") as "pickup" | "ship",
}));
return ( return (
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30"> <div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" /> <StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
<main className="px-6 py-12"> <main className="px-6 py-12">
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]"> <div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
{/* LEFT — Form + Embedded Stripe Elements */}
<div> <div>
<h1 className="text-4xl font-bold text-slate-900"> <h1 className="text-4xl font-bold text-slate-900">Checkout</h1>
Checkout
</h1>
<p className="mt-3 text-slate-600"> <p className="mt-3 text-slate-600">
Complete your order details. Tap to pay with Apple Pay, Google Pay, or card. Your details stay secure we never see them.
</p> </p>
{/* Error alert */} {/* Error alert (shared across both payment paths) */}
{error && ( {(hostedError) && (
<div className="mt-6 rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm shadow-sm" role="alert"> <div className="mt-6 rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm shadow-sm" role="alert">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<svg className="h-5 w-5 text-red-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg className="h-5 w-5 text-red-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<span>{error}</span> <span>{hostedError}</span>
</div> </div>
</div> </div>
)} )}
{/* Loading indicator */} <div className="mt-8 space-y-6">
{loading && ( {/* Customer Information controlled inputs feed the embedded
<div className="mt-6 flex items-center gap-3 rounded-xl bg-emerald-50 border border-emerald-200 p-4" role="status" aria-live="polite"> Stripe Elements above. The wallet (Apple Pay) will overwrite
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-300 border-t-emerald-600" aria-hidden="true" /> name/email at confirm time, but we still collect them here
<span className="text-sm text-emerald-700">Placing your order...</span> so the order row has them in case the wallet omits them. */}
</div>
)}
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
{/* Customer Information */}
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100"> <fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
<legend className="text-xl font-bold text-slate-900">Customer Information</legend> <legend className="text-xl font-bold text-slate-900">Your details</legend>
<p className="mt-1 text-xs text-slate-500">
Used for the order confirmation. Apple Pay / Google Pay will fill these in for you.
</p>
<div className="mt-4 space-y-4"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div className="sm:col-span-2">
<label htmlFor="customer_name" className="block text-sm font-medium text-slate-700"> <label htmlFor="customer_name" className="block text-sm font-medium text-slate-700">
Full Name <span className="text-red-500" aria-hidden="true">*</span> Full Name
</label> </label>
<input <input
id="customer_name" id="customer_name"
name="customer_name" name="customer_name"
required autoComplete="name"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all" className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="Jane Smith" placeholder="Jane Smith"
aria-required="true"
/> />
</div> </div>
<div> <div>
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700"> <label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
Email <span className="text-slate-400 text-xs">(optional)</span> Email <span className="text-red-500" aria-hidden="true">*</span>
</label> </label>
<input <input
id="customer_email" id="customer_email"
name="customer_email" name="customer_email"
type="email" type="email"
autoComplete="email" autoComplete="email"
required
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all" className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="jane@example.com" placeholder="jane@example.com"
aria-required="true"
/> />
</div> </div>
@@ -223,6 +260,8 @@ export default function CheckoutClient() {
name="customer_phone" name="customer_phone"
type="tel" type="tel"
autoComplete="tel" autoComplete="tel"
value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all" className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="(555) 555-5555" placeholder="(555) 555-5555"
/> />
@@ -230,7 +269,7 @@ export default function CheckoutClient() {
</div> </div>
</fieldset> </fieldset>
{/* Shed Pickup */} {/* Shed Pickup notice */}
{hasShedPickupItems && ( {hasShedPickupItems && (
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100"> <fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend> <legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
@@ -277,6 +316,11 @@ export default function CheckoutClient() {
id="stop_id" id="stop_id"
name="stop_id" name="stop_id"
required required
value={(selectedStop as StopInfo | null)?.id ?? ""}
onChange={(e) => {
const found = stops.find((s) => s.id === e.target.value) ?? null;
setSelectedStop(found as StopInfo | null);
}}
className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all appearance-none bg-white" className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all appearance-none bg-white"
aria-required="true" aria-required="true"
> >
@@ -300,8 +344,8 @@ export default function CheckoutClient() {
Enter your shipping details for delivery. Enter your shipping details for delivery.
</p> </p>
<div className="mt-4 space-y-4"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
<div> <div className="sm:col-span-3">
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700"> <label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
Street Address Street Address
</label> </label>
@@ -313,80 +357,97 @@ export default function CheckoutClient() {
placeholder="123 Main St" placeholder="123 Main St"
/> />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div>
<div> <label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700"> City
City </label>
</label> <input
<input id="shipping_city"
id="shipping_city" name="shipping_city"
name="shipping_city" autoComplete="address-level2"
autoComplete="address-level2" value={shippingCity}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all" onChange={(e) => setShippingCity(e.target.value)}
/> className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
</div> />
<div> </div>
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700"> <div>
State <label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
</label> State
<input </label>
id="shipping_state" <input
name="shipping_state" id="shipping_state"
autoComplete="address-level1" name="shipping_state"
maxLength={2} autoComplete="address-level1"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase" maxLength={2}
placeholder="NC" value={shippingState}
/> onChange={(e) => setShippingState(e.target.value.toUpperCase())}
</div> className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase"
<div> placeholder="NC"
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700"> />
ZIP Code </div>
</label> <div>
<input <label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
id="shipping_postal_code" ZIP Code
name="shipping_postal_code" </label>
autoComplete="postal-code" <input
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all" id="shipping_postal_code"
placeholder="28147" name="shipping_postal_code"
/> autoComplete="postal-code"
</div> value={shippingPostal}
onChange={(e) => setShippingPostal(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="28147"
/>
</div> </div>
</div> </div>
</fieldset> </fieldset>
)} )}
{/* Submit button */} {/* Embedded Stripe Elements (Express + Card) — real checkout */}
<button <div data-testid="stripe-express-checkout">
type="submit" <StripeExpressCheckout
disabled={loading} items={expressItems}
className="w-full rounded-xl bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 px-6 py-4 text-lg font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/30 hover:-translate-y-0.5 flex items-center justify-center gap-2" brandId={cartBrandId}
> customerName={customerName}
{loading ? ( customerEmail={customerEmail}
<> customerPhone={customerPhone}
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"> selectedStop={selectedStop as StopInfo | null}
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> shippingState={shippingState}
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> shippingPostal={shippingPostal}
</svg> shippingCity={shippingCity}
Redirecting to payment... checkoutSessionKey={idempotencyKeyRef.current ?? undefined}
</> onHostedCheckout={() => void handleHostedCheckout()}
) : ( />
<> </div>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> {/* Hosted-checkout fallback (legacy Stripe Checkout) */}
</svg> <div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-4">
Pay Now ${subtotal.toFixed(2)} <p className="text-xs text-slate-500 mb-2 text-center">
</> Prefer Stripes hosted page? Tap below.
)} </p>
</button> <button
</form> type="button"
onClick={() => void handleHostedCheckout()}
disabled={hostedLoading}
className="w-full rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-800 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
>
{hostedLoading ? (
<>
<span className="h-4 w-4 rounded-full border-2 border-slate-300 border-t-slate-700 animate-spin" />
Redirecting to Stripe
</>
) : (
<>Use secure hosted checkout </>
)}
</button>
</div>
</div>
</div> </div>
{/* Order Summary */} {/* Order Summary */}
<aside aria-label="Order summary"> <aside aria-label="Order summary">
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100 sticky top-6"> <div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100 sticky top-6">
<h2 className="text-xl font-bold text-slate-900"> <h2 className="text-xl font-bold text-slate-900">Order Summary</h2>
Order Summary
</h2>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{cart.map((item) => ( {cart.map((item) => (
@@ -419,12 +480,20 @@ export default function CheckoutClient() {
</div> </div>
</div> </div>
{/* Secure payment badge */} {/* Trust badges */}
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-slate-500"> <div className="mt-4 space-y-1.5">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <div className="flex items-center justify-center gap-2 text-xs text-slate-500">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
</svg> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
<span>Secured by Stripe</span> </svg>
<span>Secured by Stripe</span>
</div>
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span>100% Sweetness Guarantee</span>
</div>
</div> </div>
</div> </div>
</aside> </aside>
+70 -58
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, Suspense, useState } from "react"; import { useEffect, Suspense, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { createOrder } from "@/actions/checkout"; import { createOrder } from "@/actions/checkout";
@@ -46,67 +46,79 @@ function formatStopDate(dateStr: string): string {
function SuccessContent() { function SuccessContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter();
const sessionId = searchParams.get("session_id"); const sessionId = searchParams.get("session_id");
// Embedded Stripe Elements path — we navigated here programmatically
// with ?payment_intent=pi_... after `stripe.confirmPayment` resolved.
const paymentIntentId = searchParams.get("payment_intent");
const redirectStatus = searchParams.get("redirect_status");
const orderIdParam = searchParams.get("order_id"); const orderIdParam = searchParams.get("order_id");
const [order, setOrder] = useState<StoredOrder | null>(null); // Direct access with order_id — load from sessionStorage in lazy initializer.
const [creating, setCreating] = useState(!!sessionId); // searchParams values are stable, and sessionStorage is client-only, so this
// is safe in a client component.
const [order, setOrder] = useState<StoredOrder | null>(() => {
if (!orderIdParam || sessionId || paymentIntentId) return null;
if (typeof window === "undefined") return null;
try {
const stored = sessionStorage.getItem(`order_${orderIdParam}`);
if (!stored) return null;
return JSON.parse(stored) as StoredOrder;
} catch {
return null;
}
});
const [creating, setCreating] = useState(Boolean(sessionId || (paymentIntentId && redirectStatus === "succeeded")));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Create the order from the pending-checkout payload, regardless of
// whether the user paid via hosted Stripe Checkout (?session_id=...) or
// embedded Stripe Elements (?payment_intent=...). Both paths store the
// same payload in `pending_checkout` before payment is initiated.
useEffect(() => { useEffect(() => {
if (orderIdParam && !sessionId) { if (!sessionId && !paymentIntentId) return;
// Direct access with order_id — load from sessionStorage if (paymentIntentId && redirectStatus && redirectStatus !== "succeeded") {
const stored = sessionStorage.getItem(`order_${orderIdParam}`); setError("Payment was not completed. Please try again.");
if (stored) { setCreating(false);
try { return;
setOrder(JSON.parse(stored) as StoredOrder); }
} catch {
// ignore (async () => {
} const pendingStr = sessionStorage.getItem("pending_checkout");
if (!pendingStr) {
setError("No pending order found. Please start checkout again.");
setCreating(false);
return;
} }
}
}, [orderIdParam, sessionId]);
// Stripe redirected back — create order from pending checkout data let pending: {
useEffect(() => { customerName: string;
if (!sessionId) return; customerEmail: string;
customerPhone: string;
stopId: string | null;
items: Array<{ id: string; name: string; price: number; quantity: number; fulfillment: "pickup" | "ship" }>;
cartBrandId?: string;
shippingAddress?: { state: string; postal_code: string; city?: string };
idempotencyKey: string;
paymentIntentId?: string;
};
try {
pending = JSON.parse(pendingStr);
} catch {
setError("Failed to read checkout data. Please start again.");
setCreating(false);
return;
}
const pendingStr = sessionStorage.getItem("pending_checkout"); try {
if (!pendingStr) { const result = await createOrder(
setError("No pending order found. Please start checkout again."); pending.idempotencyKey,
setCreating(false); pending.customerName,
return; pending.customerEmail,
} pending.customerPhone,
pending.stopId,
let pending: { pending.items,
customerName: string; pending.cartBrandId,
customerEmail: string; pending.shippingAddress
customerPhone: string; );
stopId: string | null;
items: Array<{ id: string; name: string; price: number; quantity: number; fulfillment: "pickup" | "ship" }>;
cartBrandId?: string;
shippingAddress?: { state: string; postal_code: string; city?: string };
idempotencyKey: string;
};
try {
pending = JSON.parse(pendingStr);
} catch {
setError("Failed to read checkout data. Please start again.");
setCreating(false);
return;
}
createOrder(
pending.idempotencyKey,
pending.customerName,
pending.customerEmail,
pending.customerPhone,
pending.stopId,
pending.items,
pending.cartBrandId,
pending.shippingAddress
)
.then((result) => {
if (!result.success) { if (!result.success) {
setError(result.error ?? "Failed to create order"); setError(result.error ?? "Failed to create order");
setCreating(false); setCreating(false);
@@ -117,12 +129,12 @@ function SuccessContent() {
sessionStorage.removeItem("cart"); sessionStorage.removeItem("cart");
setOrder(result.order); setOrder(result.order);
setCreating(false); setCreating(false);
}) } catch {
.catch(() => {
setError("Failed to create order. Please contact support."); setError("Failed to create order. Please contact support.");
setCreating(false); setCreating(false);
}); }
}, [sessionId]); })();
}, [sessionId, paymentIntentId, redirectStatus]);
if (!order || !orderIdParam) { if (!order || !orderIdParam) {
return ( return (
+20 -3
View File
@@ -76,7 +76,13 @@ export default function ContactClientPage() {
</svg> </svg>
</div> </div>
<h3 className="text-lg font-bold text-[#0a0a0a] mb-3">Phone</h3> <h3 className="text-lg font-bold text-[#0a0a0a] mb-3">Phone</h3>
<p className="text-[#666] leading-relaxed">support@routecommerce.com</p> <a
href="tel:+19703235631"
className="block text-[#1a4d2e] hover:text-[#2d6a4f] font-semibold leading-relaxed"
>
(970) 323-5631
</a>
<p className="text-sm text-[#888] mt-1">Mon&ndash;Fri, 8 AM &ndash; 6 PM MT</p>
</article> </article>
<article className="rounded-3xl bg-white border border-[#e5e5e5] p-8 text-center shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300"> <article className="rounded-3xl bg-white border border-[#e5e5e5] p-8 text-center shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300">
@@ -86,7 +92,18 @@ export default function ContactClientPage() {
</svg> </svg>
</div> </div>
<h3 className="text-lg font-bold text-[#0a0a0a] mb-3">Email</h3> <h3 className="text-lg font-bold text-[#0a0a0a] mb-3">Email</h3>
<p className="text-[#666] leading-relaxed">hello@routecommerce.com</p> <a
href="mailto:hello@routecommerce.com"
className="block text-[#1a4d2e] hover:text-[#2d6a4f] font-semibold leading-relaxed"
>
hello@routecommerce.com
</a>
<a
href="mailto:support@routecommerce.com"
className="block text-sm text-[#666] hover:text-[#1a4d2e] mt-1"
>
support@routecommerce.com
</a>
</article> </article>
</div> </div>
@@ -265,7 +282,7 @@ export default function ContactClientPage() {
<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-sm text-[#666]">2024 Route Commerce. All rights reserved.</span> <span className="text-sm text-[#666]">&copy; {new Date().getFullYear()} Route Commerce. All rights reserved.</span>
</div> </div>
<nav className="flex items-center gap-6 text-sm text-[#888]" aria-label="Footer navigation"> <nav className="flex items-center gap-6 text-sm text-[#888]" aria-label="Footer navigation">
<Link href="/privacy-policy" className="hover:text-[#1a4d2e] transition-colors">Privacy</Link> <Link href="/privacy-policy" className="hover:text-[#1a4d2e] transition-colors">Privacy</Link>
+14 -4
View File
@@ -1,12 +1,15 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import ContactClientPage from "./ContactClientPage"; import ContactClientPage from "./ContactClientPage";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com"; const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Contact Us", title: "Contact Us — Route Commerce",
description: "Get in touch with Route Commerce. Questions about produce, wholesale accounts, or becoming a partner? We'd love to hear from you.", description: "Get in touch with Route Commerce. Questions about produce, wholesale accounts, or becoming a partner? We'd love to hear from you.",
keywords: ["Route Commerce contact", "produce wholesale inquiry", "partnership questions", "agriculture platform support"], keywords: ["Route Commerce contact", "produce wholesale inquiry", "partnership questions", "agriculture platform support", "contact form", "support"],
authors: [{ name: "Route Commerce" }],
creator: "Route Commerce",
publisher: "Route Commerce",
openGraph: { openGraph: {
title: "Contact Us — Route Commerce", title: "Contact Us — Route Commerce",
description: "Get in touch with Route Commerce. Questions about produce, wholesale accounts, or becoming a partner.", description: "Get in touch with Route Commerce. Questions about produce, wholesale accounts, or becoming a partner.",
@@ -26,8 +29,9 @@ export const metadata: Metadata = {
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "Contact Us — Route Commerce", title: "Contact Us — Route Commerce",
description: "Get in touch with Route Commerce. Questions about produce, wholesale accounts, or becoming a partner.", description: "Get in touch with Route Commerce.",
site: "@RouteCommerce", site: "@RouteCommerce",
creator: "@RouteCommerce",
images: ["/og-default.jpg"], images: ["/og-default.jpg"],
}, },
alternates: { alternates: {
@@ -39,6 +43,12 @@ export const metadata: Metadata = {
}, },
}; };
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 5,
};
export default function ContactPage() { export default function ContactPage() {
return <ContactClientPage />; return <ContactClientPage />;
} }
+589 -1
View File
@@ -1,6 +1,11 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
/* Custom typefaces — Field Almanac */
--font-display: var(--font-fraunces, "Georgia"), Georgia, serif;
--font-sans: var(--font-manrope, system-ui), system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: var(--font-fragment-mono, "JetBrains Mono"), ui-monospace, SFMono-Regular, Menlo, monospace;
/* Apple-inspired dark palette — sophisticated depth */ /* Apple-inspired dark palette — sophisticated depth */
--color-surface-50: #f5f5f7; --color-surface-50: #f5f5f7;
--color-surface-100: #e8e8ed; --color-surface-100: #e8e8ed;
@@ -85,7 +90,7 @@ html {
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: var(--font-sans);
background: #ffffff; background: #ffffff;
background-attachment: fixed; background-attachment: fixed;
color: #1a1a1a; color: #1a1a1a;
@@ -442,3 +447,586 @@ select:-webkit-autofill:focus {
.transition-glass { .transition-glass {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
/* ─── Editorial Modal — "Atelier des Récoltes" ───────────────── */
/* Warm cream canvas for the modal — like aged paper */
.atelier-canvas {
background-color: #FAF7F0;
background-image:
radial-gradient(ellipse 70% 50% at 15% 0%, rgba(180, 155, 100, 0.10) 0%, transparent 55%),
radial-gradient(ellipse 60% 45% at 100% 100%, rgba(34, 78, 47, 0.06) 0%, transparent 55%);
position: relative;
}
/* Subtle grain texture for tactile feel */
.atelier-grain {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.35;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.18 0 0 0 0 0.15 0 0 0 0 0.10 0 0 0 0.045 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Botanical wreath / corner ornament */
.atelier-flourish {
background-image: url("data:image/svg+xml,%3Csvg width='80' height='80' viewBox='0 0 80 80' xmlns='http://www.w3.org/2000/svg' fill='none' stroke='%23224E2F' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 40 Q 25 20 40 30 Q 55 40 70 30'/%3E%3Cpath d='M22 32 Q 25 28 28 30'/%3E%3Cpath d='M52 38 Q 55 34 58 36'/%3E%3Cpath d='M14 45 Q 18 50 24 48'/%3E%3Ccircle cx='40' cy='30' r='2' fill='%23224E2F'/%3E%3C/svg%3E");
background-repeat: no-repeat;
}
/* The big editorial numeral — large, italic, gold */
.atelier-numeral {
font-family: var(--font-fraunces);
font-style: italic;
font-weight: 500;
font-variation-settings: "opsz" 144, "SOFT" 30;
line-height: 0.85;
letter-spacing: -0.04em;
background: linear-gradient(135deg, #CA8A04 0%, #854D0E 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Editorial title — Fraunces with optical sizing */
.atelier-title {
font-family: var(--font-fraunces);
font-weight: 500;
font-variation-settings: "opsz" 144, "SOFT" 50;
letter-spacing: -0.025em;
line-height: 0.95;
color: #1C1917;
}
.atelier-italic {
font-family: var(--font-fraunces);
font-style: italic;
font-variation-settings: "opsz" 14, "SOFT" 100;
color: #786B53;
}
/* Section label — monospace, small caps style */
.atelier-section-label {
font-family: var(--font-fragment-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #786B53;
}
/* Section number badge */
.atelier-section-num {
font-family: var(--font-fragment-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.05em;
color: #CA8A04;
display: inline-flex;
align-items: center;
gap: 6px;
}
.atelier-section-num::before {
content: '';
display: inline-block;
width: 18px;
height: 1px;
background: linear-gradient(to right, #CA8A04, transparent);
}
/* Editorial hairline rule with fade at edges */
.atelier-rule {
height: 1px;
background: linear-gradient(to right, transparent, rgba(180, 155, 100, 0.4) 20%, rgba(34, 78, 47, 0.3) 50%, rgba(180, 155, 100, 0.4) 80%, transparent);
border: 0;
}
/* Input fields — bottom border only, editorial */
.atelier-input {
font-family: var(--font-manrope);
font-size: 15px;
font-weight: 400;
color: #1C1917;
background: transparent;
border: 0;
border-bottom: 1.5px solid rgba(28, 25, 23, 0.12);
border-radius: 0;
padding: 10px 0 10px 0;
width: 100%;
outline: none;
transition: border-color 220ms cubic-bezier(0.4, 0, 0.2, 1), padding 220ms cubic-bezier(0.4, 0, 0.2, 1);
}
.atelier-input::placeholder {
color: #A8A29E;
font-style: italic;
font-family: var(--font-fraunces);
font-variation-settings: "opsz" 14, "SOFT" 100;
}
.atelier-input:hover {
border-bottom-color: rgba(28, 25, 23, 0.28);
}
.atelier-input:focus {
border-bottom-color: #224E2F;
border-bottom-width: 2px;
padding-bottom: 9.5px;
}
.atelier-input.is-error {
border-bottom-color: #B91C1C;
}
/* Large display input — name field */
.atelier-input--display {
font-family: var(--font-fraunces);
font-size: 28px;
font-weight: 500;
font-variation-settings: "opsz" 144, "SOFT" 50;
letter-spacing: -0.02em;
padding: 8px 0 14px 0;
}
.atelier-input--display::placeholder {
color: #D6D3D1;
font-style: italic;
font-variation-settings: "opsz" 144, "SOFT" 100;
}
.atelier-input--display:focus {
border-bottom-width: 2.5px;
padding-bottom: 13px;
}
/* Price input — large currency treatment */
.atelier-price {
font-family: var(--font-fraunces);
font-variation-settings: "opsz" 144, "SOFT" 50;
font-weight: 500;
font-size: 38px;
letter-spacing: -0.03em;
color: #1C1917;
background: transparent;
border: 0;
border-bottom: 2px solid #224E2F;
border-radius: 0;
padding: 4px 0 12px 36px;
width: 100%;
outline: none;
transition: border-color 200ms ease;
-moz-appearance: textfield;
}
.atelier-price::-webkit-outer-spin-button,
.atelier-price::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.atelier-price:focus {
border-bottom-color: #14532D;
border-bottom-width: 2.5px;
}
.atelier-price-sigil {
font-family: var(--font-fraunces);
font-style: italic;
font-weight: 400;
font-size: 38px;
color: #CA8A04;
position: absolute;
left: 0;
top: 4px;
pointer-events: none;
line-height: 1;
}
/* Type selector — visual cards */
.atelier-type-card {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 14px 12px;
background: rgba(255, 255, 255, 0.6);
border: 1.5px solid rgba(28, 25, 23, 0.10);
border-radius: 10px;
cursor: pointer;
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
text-align: left;
width: 100%;
min-height: 92px;
overflow: hidden;
}
.atelier-type-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(34, 78, 47, 0.02) 0%, transparent 60%);
opacity: 0;
transition: opacity 220ms ease;
pointer-events: none;
}
.atelier-type-card:hover {
border-color: rgba(34, 78, 47, 0.25);
background: rgba(255, 255, 255, 0.85);
transform: translateY(-1px);
}
.atelier-type-card:hover::before {
opacity: 1;
}
.atelier-type-card:active {
transform: translateY(0) scale(0.985);
}
.atelier-type-card.is-selected {
background: linear-gradient(135deg, #224E2F 0%, #14532D 100%);
border-color: #14532D;
box-shadow: 0 8px 24px -8px rgba(20, 83, 45, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.10);
}
.atelier-type-card.is-selected::before {
opacity: 0;
}
.atelier-type-card .atelier-type-icon {
color: #786B53;
transition: color 220ms ease, transform 220ms ease;
}
.atelier-type-card.is-selected .atelier-type-icon {
color: #FCD34D;
transform: scale(1.06);
}
.atelier-type-card .atelier-type-name {
font-family: var(--font-fraunces);
font-size: 14px;
font-weight: 500;
font-variation-settings: "opsz" 14, "SOFT" 30;
letter-spacing: -0.01em;
color: #1C1917;
transition: color 220ms ease;
}
.atelier-type-card.is-selected .atelier-type-name {
color: #FFFFFF;
}
.atelier-type-card .atelier-type-check {
opacity: 0;
color: #FCD34D;
transform: scale(0.6);
transition: opacity 200ms ease, transform 200ms ease;
}
.atelier-type-card.is-selected .atelier-type-check {
opacity: 1;
transform: scale(1);
}
/* Pill toggle — Active / Taxable */
.atelier-toggle {
display: inline-flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.atelier-toggle-track {
position: relative;
width: 38px;
height: 22px;
background: rgba(28, 25, 23, 0.12);
border-radius: 999px;
transition: background 220ms ease;
flex-shrink: 0;
}
.atelier-toggle.is-on .atelier-toggle-track {
background: linear-gradient(135deg, #224E2F 0%, #16A34A 100%);
box-shadow: 0 2px 8px -2px rgba(34, 78, 47, 0.4);
}
.atelier-toggle.is-on.is-gold .atelier-toggle-track {
background: linear-gradient(135deg, #CA8A04 0%, #EAB308 100%);
box-shadow: 0 2px 8px -2px rgba(202, 138, 4, 0.4);
}
.atelier-toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: #FFFFFF;
border-radius: 999px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.atelier-toggle.is-on .atelier-toggle-thumb {
transform: translateX(16px);
}
.atelier-toggle-label {
font-family: var(--font-fragment-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #786B53;
transition: color 220ms ease;
}
.atelier-toggle.is-on .atelier-toggle-label {
color: #1C1917;
}
/* Image drop zone — large square with botanical treatment */
.atelier-drop {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
aspect-ratio: 1 / 1;
background: rgba(255, 255, 255, 0.5);
border: 1.5px dashed rgba(34, 78, 47, 0.25);
border-radius: 14px;
cursor: pointer;
transition: all 280ms cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.atelier-drop::before {
content: '';
position: absolute;
inset: 12px;
border: 1px solid rgba(180, 155, 100, 0.18);
border-radius: 10px;
pointer-events: none;
}
.atelier-drop::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at center, rgba(34, 78, 47, 0.02) 0%, transparent 70%);
pointer-events: none;
}
.atelier-drop:hover {
border-color: rgba(34, 78, 47, 0.45);
background: rgba(255, 255, 255, 0.75);
transform: scale(1.005);
}
.atelier-drop.is-drag {
border-color: #224E2F;
border-style: solid;
background: rgba(34, 78, 47, 0.04);
transform: scale(1.01);
}
.atelier-drop.is-error {
border-color: #B91C1C;
background: rgba(185, 28, 28, 0.03);
}
.atelier-drop.has-image {
border-style: solid;
border-color: rgba(34, 78, 47, 0.3);
background: #FFFFFF;
}
.atelier-drop-eyebrow {
font-family: var(--font-fragment-mono);
font-size: 9px;
font-weight: 500;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #A8A29E;
margin-top: -4px;
}
.atelier-drop-title {
font-family: var(--font-fraunces);
font-style: italic;
font-size: 18px;
font-variation-settings: "opsz" 14, "SOFT" 100;
color: #57534E;
text-align: center;
line-height: 1.2;
}
.atelier-drop-hint {
font-family: var(--font-manrope);
font-size: 11px;
color: #A8A29E;
letter-spacing: 0.01em;
}
/* Status pill in corner of image */
.atelier-status-pill {
position: absolute;
top: 14px;
right: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px 5px 8px;
background: rgba(255, 255, 255, 0.95);
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;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
z-index: 2;
}
.atelier-status-pill .atelier-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: #16A34A;
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
}
.atelier-status-pill.is-empty .atelier-dot {
background: #D6D3D1;
box-shadow: 0 0 0 2px rgba(214, 211, 209, 0.2);
}
.atelier-status-pill.is-empty {
color: #A8A29E;
}
/* Modal enter animation */
@keyframes atelier-enter {
from { opacity: 0; transform: translateY(20px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes atelier-backdrop {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes atelier-stagger {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.atelier-enter {
animation: atelier-enter 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.atelier-backdrop {
animation: atelier-backdrop 300ms ease both;
}
.atelier-stagger > * {
animation: atelier-stagger 480ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.atelier-stagger > *:nth-child(1) { animation-delay: 80ms; }
.atelier-stagger > *:nth-child(2) { animation-delay: 140ms; }
.atelier-stagger > *:nth-child(3) { animation-delay: 200ms; }
.atelier-stagger > *:nth-child(4) { animation-delay: 260ms; }
.atelier-stagger > *:nth-child(5) { animation-delay: 320ms; }
.atelier-stagger > *:nth-child(6) { animation-delay: 380ms; }
.atelier-stagger > *:nth-child(7) { animation-delay: 440ms; }
/* Primary CTA — gradient with subtle inner highlight */
.atelier-cta {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px 22px;
font-family: var(--font-manrope);
font-size: 14px;
font-weight: 600;
letter-spacing: -0.005em;
color: #FFFFFF;
background: linear-gradient(135deg, #14532D 0%, #1F6B3E 50%, #14532D 100%);
background-size: 200% 100%;
background-position: 0% 50%;
border: 0;
border-radius: 10px;
cursor: pointer;
box-shadow:
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(0, 0, 0, 0.10);
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.atelier-cta:hover {
background-position: 100% 50%;
box-shadow:
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(0, 0, 0, 0.10);
transform: translateY(-1px);
}
.atelier-cta:active {
transform: translateY(0) scale(0.985);
}
.atelier-cta:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
background: #57534E;
box-shadow: none;
}
.atelier-cta .atelier-cta-shimmer {
position: absolute;
top: 0;
left: -100%;
width: 60%;
height: 100%;
background: linear-gradient(105deg, transparent 30%, rgba(255, 255, 255, 0.18) 50%, transparent 70%);
pointer-events: none;
transition: left 700ms cubic-bezier(0.4, 0, 0.2, 1);
}
.atelier-cta:hover .atelier-cta-shimmer {
left: 130%;
}
/* Discard button */
.atelier-discard {
font-family: var(--font-fraunces);
font-style: italic;
font-size: 14px;
font-variation-settings: "opsz" 14, "SOFT" 100;
color: #786B53;
background: transparent;
border: 0;
padding: 8px 6px;
cursor: pointer;
transition: color 180ms ease;
}
.atelier-discard:hover {
color: #1C1917;
}
/* Brand selector */
.atelier-select {
font-family: var(--font-manrope);
font-size: 15px;
font-weight: 500;
color: #1C1917;
background: transparent;
border: 0;
border-bottom: 1.5px solid rgba(28, 25, 23, 0.12);
border-radius: 0;
padding: 10px 24px 10px 0;
width: 100%;
outline: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23786B53' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right center;
transition: border-color 220ms ease;
}
.atelier-select:hover { border-bottom-color: rgba(28, 25, 23, 0.28); }
.atelier-select:focus { border-bottom-color: #224E2F; border-bottom-width: 2px; padding-bottom: 9.5px; }
/* Hint / help text */
.atelier-hint {
font-family: var(--font-fraunces);
font-style: italic;
font-size: 12px;
font-variation-settings: "opsz" 14, "SOFT" 100;
color: #A8A29E;
line-height: 1.4;
margin-top: 6px;
}
/* Error banner */
.atelier-error {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
background: linear-gradient(135deg, rgba(185, 28, 28, 0.06) 0%, rgba(185, 28, 28, 0.03) 100%);
border: 1px solid rgba(185, 28, 28, 0.25);
border-radius: 10px;
font-family: var(--font-manrope);
font-size: 13px;
color: #991B1B;
}
.atelier-error svg { flex-shrink: 0; margin-top: 1px; }
+1 -1
View File
@@ -36,7 +36,7 @@ export default function ErrorPage({
<h1 className="text-4xl font-black text-white mb-4">Our Story Unavailable</h1> <h1 className="text-4xl font-black text-white mb-4">Our Story Unavailable</h1>
<p className="text-blue-100 mb-8 leading-relaxed"> <p className="text-blue-100 mb-8 leading-relaxed">
We couldn't load our story page. Please try again. We couldn&apos;t load our story page. Please try again.
</p> </p>
{process.env.NODE_ENV === "development" && ( {process.env.NODE_ENV === "development" && (
@@ -75,7 +75,7 @@ export default function IndianRiverContactPage() {
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Get in Touch</p> <p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Get in Touch</p>
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight">Contact Us</h1> <h1 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight">Contact Us</h1>
<p className="mt-5 text-lg text-stone-600 max-w-xl mx-auto leading-relaxed"> <p className="mt-5 text-lg text-stone-600 max-w-xl mx-auto leading-relaxed">
Questions about citrus, your order, or becoming a wholesale partner? We'd love to hear from you. Questions about citrus, your order, or becoming a wholesale partner? We&apos;d love to hear from you.
</p> </p>
</motion.div> </motion.div>

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