46 Commits

Author SHA1 Message Date
tyler 3f731f7739 fix(admin): wrap post-auth data loads in try/catch to surface and survive SC render errors
Deploy to route.crispygoat.com / deploy (push) Successful in 3m9s
The /admin page was throwing a Server Components render error (digest
4266906817) in production. The exact throw point was not visible from
the browser (Next.js hides it for security) and server logs were not
available at debugging time.

The layout and page both call data-loading functions (getActiveBrandId,
listBrandsForAdmin, supabase brands lookup) that could throw on a
transient DB/network failure, and these calls had no try/catch. A
single failed call would crash the entire admin shell.

Wrap each call in try/catch with console.error so:
  1. The page renders with sensible defaults if a call fails
  2. The actual error is logged server-side (visible via the digest in
     the admin error boundary or PM2/Docker logs)
  3. The admin shell stays functional even if a single data source is
     down

No behavior change on the happy path.
2026-06-06 22:35:24 +00:00
tyler 5654ebaecd chore(auth): remove legacy rc_auth_uid/rc_uid/upsert_admin_user path; scope sign-in to ADMIN_ALLOWED_EMAILS; silence lockfile warning
Deploy to route.crispygoat.com / deploy (push) Successful in 3m14s
Cleanup after Auth.js v5 became the only sign-in path. The platform
had three overlapping auth modes (dev cookie, legacy rc_auth_uid, Auth.js
JWT) and a pile of dead-code pages/routes that only existed to support
the legacy path.

What changed:

* getAdminUser() now has only two auth paths:
    1. dev_session cookie (auto-issued by src/proxy.ts for /admin/* when
       ALLOW_DEV_LOGIN is enabled)
    2. Auth.js v5 JWT (the encrypted cookie + auth() lookup)
  The legacy rc_auth_uid/rc_uid branch and the Supabase REST fetch
  against admin_users are gone.

* The signIn callback in src/lib/auth.ts enforces ADMIN_ALLOWED_EMAILS
  when set. Unset = open mode (backward compatible with demo/dev). Dev
  credentials provider is exempt. The new env var is wired through
  .env.example and .gitea/workflows/deploy.yml (read from
  secrets.ADMIN_ALLOWED_EMAILS, written to the server .env file).

* change-password/page.tsx now uses auth() server-side instead of
  fetching the deleted /api/auth/uid endpoint. The form is split into
  page.tsx (server component, auth check) + ChangePasswordForm.tsx
  (client component, form state). updatePasswordAction now reads the
  user id from auth() instead of the rc_auth_uid cookie.

* Deleted 14 dead-code files:
    - Pages: login2, logout, auth/callback, admin/debug-auth,
      admin/test-auth
    - API routes: api/login, api/logout, api/auth/uid, api/force-admin,
      api/set-auth-cookie, api/debug-cookie, api/debug-me,
      api/debug-auth
    - Actions: src/actions/login.ts
  These were the old email/password login, the old Supabase OAuth
  callback, the old /api/auth/uid probe, and a pile of debug endpoints
  that have been superseded by the new proxy + the new /login page.

* next.config.ts: set outputFileTracingRoot: '.' to silence the
  Next.js 16 lockfile-inference warning. Without this the build
  walked up from package.json looking for a lockfile, found the
  homelab runner's stale act cache at /home/tyler/.cache/act/.../package-lock.json,
  and warned on every build. '. resolves to the project root in both
  dev and CI, so it's the right answer.

Out of scope (deferred):

* src/actions/admin/users.ts still uses rc_auth_uid internally for its
  dev-bypass logic. It works (the rc_auth_uid branch is gated on
  NODE_ENV != 'production' and DEV_FORCE_UID), but it's now genuinely
  unreachable in production. Clean up in a follow-up.

Pre-flight:
* npx tsc --noEmit: clean
* npm run lint (touched files): clean
* npm run build: clean — proxy picked up, no lockfile warning, all
  93 static pages generated.
2026-06-06 22:13:56 +00:00
tyler 1cecbce392 fix(auth): port dev auto-login to Next.js 16 proxy.ts
Deploy to route.crispygoat.com / deploy (push) Successful in 3m8s
Next.js 16 renamed middleware.ts to proxy.ts and only allows one of the
two. Delete src/middleware.ts and rewrite src/proxy.ts to do the dev
auto-login + /login bounce + /admin gate explicitly (no NextAuth auth()
wrapper — auth.handler() returns a NextMiddleware taking (req, event),
which makes wrapping it awkward when we also need to set cookies on the
response).

Drop the now-dead 'authorized' callback from auth.config.ts (it was only
fired by the NextAuth wrapper, which we no longer use).

Migration 209: add a defensive unique constraint on admin_users.user_id
so the ON CONFLICT (user_id) clause in the new RPC resolves regardless
of whether the Supabase-dashboard-created table shipped with one.
2026-06-06 21:52:19 +00:00
tyler b63d0415ab fix(deploy): seed docker-compose.yml before any docker compose cmd
Deploy to route.crispygoat.com / deploy (push) Failing after 1m7s
The 'cp -f deploy/docker-compose.yml ...' was sitting AFTER
'docker compose down' in the step. 'docker compose down' reads
and validates the compose file on the server — so the old copy
(with the dead nextjs service and its env_file: ../.env.production
reference) was still being read, and docker compose bailed on the
missing .env.production file before the copy could overwrite it.

Moved the config-file seeding to the top of the step, right after
'mkdir -p $APP_DIR'. Now the new compose file is in place before
either 'docker compose down' or 'docker compose up' runs.
2026-06-06 21:29:21 +00:00
tyler e2e56252ec fix(deploy): always copy docker-compose.yml to server
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
The 'Start Docker stack' step used '[ -f ... ] || cp' for
docker-compose.yml, which only copied the file on the first
deploy. Subsequent deploys kept the stale copy on the server.

The stale copy still had the dead 'nextjs' service with
'env_file: ../.env.production', which docker compose validates
on every 'up' and bailed because .env.production is written
later by the 'Deploy' step.

Changed to unconditional 'cp -f' so the server always has the
latest compose file.
2026-06-06 20:48:43 +00:00
tyler 48ce5665b9 docs(memory): deploy fix notes
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-06 20:47:15 +00:00
tyler 2d55791458 fix(deploy): PostgREST env + remove dead nextjs service from compose
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
Build was failing on the 'Start Docker stack' step with two issues:

1. PGRST_DB_URI not set — the env var was only in the 'Deploy' step,
   which runs after PostgREST has already started. PostgREST booted
   with a blank DB URI and the step exited 1.

2. docker-compose.yml had a 'nextjs' service with
   env_file: ../.env.production, but .env.production is written
   later by the 'Deploy' step. docker compose validates the entire
   compose file on 'up' and bailed because the path didn't exist
   yet.

   The 'nextjs' service is dead code anyway: PM2 runs Next.js
   directly from $APP_DIR, never through docker. Removed it.

Also fixed: 'docker compose up -d db postgrest minio minio_init'
referenced services that don't exist in the compose file (Postgres
runs on the host, not in docker). Changed to just 'postgrest', and
the pg_isready check now uses host psql directly instead of
'docker compose exec -T db'.

Changes:
- deploy/docker-compose.yml: drop nextjs service, keep only postgrest
- .gitea/workflows/deploy.yml:
  - Add PGRST_DB_URI / PGRST_DB_ANON_ROLE / PGRST_SERVER_PORT to
    the 'Start Docker stack' step env
  - Write them to $APP_DIR/.env so docker compose picks them up
  - 'docker compose up -d postgrest' (was: db postgrest minio minio_init)
  - pg_isready check uses host psql (was: docker compose exec -T db)
2026-06-06 20:46:52 +00:00
tyler 6c5ca6829f docs(memory): Auth.js v5 wiring notes
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-06 20:31:13 +00:00
tyler 1e9f9c0414 feat(auth): wire getAdminUser() to Auth.js v5 Google sign-in
Deploy to route.crispygoat.com / deploy (push) Failing after 2s
After a user signs in with Google, they land on /admin but see
'Your account does not have admin access' because getAdminUser()
only checked the legacy dev_session and rc_auth_uid cookies.

This completes the Auth.js path:

- New src/lib/db.ts: shared pg.Pool singleton (extracted from
  src/lib/auth.ts). The single connection pool for the whole app
  — server actions, API routes, and Auth.js all import from here.

- src/lib/auth.ts: imports the shared pool, signIn event now calls
  the new upsert_admin_user_for_authjs RPC (idempotent) to
  auto-create a platform_admin row on first sign-in.

- New supabase/migrations/209_authjs_auto_create_admin.sql:
  - Defensive ALTER TABLE ADD COLUMN IF NOT EXISTS for
    can_manage_settings (was likely dashboard-added, not in any
    tracked migration)
  - SECURITY DEFINER RPC upsert_admin_user_for_authjs(p_user_id UUID)
    that inserts a platform_admin row with all permissions true
    and ON CONFLICT (user_id) DO NOTHING
  - NOTIFY pgrst to reload PostgREST schema cache

- src/lib/admin-permissions.ts: new Auth.js session check between
  dev_session and rc_auth_uid. Uses auth() from @/lib/auth to
  decrypt the JWT cookie server-side, then getAdminUserFromPool()
  queries admin_users + admin_user_brands via the shared pool.
  Legacy rc_auth_uid path unchanged (deferred).

- src/middleware.ts: recognizes Auth.js session cookies
  (authjs.session-token and __Secure-authjs.session-token) at the
  edge so signed-in users aren't bounced to /login.

Flow after this change:
  Dev/demo:  visit /admin → middleware auto-issues dev_session → in
  Prod:      click Google → Auth.js OAuth → signIn event creates
             admin_users row → redirect to /admin → getAdminUser()
             reads JWT, queries pool, returns platform_admin.
2026-06-06 20:30:11 +00:00
tyler 53d995fc99 docs(memory): login flow consolidation notes
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-06 20:14:44 +00:00
tyler e499139c74 fix(login): one-button Google sign-in + middleware auto-issues dev cookie
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
The dev login flow was redirecting back to /login because:
  - src/middleware.ts didn't exist, so the Auth.js authorized
    callback in auth.config.ts never ran
  - Even if it had, it only checked the Auth.js JWT, not dev_session
  - Clicking the demo buttons set the cookie via document.cookie,
    but the admin layout (via getAdminUser) was the only thing
    reading it — no edge gate

Fix:
  - New src/middleware.ts: gates /admin/* and /login at the edge.
    Auto-issues dev_session=platform_admin when ALLOW_DEV_LOGIN is
    enabled (default on, set to 'false' in prod). No buttons, no
    client-side cookie games.
  - LoginClient.tsx: stripped to a single Google OAuth button.
    Removed email/password form, dev credentials form, and the
    /login?demo=1 three-button picker.
  - Removed signInWithDev from auth-signin.ts (no longer called).
  - Removed dead /dev-login page and /api/dev-login route.

Net result: one sign-in path (Google), invisible dev auto-login
via middleware, no more three modes.
2026-06-06 20:14:08 +00:00
tyler 7489da3da0 docs(memory): record successful build + production prep checklist
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-06 19:58:26 +00:00
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
132 changed files with 15973 additions and 2752 deletions
+89
View File
@@ -0,0 +1,89 @@
# ============================================================================
# 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
# Comma-separated list of email addresses allowed to sign in via Google
# OAuth. If unset (or empty), any Google account can sign in and gets a
# `platform_admin` row auto-created — fine for demo/dev. Set this in
# production to lock sign-in down to a known set of admins.
# Example: ADMIN_ALLOWED_EMAILS=tyler@example.com,sarah@example.com
ADMIN_ALLOWED_EMAILS=
# ── 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
+325
View File
@@ -0,0 +1,325 @@
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 }}
# PostgREST — needs the DB URI at start time (it reads env
# from the container, not from .env.production which is
# written later by the Deploy step).
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
run: |
APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR
# Seed config files into APP_DIR FIRST, before any docker compose
# command. The `docker compose down` below validates the compose
# file (including `env_file` paths) — if the old copy is still
# on the server with a broken `env_file`, the step fails before
# we get a chance to overwrite it.
# - docker-compose.yml: copied UNCONDITIONALLY so deploys pick
# up compose changes. The previous `[ -f ... ] ||` guard
# kept stale copies on the server.
# - .env.example: copied on first deploy only (it's a template;
# the real `.env` is built from it below).
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
cp -f deploy/docker-compose.yml $APP_DIR/docker-compose.yml
# 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
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 "PGRST_DB_URI=${PGRST_DB_URI}"
echo "PGRST_DB_ANON_ROLE=${PGRST_DB_ANON_ROLE:-anon}"
echo "PGRST_SERVER_PORT=${PGRST_SERVER_PORT:-3000}"
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.
# Only `postgrest` lives in docker; Postgres itself runs on the
# host (see the migrations step below, which uses
# `psql -h 127.0.0.1`).
docker compose up -d --force-recreate postgrest
# Wait for Postgres to accept connections on the host.
# The DB is on 127.0.0.1, not in a docker service.
for i in $(seq 1 30); do
if PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "SELECT 1" > /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 }}
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
# 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 }}
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
# 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 "ADMIN_ALLOWED_EMAILS=%s\n" "$ADMIN_ALLOWED_EMAILS"
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"
+59 -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,14 +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.
**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. **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.
No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`). 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.
--- ---
@@ -41,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:
@@ -55,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
@@ -182,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
@@ -200,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
@@ -217,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` |
@@ -238,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`.
+254 -9
View File
@@ -2,11 +2,40 @@
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations. This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
**Last updated:** 2026-06-03 (during Supabase migration apply session) **Last updated:** 2026-06-06 (Supabase → Postgres pivot)
--- ---
## Supabase CLI + Migrations Tooling ## 🚨 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) ### Login + Link (done in this session)
- User ran `supabase login` - User ran `supabase login`
@@ -35,15 +64,23 @@ Key changes:
- Falls back to direct `pg` only if CLI path fails. - Falls back to direct `pg` only if CLI path fails.
- Header comments updated with current recommended workflow. - Header comments updated with current recommended workflow.
**Recommended commands now:** **Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):**
```bash ```bash
# Supabase CLI path (legacy — do not use going forward)
supabase login supabase login
supabase link --project-ref wnzkhezyhnfzhkhiflrp supabase link --project-ref wnzkhezyhnfzhkhiflrp
node supabase/push-migrations.js 148 # or any prefix node supabase/push-migrations.js 148 # CLI path
# or # or
npm run migrate:one 148 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). `npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
--- ---
@@ -133,19 +170,23 @@ Verification queries (post-apply) confirmed:
--- ---
## Current State / Gotchas ## Current State / Gotchas (2026-06-06)
- `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. Only early ones (001/002) + many timestamped migrations from other activity are tracked. - 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.
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project. - 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.)
- When adding **new** migrations in the future, prefer the standard `supabase migration new` flow if possible, but the custom `push-migrations.js` + numeric prefix style is still the established pattern here. - `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.
- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies. - 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. - 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 ## How to Use This Memory
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md` - 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. - Update this file with new key facts, applied migrations, or new gotchas.
- Feel free to add dated sections. - Feel free to add dated sections.
@@ -245,3 +286,207 @@ Follow-up pass on the original Codex review covering public site, buyer path, bi
### Migration 203 — applied via Supabase CLI ### 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. `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.
## Build green — 2026-06-06
Push `32396af` to `origin/main` triggered a successful Gitea deploy. Fixes that landed:
- `force-dynamic` on `src/app/admin/layout.tsx` + `src/app/admin/settings/square-sync/page.tsx`
- try/catch around Supabase REST fetches in `src/actions/stops.ts` and `src/actions/brand-settings.ts`
- `.gitea/workflows/deploy.yml` paths updated to `deploy/docker-compose.yml`
## Production prep — next steps
1. **Verify the stack is actually running.** SSH to the deploy host, `docker compose -p prod-app ps` in `$APP_DIR` (`/home/tyler/route-commerce`). All services should be `healthy`.
2. **Test Postgres connectivity.** `docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c '\dt'` should list tables from migrations. `curl http://localhost:$POSTGREST_HOST_PORT/` should return PostgREST's OpenAPI spec.
3. **Test app → PostgREST.** Hit any public page that reads from PostgREST (e.g. `/indian-river-direct/stops` after the revalidate window). If it returns stops, the chain works.
4. **Replace dummy secrets** in Gitea:
- `NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321` + `NEXT_PUBLIC_SUPABASE_ANON_KEY=dummy-supabase-anon-ke` — either set real Supabase project values, or remove entirely once the Postgres-direct migration is complete (CLAUDE.md direction).
- `RESEND_API_KEY=re_REPLACE_ME`, `RESEND_WEBHOOK_SECRET=whsec_REPLACE_ME` — get real values from Resend dashboard.
- `STRIPE_SECRET_KEY=sk_test_REPLACE_ME`, `STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_ME`, `STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME` — real test-mode values from Stripe.
5. **Supabase → direct Postgres migration.** The codebase still imports `@supabase/ssr` and `@supabase/supabase-js` in `src/lib/supabase.ts`, `src/lib/supabase/server.ts`, `src/actions/login.ts`, `src/actions/admin/users.ts`, `src/actions/admin/force-login.ts`, `src/actions/wholesale-auth.ts`. CLAUDE.md says these should be purged. The deploy stack already has PostgREST, so the path is: replace `supabase.from(...)` calls with `fetch` to `NEXT_PUBLIC_API_URL/rest/v1/...` or direct `pg` queries, then drop the `@supabase/*` deps.
6. **Auth.js hardening.** `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` aren't in the secret list — the workflow falls back to `BETTER_AUTH_*` names which exist. Set the canonical `AUTH_*` names too so the fallback isn't load-bearing.
## Login flow consolidated — 2026-06-06
Push `e499139` fixes the "dev login redirects back to /login" bug and
removes the three-mode login page.
**Root cause:** `src/middleware.ts` didn't exist, so the `authorized`
callback in `auth.config.ts` never ran at the edge. The demo buttons at
`/login?demo=1` set `dev_session` via `document.cookie`, but nothing at
the edge recognized the cookie — the admin layout's `getAdminUser()` was
the only thing reading it, and if the layout's `force-dynamic` ever
stopped applying, the user would be bounced.
**Fix:**
- **New `src/middleware.ts`** — plain middleware (NOT the `auth()`
wrapper). Gates `/admin/*` and `/login`:
- If `dev_session`, `rc_auth_uid`, or `rc_uid` cookie is present →
`NextResponse.next()`.
- If no auth cookie, on `/admin/*`, and `ALLOW_DEV_LOGIN !== "false"`
(on by default) → set `dev_session=platform_admin` cookie and
`NextResponse.next()`. Invisible auto-login.
- If no auth and dev disabled → redirect to `/login`.
- If authenticated and on `/login` → redirect to `/admin`.
- **`src/app/login/LoginClient.tsx`** — stripped to a single Google
OAuth button. Removed:
- Email/password form (was hitting dummy Supabase and 500'ing).
- Dev credentials form (`signInWithDev`).
- `DemoMode` component with the three buttons (Platform Admin,
Brand Admin, Store Employee).
- `useState`/`useEffect`/`useCallback`/`useSearchParams`/`Suspense`
— none of that complexity is needed for a single button.
- **`src/actions/auth-signin.ts`** — removed `signInWithDev`. Kept
`signInWithGoogle` and `signOutAction`.
- **Deleted `src/app/dev-login/page.tsx`** and
**`src/app/api/dev-login/route.ts`** — dead routes, middleware
handles it.
**What "one way to log in" looks like now:**
- Dev/demo: visit `/admin` → middleware sets `dev_session` cookie →
`getAdminUser()` returns platform_admin → you're in.
- Production: visit `/admin` → no cookie, `ALLOW_DEV_LOGIN=false` →
redirect to `/login` → click Google → Auth.js OAuth flow.
**Note for Auth.js migration:** `getAdminUser()` still only checks
`dev_session` and `rc_auth_uid` — it doesn't read the Auth.js JWT.
After Google sign-in succeeds, the user has a valid Auth.js session
but `getAdminUser()` returns null. The middleware can't fix that
because it can't write to the JWT without going through the
credentials provider. This is the next piece of the Auth.js migration
(see CLAUDE.md "Auth.js migration — in progress"). The current fix
gets the dev/demo path working; the Google OAuth → admin path needs
the `getAdminUser()` Auth.js check wired up.
## Auth.js v5 wiring complete — 2026-06-06
Push `1e9f9c0` completes the Auth.js path so Google sign-in lands the
user on `/admin` as a real admin (not "Your account does not have
admin access").
**What landed:**
- **`src/lib/db.ts` (NEW)** — shared `pg.Pool` singleton. The single
connection pool for the whole app. Extracted from `src/lib/auth.ts`
(which had its own private pool). Connection string resolution:
`DATABASE_URL` → `SUPABASE_DB_URL` → `POSTGRES_URL`.
- **`src/lib/auth.ts`** — imports the shared pool. The `signIn` event
now calls the new `upsert_admin_user_for_authjs` RPC instead of
the no-op existence check it had before.
- **`supabase/migrations/209_authjs_auto_create_admin.sql` (NEW)** —
pushed automatically by the deploy workflow (line 130 of
`.gitea/workflows/deploy.yml` does `cat supabase/migrations/[0-9]*.sql
| $PG`). Contains:
- `ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS
can_manage_settings BOOLEAN NOT NULL DEFAULT false` — defensive,
since this column is in the TypeScript `AdminUser` type but not
in any tracked migration (was likely dashboard-added).
- SECURITY DEFINER RPC `upsert_admin_user_for_authjs(p_user_id UUID)`
that inserts a `platform_admin` row with all `can_manage_*` flags
true, `ON CONFLICT (user_id) DO NOTHING`.
- `NOTIFY pgrst, 'reload schema'` so PostgREST picks up the new RPC.
- **`src/lib/admin-permissions.ts`** — new Auth.js session check
between `dev_session` and `rc_auth_uid`. Uses `auth()` from
`@/lib/auth` to decrypt the JWT cookie server-side, then
`getAdminUserFromPool()` queries `admin_users` + `admin_user_brands`
via the shared pool. The legacy `rc_auth_uid` path is unchanged
(deferred — it still hits the dummy Supabase URL in prod).
- **`src/middleware.ts`** — recognizes `authjs.session-token` and
`__Secure-authjs.session-token` cookies at the edge so signed-in
users aren't bounced to `/login`.
**Key insight: same ID space.** Both `admin_users.user_id` (UUID, per
`028_fix_caller_uid_type.sql`) and Auth.js `users.id` (UUID, per
`204_authjs_tables.sql:18`) are in the same UUID space. The
`@auth/pg-adapter` auto-generates a fresh UUID per new user on first
sign-in; the Google `sub` claim is stored separately in
`accounts."providerAccountId"`. So no schema change was needed —
just a `user_id` lookup in `getAdminUserFromPool()`.
**Full sign-in flow now:**
1. Dev/demo: visit `/admin` → middleware auto-issues `dev_session`
cookie → `getAdminUser()` returns platform_admin. (No DB call.)
2. Production: click "Sign in with Google" → Auth.js OAuth →
`signIn` event fires → `upsert_admin_user_for_authjs` creates
the `admin_users` row → redirect to `/admin` → `getAdminUser()`
reads JWT, queries pool via `auth.js.user.id`, returns
platform_admin.
**What's still broken (out of scope for this push):**
- Legacy `rc_auth_uid` path in `getAdminUser()` still fetches from
`${NEXT_PUBLIC_SUPABASE_URL}/rest/v1/...` which is a dummy
`http://localhost:54321` in prod. Any pre-existing user with a
`rc_auth_uid` cookie will get null. Defer until the Supabase →
direct Postgres migration of the REST calls.
- `getCurrentAdminUser` (client-side variant) still reads from
server-passed props — no change needed.
- The `signIn` event RPC call will fail silently if `DATABASE_URL`
is not set. The user would see "Your account does not have admin
access" and need to sign out and back in once the env is fixed.
## Deploy fix — PostgREST env + dead nextjs service — 2026-06-06
Push `2d55791` fixes two issues that broke the "Start Docker stack" step:
1. **`PGRST_DB_URI` not set** — the env var was only in the "Deploy"
step's env, which runs after PostgREST has already started.
PostgREST booted with a blank DB URI. Now set in the "Start
Docker stack" step's env and written to `$APP_DIR/.env` (the
file docker compose auto-loads).
2. **`docker-compose.yml` had a dead `nextjs` service** with
`env_file: ../.env.production`. That file is written by the
"Deploy" step (later in the workflow), so at "Start Docker stack"
time the path doesn't exist. `docker compose up` validates the
whole compose file and bailed.
The `nextjs` service is dead code anyway — PM2 runs Next.js
directly from `$APP_DIR`, never through docker. Removed it.
**Other fixes in the same push:**
- `docker compose up -d db postgrest minio minio_init` referenced
services that don't exist in the compose file. Postgres runs on
the host (the migrations step uses `psql -h 127.0.0.1`), not in
docker. Changed to just `postgrest`.
- The `pg_isready` check was `docker compose exec -T db pg_isready`.
Since `db` is a host service, changed to
`PGPASSWORD=... psql -h 127.0.0.1 -U ... -d ... -c "SELECT 1"`.
**Architecture (now consistent):**
- Postgres: host (127.0.0.1:5432), migrations via `psql -h 127.0.0.1`
- PostgREST: docker, connects to host Postgres via `PGRST_DB_URI`
- Next.js: host, PM2 process, reads `DATABASE_URL` from `.env.production`
- MinIO: not yet wired up (the `MINIO_ROOT_USER`/`PASSWORD` env vars
are written to `.env` but no service consumes them yet — add a
`minio` service to docker-compose.yml when storage goes live)
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
+38
View File
@@ -0,0 +1,38 @@
# =============================================================================
# docker-compose.yml — production stack consumed by deploy.sh
# =============================================================================
#
# Only `postgrest` lives in docker. Postgres itself runs on the host
# (the deploy workflow applies migrations via `psql -h 127.0.0.1`).
# Next.js runs under PM2 on the host — it is NOT a docker service.
#
# The host-side port (POSTGREST_HOST_PORT) is written by the deploy
# workflow into $APP_DIR/.env. We interpolate from there with
# ${VAR:-3011} so a manual `docker compose up` without the deploy
# script still works.
# =============================================================================
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
+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;
}
}
@@ -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",
],
};
+11
View File
@@ -1,6 +1,17 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Lock the file-tracing root to the project directory. Without this,
// Next.js 16 walks up from package.json looking for a lockfile, finds
// the homelab runner's stale `act` cache at
// /home/tyler/.cache/act/.../package-lock.json, and warns:
// "We detected multiple lockfiles and selected the directory of
// /home/tyler/package-lock.json as the root directory."
// The deploy runner's APP_DIR is /home/tyler/route-commerce, so
// resolving relative to the project root is correct both locally and
// in CI.
outputFileTracingRoot: ".",
// Enable strict mode // Enable strict mode
reactStrictMode: true, reactStrictMode: true,
+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": "{}"
} }
+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()
+14 -8
View File
@@ -1,18 +1,24 @@
"use server"; "use server";
import { cookies } from "next/headers"; import { auth } from "@/lib/auth";
import { createClient as createServiceClient } from "@supabase/supabase-js"; import { createClient as createServiceClient } from "@supabase/supabase-js";
/**
* Update the password for the currently signed-in admin.
*
* Identity comes from the Auth.js session (`auth().user.id`), which is
* the same UUID space as `admin_users.user_id` and `auth.users.id` in
* Postgres. The legacy `rc_auth_uid` / `rc_uid` cookie fallback has
* been removed — the Auth.js JWT is the single source of truth.
*/
export async function updatePasswordAction( export async function updatePasswordAction(
newPassword: string newPassword: string
): Promise<{ error?: string }> { ): Promise<{ error?: string }> {
const cookieStore = await cookies(); const session = await auth();
const uid = const userId = session?.user?.id;
cookieStore.get("rc_auth_uid")?.value ??
cookieStore.get("rc_uid")?.value;
if (!uid) { if (!userId) {
return { error: "Not authenticated. Please log in again." }; return { error: "Not authenticated. Please sign in again." };
} }
const service = createServiceClient( const service = createServiceClient(
@@ -21,7 +27,7 @@ export async function updatePasswordAction(
); );
const { error } = await service.rpc("update_user_password", { const { error } = await service.rpc("update_user_password", {
p_user_id: uid, p_user_id: userId,
p_password: newPassword, p_password: newPassword,
}); });
+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 { 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" };
} }
+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",
+32
View File
@@ -0,0 +1,32 @@
"use server";
import { signIn, signOut } from "@/lib/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>
*
* Note: dev/demo authentication is no longer a button on the login page.
* `src/middleware.ts` auto-issues the `dev_session` cookie for /admin/*
* when ALLOW_DEV_LOGIN is enabled. See CLAUDE.md.
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
}
@@ -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);
+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 };
}
-56
View File
@@ -1,56 +0,0 @@
"use server";
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
export type LoginWithPasswordResult =
| { success: true; redirect: true }
| { success: false; error: string };
export async function loginWithPassword(
email: string,
password: string
): Promise<LoginWithPasswordResult> {
const cookieStore = await cookies();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return { success: false, error: "Server misconfiguration." };
}
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
},
},
});
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error || !data.user) {
return { success: false, error: error?.message || "Invalid credentials" };
}
// Set the rc_auth_uid cookie that getAdminUser() reads
const isProd = process.env.NODE_ENV === "production";
cookieStore.set("rc_auth_uid", data.user.id, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: "lax",
secure: isProd,
});
return { success: true, redirect: true };
}
+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";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
@@ -41,7 +42,11 @@ export async function createAdminOrder(
} }
// Brand scoping // Brand scoping
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;
if (adminUser.role === "brand_admin" && adminUser.brand_id && effectiveBrandId !== adminUser.brand_id) { if (adminUser.role === "brand_admin" && adminUser.brand_id && effectiveBrandId !== adminUser.brand_id) {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
+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: [] };
} }
+56 -31
View File
@@ -2,6 +2,7 @@
import { revalidateTag } from "next/cache"; 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 = {
@@ -25,7 +26,11 @@ 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" };
} }
@@ -105,22 +110,31 @@ 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 [];
}
} }
/** /**
@@ -150,24 +164,35 @@ export async function getPublicStopsForBrand(
): Promise<PublicStop[]> { ): Promise<PublicStop[]> {
if (!brandSlug) return []; if (!brandSlug) return [];
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 page render with zero stops; at runtime the
// fetch path is unchanged.
if (!supabaseUrl || !supabaseKey) return [];
const response = await fetch( // Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`, // doesn't crash the prerender — the page just renders with no stops
{ // and revalidates from a real request once the cache is warm.
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_public_stops_for_brand`,
next: { {
revalidate: 300, method: "POST",
tags: ["stops", `brand:${brandSlug}:stops`], 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 []; if (!response.ok) return [];
const stops = await response.json(); const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : []; return Array.isArray(stops) ? (stops as PublicStop[]) : [];
} catch {
return [];
}
} }
+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,
};
}
+9 -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 } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { svcHeaders } from "@/lib/svc-headers";
type Irrigator = { type Irrigator = {
@@ -94,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,
}), }),
@@ -186,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,
}), }),
} }
); );
@@ -215,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,
}), }),
} }
); );
@@ -242,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,
}), }),
} }
); );
@@ -269,7 +270,7 @@ 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,
}), }),
} }
); );
@@ -356,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 }),
} }
); );
@@ -392,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,
}), }),
} }
); );
@@ -421,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),
}), }),
} }
); );
-60
View File
@@ -1,60 +0,0 @@
import { cookies } from "next/headers";
export default async function DebugAuthPage() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
let adminUsersStatus = "not_tried";
let adminUsersResult: string | null = null;
if (rcAuthUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (supabaseUrl && serviceKey) {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
adminUsersStatus = String(res.status);
const data = await res.json().catch(() => null);
adminUsersResult = JSON.stringify(data);
} catch (e) {
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
}
} else {
adminUsersStatus = "missing_env_vars";
}
} else {
adminUsersStatus = "no_rc_auth_uid_cookie";
}
return (
<div className="min-h-screen bg-zinc-950 p-8 text-white">
<h1 className="text-2xl font-bold text-emerald-400 mb-6">Auth Debug</h1>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Cookies ({allCookies.length})</h2>
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
</pre>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Key Cookies</h2>
<p>rc_auth_uid: <span className={rcAuthUid ? "text-emerald-400" : "text-red-400"}>{rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}</span></p>
<p>rc_uid: <span className={rcUid ? "text-emerald-400" : "text-red-400"}>{rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}</span></p>
<p>rc_access_token: <span className={rcAccessToken ? "text-yellow-400" : "text-red-400"}>{rcAccessToken ? "PRESENT" : "NOT SET"}</span></p>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Admin Users Lookup</h2>
<p>Status: <span className="text-lg font-mono">{adminUsersStatus}</span></p>
<p>Result: <pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto mt-2">{adminUsersResult || "(none)"}</pre></p>
</div>
</div>
);
}
+33 -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,34 @@ 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).
// Wrapped in try/catch so a transient brand-resolution failure can't
// crash the whole admin shell — we fall back to null (no active brand).
let activeBrandId: string | null = null;
try {
activeBrandId = await getActiveBrandId(adminUser);
} catch (err) {
console.error("[admin/layout] getActiveBrandId failed:", err);
}
// Fetch accessible brands for the sidebar BrandSelector. Wrapped in
// try/catch so the sidebar renders empty rather than crashing the page
// if the brands query fails.
let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
try {
brands = await listBrandsForAdmin();
} catch (err) {
console.error("[admin/layout] listBrandsForAdmin failed:", err);
}
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>
+13 -6
View File
@@ -1,5 +1,6 @@
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";
@@ -17,13 +18,19 @@ 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)
@@ -41,8 +48,8 @@ export default async function AdminOrdersPage() {
.order("name") .order("name")
.limit(200); .limit(200);
if (adminUser?.brand_id) { if (activeBrandId) {
prodQuery = prodQuery.eq("brand_id", adminUser.brand_id); prodQuery = prodQuery.eq("brand_id", activeBrandId);
} }
const { data: prods } = await prodQuery; const { data: prods } = await prodQuery;
@@ -80,7 +87,7 @@ export default async function AdminOrdersPage() {
initialOrders={brandOrders} initialOrders={brandOrders}
initialStops={brandStops} initialStops={brandStops}
initialProducts={brandProducts} initialProducts={brandProducts}
brandId={adminUser?.brand_id ?? null} brandId={activeBrandId}
/> />
</div> </div>
</div> </div>
+18 -13
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
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 { isFeatureEnabled } from "@/lib/feature-flags"; import { isFeatureEnabled } from "@/lib/feature-flags";
import { getBillingOverview } from "@/actions/billing/billing-overview"; import { getBillingOverview } from "@/actions/billing/billing-overview";
import DashboardClient from "@/components/admin/DashboardClient"; import DashboardClient from "@/components/admin/DashboardClient";
@@ -23,20 +24,24 @@ 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";
// For platform_admin in dev mode, adminUser.brand_id is null. To keep // Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
// the dashboard's "Active Products" stat in sync with the billing page, // > first of brand_ids). For platform_admin in dev mode this is null, so we
// we need to pick a brand and use the same getBillingOverview action // fall back to the first brand in the brands table to keep the dashboard's
// the billing page does. Otherwise the dashboard falls back to default // "Active Products" stat in sync with the billing page. Wrapped in try/catch
// (0/0/0) usage values and contradicts the billing page's "Products 1/25". // so a transient DB/network failure can't crash the whole admin page.
let dashboardBrandId: string | null = adminUser?.brand_id ?? null; let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") { if (!dashboardBrandId && adminUser?.role === "platform_admin") {
const { data: firstBrand } = await supabase try {
.from("brands") const { data: firstBrand } = await supabase
.select("id") .from("brands")
.limit(1) .select("id")
.single(); .limit(1)
if (firstBrand?.id) { .single();
dashboardBrandId = firstBrand.id; if (firstBrand?.id) {
dashboardBrandId = firstBrand.id;
}
} catch (err) {
console.error("[admin/page] supabase brands lookup failed:", err);
} }
} }
+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)" }}>
+6 -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 { getBillingOverview } from "@/actions/billing/billing-overview"; 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;
@@ -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");
+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;
-90
View File
@@ -1,90 +0,0 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export default async function TestAuthPage() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
let adminUser = null;
let error: string | null = null;
try {
adminUser = await getAdminUser();
} catch (e: unknown) {
error = e instanceof Error ? e.message : String(e);
}
return (
<div className="min-h-screen bg-zinc-950 p-8 text-white">
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">All Cookies ({allCookies.length})</h2>
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
</pre>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_auth_uid</h2>
<p className="text-lg font-mono">
{allCookies.some(c => c.name === "rc_auth_uid")
? <span className="text-emerald-400">SET {allCookies.find(c => c.name === "rc_auth_uid")?.value}</span>
: <span className="text-red-400">NOT SET</span>
}
</p>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token</h2>
<p className="text-lg font-mono">
{allCookies.some(c => c.name === "rc_access_token")
? <span className="text-yellow-400">SET (not needed)</span>
: <span className="text-zinc-500">NOT SET (OK)</span>
}
</p>
</div>
<div className="mb-6">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
{error ? (
<div>
<p className="text-red-400 font-bold">ERROR</p>
<pre className="bg-red-950/50 p-4 rounded-xl text-sm mt-2">{error}</pre>
</div>
) : adminUser ? (
<div>
<p className="text-emerald-400 font-bold">AUTHENTICATED</p>
<pre className="bg-black/50 p-4 rounded-xl text-sm mt-2 overflow-auto">
{JSON.stringify({
id: adminUser.id,
user_id: adminUser.user_id,
role: adminUser.role,
brand_id: adminUser.brand_id,
active: adminUser.active,
}, null, 2)}
</pre>
</div>
) : (
<p className="text-red-400">NOT AUTHENTICATED null returned</p>
)}
</div>
<div className="mt-8 pt-4 border-t border-zinc-800">
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Quick Actions</h2>
<div className="flex gap-4">
<a href="/admin" className="px-4 py-2 bg-emerald-600 text-white rounded-xl text-sm font-bold hover:bg-emerald-500">
Go to Admin
</a>
<form action="/api/logout" method="POST">
<button type="submit" className="px-4 py-2 bg-zinc-800 text-white rounded-xl text-sm font-bold hover:bg-zinc-700">
Logout
</button>
</form>
</div>
</div>
</div>
);
}
+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;
-11
View File
@@ -1,11 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const uid =
cookieStore.get("rc_auth_uid")?.value ??
cookieStore.get("rc_uid")?.value ??
null;
return NextResponse.json({ uid });
}
-48
View File
@@ -1,48 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
let adminUsersStatus = "not_tried";
let adminUsersResult: string | null = null;
if (rcAuthUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (supabaseUrl && serviceKey) {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
adminUsersStatus = String(res.status);
const data = await res.json().catch(() => null);
adminUsersResult = JSON.stringify(data);
} catch (e) {
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
}
} else {
adminUsersStatus = "missing_env_vars";
}
} else {
adminUsersStatus = "no_rc_auth_uid_cookie";
}
return NextResponse.json({
cookies: {
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 10)}...` : null,
rc_uid: rcUid ? `${rcUid.slice(0, 10)}...` : null,
rc_access_token: rcAccessToken ? "present" : null,
all_cookie_names: allCookies.map(c => c.name),
},
admin_users: {
status: adminUsersStatus,
result: adminUsersResult,
},
});
}
-18
View File
@@ -1,18 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function GET() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
return NextResponse.json({
received_cookies: allCookies.map(c => ({ name: c.name, value_len: c.value.length })),
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 8)}...` : "MISSING",
rc_uid: rcUid ? `${rcUid.slice(0, 8)}...` : "MISSING",
raw_rc_auth_uid: rcAuthUid ?? null,
});
}
-80
View File
@@ -1,80 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function GET() {
const cookieStore = await cookies();
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
if (!uid) {
return NextResponse.json({ error: "No uid cookie found" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) {
return NextResponse.json({
uid,
error: "Missing env vars",
supabaseUrl: supabaseUrl ?? "MISSING",
serviceKeyPresent: !!serviceKey,
});
}
// Exact same lookup as getAdminUser
const lookupRes = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
let adminUsers: unknown[] = [];
let lookupOk = lookupRes.ok;
let lookupStatus = lookupRes.status;
let lookupData: unknown = null;
if (lookupRes.ok) {
lookupData = await lookupRes.json().catch(() => []);
adminUsers = Array.isArray(lookupData) ? lookupData : [];
} else {
lookupData = await lookupRes.text().catch(() => "unknown error");
}
if (adminUsers.length > 0) {
return NextResponse.json({
uid,
result: "found",
adminUser: adminUsers[0],
});
}
// Try auto-create
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(uid)) {
return NextResponse.json({ uid, result: "invalid_uuid" });
}
const postRes = await fetch(`${supabaseUrl}/rest/v1/admin_users`, {
method: "POST",
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({
user_id: uid, role: "platform_admin", brand_id: null, active: true,
can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true,
can_manage_settings: true, must_change_password: false
}),
});
return NextResponse.json({
uid,
result: "auto_created",
lookupOk,
lookupStatus,
adminUsersFound: adminUsers.length,
postStatus: postRes.status,
postOk: postRes.ok,
postData: postRes.ok ? await postRes.json().catch(() => null) : await postRes.text().catch(() => null),
});
}
-21
View File
@@ -1,21 +0,0 @@
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
response.cookies.set("dev_session", "platform_admin", {
path: "/",
sameSite: "lax",
httpOnly: false,
});
return response;
}
export async function GET() {
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
response.cookies.set("dev_session", "platform_admin", {
path: "/",
sameSite: "lax",
httpOnly: false,
});
return response;
}
-31
View File
@@ -1,31 +0,0 @@
import { NextResponse } from "next/server";
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
const DEV_ROLES = ["platform_admin", "brand_admin", "store_employee"];
export async function GET(request: Request) {
const url = new URL(request.url);
const role = url.searchParams.get("role") ?? "platform_admin";
const safeRole = DEV_ROLES.includes(role) ? role : "platform_admin";
const origin = url.origin;
const response = NextResponse.redirect(new URL("/admin", origin));
const cookieOptions = {
path: "/",
maxAge: 60 * 60 * 24 * 30,
sameSite: "lax" as const,
};
response.cookies.set("dev_session", safeRole, {
...cookieOptions,
httpOnly: false,
});
response.cookies.set("rc_auth_uid", DEV_ADMIN_UID, {
...cookieOptions,
httpOnly: false,
});
return response;
}
-47
View File
@@ -1,47 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST(request: Request) {
const { email, password } = await request.json().catch(() => ({}));
if (!email || !password) {
return NextResponse.json({ error: "Email and password are required." }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
}
const authRes = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { "Content-Type": "application/json", apikey: supabaseAnonKey },
body: JSON.stringify({ email, password }),
});
const authData = await authRes.json().catch(() => null);
if (!authRes.ok || !authData?.access_token) {
const msg = authData?.error_description ?? authData?.error ?? "Invalid credentials.";
return NextResponse.json({ ok: false, error: msg }, { status: 401 });
}
const userId = authData.user?.id ?? authData.user_id;
if (!userId) {
return NextResponse.json({ ok: false, error: "Auth succeeded but user ID missing." }, { status: 500 });
}
// Set cookie + return JSON — client reads this and navigates
const isProd = process.env.NODE_ENV === "production";
const response = NextResponse.json({ ok: true });
response.cookies.set("rc_auth_uid", userId, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: "lax",
secure: isProd,
});
return response;
}
-12
View File
@@ -1,12 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST() {
const cookieStore = await cookies();
// Clear all auth cookies (new + legacy)
cookieStore.delete("rc_access_token");
cookieStore.delete("rc_uid");
cookieStore.delete("rc_auth_uid");
cookieStore.delete("rc_auth_token");
return NextResponse.json({ success: true });
}
-22
View File
@@ -1,22 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST(request: Request) {
const { userId } = await request.json().catch(() => ({}));
if (!userId) {
return NextResponse.json({ error: "Missing userId" }, { status: 400 });
}
const cookieStore = await cookies();
const isProd = process.env.NODE_ENV === "production";
cookieStore.set("rc_auth_uid", userId, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: isProd ? "strict" : "lax",
secure: isProd,
});
return NextResponse.json({ ok: true });
}
-54
View File
@@ -1,54 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
export default function AuthCallback() {
const router = useRouter();
useEffect(() => {
const url = new URL(window.location.href);
// Supabase sends token via query params (not hash) on redirect
const accessToken = url.searchParams.get("token") || url.searchParams.get("access_token");
const type = url.searchParams.get("type");
const error = url.searchParams.get("error");
if (error) {
router.replace(`/login?error=${error}`);
return;
}
if (!accessToken) {
router.replace("/login?error=no_token");
return;
}
// Validate token by fetching user info from Supabase
fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/user`, {
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
Authorization: `Bearer ${accessToken}`,
},
})
.then(r => r.json())
.then(data => {
if (data?.id) {
// Set rc_auth_uid cookie via API route
return fetch("/api/set-auth-cookie", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: data.id }),
});
}
throw new Error("No user ID in response");
})
.then(() => router.replace("/admin"))
.catch(() => router.replace("/login?error=token_invalid"));
}, [router]);
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="text-zinc-400">Verifying reset link...</div>
</div>
);
}
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updatePasswordAction } from "@/actions/admin/password";
import { logUserActivity } from "@/actions/admin/audit";
export default function ChangePasswordForm({ userId }: { userId: string }) {
const router = useRouter();
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (password.length < 8) {
setError("Password must be at least 8 characters.");
return;
}
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setLoading(true);
const result = await updatePasswordAction(password);
setLoading(false);
if (result.error) {
setError(result.error);
return;
}
logUserActivity({
user_id: userId,
activity_type: "password_change",
details: {},
}).catch(() => {});
router.push("/admin");
router.refresh();
}
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
<div className="w-full max-w-md">
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8">
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-full bg-amber-900/30 border border-amber-700/40">
<svg
className="h-6 w-6 text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-zinc-100">Change Password</h1>
<p className="mt-1 text-sm text-zinc-500">
You must set a new password before continuing.
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && (
<div className="rounded-xl bg-red-900/20 border border-red-800/50 p-4 text-sm text-red-400">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
New Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
placeholder="Min. 8 characters"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Confirm Password
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
placeholder="Repeat password"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-violet-600 hover:bg-violet-500 px-6 py-4 text-base font-bold text-white disabled:opacity-50 transition-colors"
>
{loading ? "Updating..." : "Update Password"}
</button>
</form>
</div>
</div>
</main>
);
}
+22 -144
View File
@@ -1,150 +1,28 @@
"use client"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import ChangePasswordForm from "./ChangePasswordForm";
import { useEffect, useState } from "react"; export const dynamic = "force-dynamic";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { updatePasswordAction } from "@/actions/admin/password";
import { logUserActivity } from "@/actions/admin/audit";
export default function ChangePasswordPage() { /**
const router = useRouter(); * Forced password-change page.
const [password, setPassword] = useState(""); *
const [confirm, setConfirm] = useState(""); * Linked from `src/app/admin/layout.tsx:64` when the admin user's row
const [error, setError] = useState<string | null>(null); * has `must_change_password = true`. Verifies the Auth.js session
const [loading, setLoading] = useState(false); * server-side, then renders the form with the user id passed as a prop.
const [checkingSession, setCheckingSession] = useState(true); *
const [userId, setUserId] = useState<string | null>(null); * Previously this page fetched `/api/auth/uid` from a `useEffect` to
* resolve the current user. That endpoint (and the underlying
* `rc_auth_uid` cookie) has been removed the Auth.js JWT is the
* single source of truth for identity now.
*/
export default async function ChangePasswordPage() {
const session = await auth();
const userId = session?.user?.id;
useEffect(() => { if (!userId) {
fetch("/api/auth/uid") redirect("/login");
.then((r) => r.json())
.then((data) => {
if (!data.uid) {
router.push("/login");
} else {
setUserId(data.uid);
setCheckingSession(false);
}
})
.catch(() => router.push("/login"));
}, [router]);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (password.length < 8) {
setError("Password must be at least 8 characters.");
return;
}
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setLoading(true);
const result = await updatePasswordAction(password);
setLoading(false);
if (result.error) {
setError(result.error);
return;
}
if (userId) {
logUserActivity({
user_id: userId,
activity_type: "password_change",
details: {},
});
}
router.push("/admin");
router.refresh();
} }
if (checkingSession) { return <ChangePasswordForm userId={userId} />;
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
<div className="w-full max-w-md">
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 text-center">
<p className="text-zinc-500 text-sm">Checking session...</p>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
<div className="w-full max-w-md">
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8">
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-full bg-amber-900/30 border border-amber-700/40">
<svg className="h-6 w-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-zinc-100">Change Password</h1>
<p className="mt-1 text-sm text-zinc-500">
You must set a new password before continuing.
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && (
<div className="rounded-xl bg-red-900/20 border border-red-800/50 p-4 text-sm text-red-400">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
New Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
placeholder="Min. 8 characters"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Confirm Password
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
placeholder="Repeat password"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-violet-600 hover:bg-violet-500 px-6 py-4 text-base font-bold text-white disabled:opacity-50 transition-colors"
>
{loading ? "Updating..." : "Update Password"}
</button>
</form>
<div className="mt-4 border-t border-zinc-800 pt-4">
<Link
href="/logout"
className="block text-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
>
Sign out instead
</Link>
</div>
</div>
</div>
</main>
);
} }
+229 -161
View File
@@ -4,25 +4,36 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; 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);
@@ -33,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!,
@@ -47,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,
@@ -95,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 (
@@ -132,9 +160,7 @@ 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
</h1>
<Link <Link
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"
@@ -147,71 +173,81 @@ export default function CheckoutClient() {
); );
} }
// 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>
@@ -224,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"
/> />
@@ -231,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>
@@ -278,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"
> >
@@ -301,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>
@@ -314,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) => (
@@ -420,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>
+18 -7
View File
@@ -47,12 +47,16 @@ function formatStopDate(dateStr: string): string {
function SuccessContent() { function SuccessContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
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");
// Direct access with order_id — load from sessionStorage in lazy initializer. // Direct access with order_id — load from sessionStorage in lazy initializer.
// searchParams values are stable, and sessionStorage is client-only, so this // searchParams values are stable, and sessionStorage is client-only, so this
// is safe in a client component. // is safe in a client component.
const [order, setOrder] = useState<StoredOrder | null>(() => { const [order, setOrder] = useState<StoredOrder | null>(() => {
if (!orderIdParam || sessionId) return null; if (!orderIdParam || sessionId || paymentIntentId) return null;
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
try { try {
const stored = sessionStorage.getItem(`order_${orderIdParam}`); const stored = sessionStorage.getItem(`order_${orderIdParam}`);
@@ -62,14 +66,20 @@ function SuccessContent() {
return null; return null;
} }
}); });
const [creating, setCreating] = useState(!!sessionId); const [creating, setCreating] = useState(Boolean(sessionId || (paymentIntentId && redirectStatus === "succeeded")));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Stripe redirected back — create order from pending checkout data. // Create the order from the pending-checkout payload, regardless of
// Wrapped in an async IIFE so all setState calls happen inside a callback, // whether the user paid via hosted Stripe Checkout (?session_id=...) or
// not in the synchronous effect body (satisfies set-state-in-effect rule). // embedded Stripe Elements (?payment_intent=...). Both paths store the
// same payload in `pending_checkout` before payment is initiated.
useEffect(() => { useEffect(() => {
if (!sessionId) return; if (!sessionId && !paymentIntentId) return;
if (paymentIntentId && redirectStatus && redirectStatus !== "succeeded") {
setError("Payment was not completed. Please try again.");
setCreating(false);
return;
}
(async () => { (async () => {
const pendingStr = sessionStorage.getItem("pending_checkout"); const pendingStr = sessionStorage.getItem("pending_checkout");
@@ -88,6 +98,7 @@ function SuccessContent() {
cartBrandId?: string; cartBrandId?: string;
shippingAddress?: { state: string; postal_code: string; city?: string }; shippingAddress?: { state: string; postal_code: string; city?: string };
idempotencyKey: string; idempotencyKey: string;
paymentIntentId?: string;
}; };
try { try {
pending = JSON.parse(pendingStr); pending = JSON.parse(pendingStr);
@@ -123,7 +134,7 @@ function SuccessContent() {
setCreating(false); setCreating(false);
} }
})(); })();
}, [sessionId]); }, [sessionId, paymentIntentId, redirectStatus]);
if (!order || !orderIdParam) { if (!order || !orderIdParam) {
return ( return (
-20
View File
@@ -1,20 +0,0 @@
"use client";
export default function DevLoginPage() {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 shadow-xl">
<h1 className="text-2xl font-bold text-zinc-100 mb-4">Dev Login</h1>
<p className="text-zinc-400 mb-6">Click below to login as platform admin:</p>
<form action="/api/dev-login" method="POST">
<button
type="submit"
className="w-full rounded-xl bg-emerald-600 px-6 py-4 text-base font-bold text-white hover:bg-emerald-500 transition-colors"
>
Login as Platform Admin
</button>
</form>
</div>
</div>
);
}
+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; }
+23 -2
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Fraunces, Manrope, Fragment_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Providers } from "@/components/Providers"; import { Providers } from "@/components/Providers";
import ToastNotificationContainer from "@/components/notifications/ToastNotification"; import ToastNotificationContainer from "@/components/notifications/ToastNotification";
@@ -6,6 +7,26 @@ import CookieConsentBanner from "@/components/legal/CookieConsentBanner";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com"; const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
const fraunces = Fraunces({
subsets: ["latin"],
display: "swap",
variable: "--font-fraunces",
axes: ["SOFT", "WONK", "opsz"],
});
const manrope = Manrope({
subsets: ["latin"],
display: "swap",
variable: "--font-manrope",
});
const fragmentMono = Fragment_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-fragment-mono",
weight: "400",
});
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
@@ -50,8 +71,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning className={`${fraunces.variable} ${manrope.variable} ${fragmentMono.variable}`}>
<body> <body className="font-sans">
<Providers>{children}</Providers> <Providers>{children}</Providers>
<ToastNotificationContainer /> <ToastNotificationContainer />
<CookieConsentBanner /> <CookieConsentBanner />
+202 -380
View File
@@ -1,100 +1,82 @@
"use client"; "use client";
import { useState, useEffect, useCallback, Suspense } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { signInWithGoogle } from "@/actions/auth-signin";
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [globalError, setGlobalError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [forgotPassword, setForgotPassword] = useState(false);
const [forgotEmail, setForgotEmail] = useState("");
const [forgotSent, setForgotSent] = useState(false);
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotError, setForgotError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setGlobalError(null);
if (!email.trim()) { setGlobalError("Please enter your email address."); return; }
if (!password.trim()) { setGlobalError("Please enter your password."); return; }
setLoading(true);
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), password }),
});
const data = await res.json().catch(() => ({ error: "Login failed" }));
if (res.ok && data?.ok) {
window.location.replace("/admin");
} else {
setGlobalError(data?.error || `Login failed (${res.status})`);
}
} catch {
setGlobalError("Network error. Please try again.");
} finally {
setLoading(false);
}
}, [email, password]);
const handleForgotPassword = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!forgotEmail.trim()) return;
setForgotLoading(true);
setForgotError(null);
const fd = new FormData();
fd.set("email", forgotEmail.trim());
const result = await fetch("/api/forgot-password", {
method: "POST",
body: fd,
}).then(r => r.json()).catch(() => ({ error: "Network error" }));
setForgotLoading(false);
if (result.error) {
setForgotError(result.error);
} else {
setForgotSent(true);
}
}, [forgotEmail]);
/**
* The login page is a single Google OAuth button.
*
* The three "modes" that used to live here are gone:
* Email/password removed. Hit a dummy Supabase and 500'd.
* Dev credentials form removed. The dev cookie is now issued by
* `src/middleware.ts` when ALLOW_DEV_LOGIN is enabled.
* /login?demo=1 three-button picker removed. Same reason.
*
* Flow:
* In dev / demo: visiting /admin auto-logs you in via the middleware.
* In production: click "Sign in with Google" Auth.js handles OAuth.
*/
export default function LoginClient() {
return ( return (
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}> <main
{/* Google Fonts */} className="min-h-screen flex flex-col relative overflow-hidden"
style={{ backgroundColor: "#faf8f5", height: "100vh" }}
>
<style jsx global>{` <style jsx global>{`
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
html, body { overflow: hidden; } html,
body {
overflow: hidden;
}
`}</style> `}</style>
{/* Organic background elements */} {/* Organic background elements */}
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true"> <div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} /> <div
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} /> className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20"
<div className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10" style={{ background: "radial-gradient(circle, #1a4d2e15 0%, transparent 70%)", filter: "blur(30px)" }} /> style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }}
/>
<div
className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15"
style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }}
/>
<div
className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10"
style={{ background: "radial-gradient(circle, #1a4d2e15 0%, transparent 70%)", filter: "blur(30px)" }}
/>
</div> </div>
{/* Header */} {/* Header */}
<header className="w-full py-6 px-6 lg:px-8"> <header className="w-full py-6 px-6 lg:px-8">
<div className="max-w-7xl mx-auto flex items-center justify-between"> <div className="max-w-7xl mx-auto flex items-center justify-between">
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}> <Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}> <div
className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105"
style={{ backgroundColor: "#1a4d2e" }}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> <path
d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z"
fill="#faf8f5"
stroke="#faf8f5"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg> </svg>
</div> </div>
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}> <span
className="text-xl font-semibold tracking-tight"
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}
>
Route Commerce Route Commerce
</span> </span>
</Link> </Link>
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}> <Link
href="/"
className="text-sm font-medium transition-opacity hover:opacity-60"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
>
Back to home Back to home
</Link> </Link>
</div> </div>
@@ -102,202 +84,128 @@ function LoginForm() {
{/* Login Card */} {/* Login Card */}
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10"> <div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
<div className={`w-full max-w-sm transition-all duration-700 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"}`}> <div className="w-full max-w-sm">
{/* Card */}
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden"> <div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
{/* Subtle top accent */}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" /> <div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" />
<div className="p-8 sm:p-10"> <div className="p-8 sm:p-10">
{/* Logo & Title */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}> <div
className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5"
style={{
background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)",
}}
>
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg> </svg>
</div> </div>
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}> <h1
className="text-3xl font-semibold text-stone-900"
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}
>
Welcome back Welcome back
</h1> </h1>
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}> <p
className="mt-2 text-sm"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
>
Sign in to your account Sign in to your account
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form"> {/* Single sign-in method: Google OAuth via Auth.js */}
{globalError && ( <form action={signInWithGoogle} className="space-y-3">
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 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" />
</svg>
{globalError}
</div>
</div>
)}
<div className="space-y-2">
<label htmlFor="email" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="username"
disabled={loading}
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
placeholder="you@company.com"
aria-required="true"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Password</label>
<div className="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={loading}
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 pr-12 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
placeholder="••••••••"
aria-required="true"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600 p-1 transition-colors"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<button <button
type="submit" type="submit"
disabled={loading} className="w-full inline-flex items-center justify-center gap-3 rounded-xl border border-stone-200/80 bg-white px-6 py-3.5 text-sm font-semibold text-stone-900 shadow-sm transition-all hover:bg-stone-50 active:scale-[0.98]"
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }} aria-label="Sign in with Google"
> >
{loading ? ( <svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<> <path
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"> fill="#4285F4"
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> />
</svg> <path
Signing in... fill="#34A853"
</> d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0 0 12 23z"
) : "Sign in"} />
<path
fill="#FBBC05"
d="M5.84 14.09a6.6 6.6 0 0 1 0-4.18V7.07H2.18a11 11 0 0 0 0 9.86l3.66-2.84z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A10.99 10.99 0 0 0 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"
/>
</svg>
<span>Sign in with Google</span>
</button> </button>
{!forgotPassword && !forgotSent && (
<button
type="button"
onClick={() => { setForgotPassword(true); setGlobalError(null); }}
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
>
Forgot password?
</button>
)}
</form> </form>
{/* Password Reset Form */} <p
{forgotPassword && !forgotSent && ( className="mt-6 text-center text-xs"
<form onSubmit={handleForgotPassword} className="mt-6 space-y-4 border-t border-stone-200/50 pt-6" aria-label="Password reset form"> style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}
<p className="text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b6b6b" }}>Enter your email and we&apos;ll send you a reset link.</p> >
{forgotError && ( By signing in you agree to our{" "}
<div role="alert" className="rounded-xl bg-red-50/80 p-3 text-sm text-red-600 border border-red-100"> <Link href="/terms-and-conditions" className="underline hover:text-stone-700">
{forgotError} Terms
</div> </Link>{" "}
)} and{" "}
<input <Link href="/privacy-policy" className="underline hover:text-stone-700">
type="email" Privacy Policy
value={forgotEmail} </Link>
onChange={(e) => setForgotEmail(e.target.value)} .
required </p>
className="w-full rounded-xl border border-stone-200/80 bg-white/90 px-4 py-3.5 text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400 transition-all"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
placeholder="you@company.com"
aria-required="true"
/>
<button
type="submit"
disabled={forgotLoading}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
>
{forgotLoading ? "Sending..." : "Send Reset Link"}
</button>
<button
type="button"
onClick={() => { setForgotPassword(false); setForgotEmail(""); setForgotError(null); }}
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
>
Back to sign in
</button>
</form>
)}
{/* Reset Email Sent */}
{forgotSent && (
<div className="mt-6 border-t border-stone-200/50 pt-6" role="status" aria-live="polite">
<div className="rounded-xl p-4 text-sm border" style={{ backgroundColor: "#f0fdf4", color: "#166534", borderColor: "#bbf7d0" }}>
<strong>Check your inbox.</strong> If an account exists for <span className="font-medium">{forgotEmail}</span>, a reset link has been sent.
</div>
<button
type="button"
onClick={() => { setForgotPassword(false); setForgotSent(false); setForgotEmail(""); }}
className="mt-4 w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
>
Back to sign in
</button>
</div>
)}
</div> </div>
{/* Security Trust Badges */} {/* Security Trust Badges */}
<div className="border-t border-stone-100/50 px-8 py-5" style={{ backgroundColor: "rgba(250, 248, 245, 0.5)" }}> <div
<div className="flex flex-col gap-3"> className="border-t border-stone-100/50 px-8 py-5"
<div className="flex items-center justify-center gap-4 text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}> style={{ backgroundColor: "rgba(250, 248, 245, 0.5)" }}
<div className="flex items-center gap-1.5"> >
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <div
<path strokeLinecap="round" strokeLinejoin="round" 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" /> className="flex items-center justify-center gap-4 text-xs"
</svg> style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}
<span>256-bit SSL</span> >
</div> <div className="flex items-center gap-1.5">
<span className="text-stone-300"></span> <svg
<div className="flex items-center gap-1.5"> className="w-4 h-4 text-[#6b8f71]"
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> fill="none"
<path strokeLinecap="round" strokeLinejoin="round" 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" /> viewBox="0 0 24 24"
</svg> stroke="currentColor"
<span>SOC 2</span> strokeWidth={2}
</div> >
<span className="text-stone-300"></span> <path
<div className="flex items-center gap-1.5"> strokeLinecap="round"
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none"> strokeLinejoin="round"
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#6b8f71"/> 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> />
<span>Powered by Supabase</span> </svg>
</div> <span>256-bit SSL</span>
</div>
<span className="text-stone-300"></span>
<div className="flex items-center gap-1.5">
<svg
className="w-4 h-4 text-[#6b8f71]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
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>SOC 2</span>
</div> </div>
</div> </div>
</div> </div>
@@ -305,8 +213,19 @@ function LoginForm() {
{/* Back link */} {/* Back link */}
<div className="text-center mt-6"> <div className="text-center mt-6">
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}> <Link
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"> href="/brands"
className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<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>
View Farms View Farms
@@ -319,20 +238,49 @@ function LoginForm() {
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10"> <footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4"> <div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}> <div
className="w-5 h-5 rounded-full flex items-center justify-center"
style={{ backgroundColor: "#1a4d2e" }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"> <svg width="10" height="10" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> <path
d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z"
fill="#faf8f5"
stroke="#faf8f5"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg> </svg>
</div> </div>
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}> <span
className="text-xs"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}
>
© {new Date().getFullYear()} Route Commerce © {new Date().getFullYear()} Route Commerce
</span> </span>
</div> </div>
<nav className="flex items-center gap-6"> <nav className="flex items-center gap-6">
<Link href="/privacy-policy" className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71", letterSpacing: "0.08em" }}> <Link
href="/privacy-policy"
className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
color: "#6b8f71",
letterSpacing: "0.08em",
}}
>
Privacy Privacy
</Link> </Link>
<Link href="/terms-and-conditions" className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71", letterSpacing: "0.08em" }}> <Link
href="/terms-and-conditions"
className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]"
style={{
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
color: "#6b8f71",
letterSpacing: "0.08em",
}}
>
Terms Terms
</Link> </Link>
</nav> </nav>
@@ -341,129 +289,3 @@ function LoginForm() {
</main> </main>
); );
} }
// Demo mode wrapper
function DemoMode() {
return (
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
{/* Organic background elements */}
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
</div>
{/* Header */}
<header className="w-full py-6 px-6 lg:px-8">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}>
Route Commerce
</span>
</Link>
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}>
Back to home
</Link>
</div>
</header>
{/* Demo Card */}
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
<div className="w-full max-w-sm">
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden p-10">
<div className="text-center mb-8">
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}>
Demo Mode
</h1>
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
Select a role to explore the platform
</p>
</div>
<div className="flex flex-col gap-3">
<button
onClick={() => { document.cookie = "dev_session=platform_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
>
Platform Admin
</button>
<button
onClick={() => { document.cookie = "dev_session=brand_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#c97a3e" }}
>
Brand Admin
</button>
<button
onClick={() => { document.cookie = "dev_session=store_employee; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#6b8f71" }}
>
Store Employee
</button>
</div>
</div>
{/* Back link */}
<div className="text-center mt-6">
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
View Farms
</Link>
</div>
</div>
</div>
{/* Footer */}
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
© {new Date().getFullYear()} Route Commerce
</span>
</div>
</div>
</footer>
</main>
);
}
// Inner component that uses useSearchParams - must be wrapped in Suspense
function LoginPageInner() {
const searchParams = useSearchParams();
const isDemo = searchParams.get("demo") === "1";
if (isDemo) return <DemoMode />;
return <LoginForm />;
}
export default function LoginClient() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-16 w-16 rounded-2xl bg-gradient-to-br from-emerald-600 to-emerald-500 animate-pulse" />
<p className="text-stone-500">Loading...</p>
</div>
</div>
}>
<LoginPageInner />
</Suspense>
);
}
-105
View File
@@ -1,105 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
export default function LoginPage2() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (!email.trim()) { setError("Email is required."); return; }
if (!password.trim()) { setError("Password is required."); return; }
setLoading(true);
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), password }),
});
setLoading(false);
if (res.status === 303) {
window.location.href = "/admin";
} else {
const data = await res.json().catch(() => ({ error: "Login failed" }));
setError(data.error || "Login failed.");
}
}
return (
<main className="min-h-screen bg-slate-100 px-6 py-12">
<div className="mx-auto max-w-md">
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200">
<h1 className="text-2xl font-bold text-slate-900">Admin Login</h1>
<p className="mt-1 text-sm text-slate-500">Sign in to your account.</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{error && (
<div role="alert" className="rounded-xl bg-red-50 p-4 text-sm text-red-700 border border-red-100">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="username"
disabled={loading}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900 disabled:bg-slate-100 disabled:cursor-not-allowed"
placeholder="admin@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={loading}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900 disabled:bg-slate-100 disabled:cursor-not-allowed"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50 flex items-center justify-center gap-3"
>
{loading ? (
<>
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Signing in...
</>
) : "Sign In"}
</button>
</form>
</div>
<Link href="/" className="mt-4 block text-sm text-slate-500 hover:text-slate-700">
Back to storefront
</Link>
</div>
</main>
);
}
-41
View File
@@ -1,41 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
export default function LogoutPage() {
const router = useRouter();
useEffect(() => {
// Clear all auth cookies — dev_session, rc_auth_uid, rc_auth_token
document.cookie = "dev_session=;path=/;max-age=0";
document.cookie = "rc_auth_uid=;path=/;max-age=0";
document.cookie = "rc_auth_token=;path=/;max-age=0";
// Clear shopping cart on logout
localStorage.removeItem("route_commerce_cart");
localStorage.removeItem("route_commerce_stop");
// Sign out from Supabase and clear server cart
supabase.auth.getUser().then(async ({ data }) => {
if (data.user?.id) {
const { clearServerCart } = await import("@/actions/checkout");
clearServerCart(data.user.id).catch(() => {});
}
supabase.auth.signOut().then(() => {
router.push("/login");
router.refresh();
});
});
}, [router]);
return (
<main className="min-h-screen bg-slate-100 px-6 py-12">
<div className="mx-auto max-w-md">
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 text-center">
<p className="text-slate-500">Signing out...</p>
</div>
</div>
</main>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { auth, signOut } from "@/lib/auth";
/**
* /protected-example
*
* Smoke-test page that demonstrates the new Auth.js v5 pattern. Calling
* `auth()` server-side returns the current session (null if not signed
* in). The middleware in `../middleware.ts` already redirects
* unauthenticated visitors to `/login`, so by the time this page renders
* we always have a session.
*
* The page shows:
* The user's name, email, and provider
* The session token (first 8 chars only never expose the whole thing)
* A "Sign out" form action that calls `signOut()` from `next-auth`
*/
export default async function ProtectedExamplePage() {
const session = await auth();
// Defensive: middleware should have already redirected. Render a
// friendly hint if we ever reach here unauthenticated.
if (!session?.user) {
return (
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6">
<div className="max-w-md rounded-2xl bg-white p-8 shadow ring-1 ring-stone-200">
<h1 className="text-xl font-semibold text-stone-900">
Not signed in
</h1>
<p className="mt-2 text-sm text-stone-600">
You should have been redirected to{" "}
<a className="text-emerald-700 underline" href="/login">
/login
</a>
. If you can see this, the middleware matcher needs adjusting.
</p>
</div>
</main>
);
}
const user = session.user;
const expires = session.expires
? new Date(session.expires).toLocaleString()
: "(no expiry)";
// The raw session token isn't on the session object in v5 (only the
// csrfToken is exposed client-side). We surface what we do have.
return (
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6 py-12">
<div className="w-full max-w-xl space-y-6">
<header>
<h1
className="text-3xl font-semibold tracking-tight text-stone-900"
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}
>
Protected example
</h1>
<p className="mt-1 text-sm text-stone-500">
You are signed in. This page is guarded by the Auth.js
middleware in <code className="text-xs">middleware.ts</code>.
</p>
</header>
<section className="rounded-2xl bg-white p-6 shadow ring-1 ring-stone-200">
<h2 className="text-sm font-semibold uppercase tracking-wider text-stone-500">
Session
</h2>
<dl className="mt-4 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
<div>
<dt className="text-stone-500">Name</dt>
<dd className="mt-1 font-medium text-stone-900">
{user.name ?? "(none)"}
</dd>
</div>
<div>
<dt className="text-stone-500">Email</dt>
<dd className="mt-1 font-medium text-stone-900 break-all">
{user.email ?? "(none)"}
</dd>
</div>
<div>
<dt className="text-stone-500">User id</dt>
<dd className="mt-1 font-mono text-xs text-stone-700 break-all">
{(user as { id?: string }).id ?? "(none)"}
</dd>
</div>
<div>
<dt className="text-stone-500">Session expires</dt>
<dd className="mt-1 font-medium text-stone-900">{expires}</dd>
</div>
</dl>
</section>
<section className="rounded-2xl bg-white p-6 shadow ring-1 ring-stone-200">
<h2 className="text-sm font-semibold uppercase tracking-wider text-stone-500">
Try it
</h2>
<p className="mt-2 text-sm text-stone-600">
Use the form below to sign out, or navigate to{" "}
<a className="text-emerald-700 underline" href="/admin">
/admin
</a>{" "}
(the same session is shared).
</p>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/login" });
}}
className="mt-4"
>
<button
type="submit"
className="rounded-xl bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
>
Sign out
</button>
</form>
</section>
</div>
</main>
);
}
@@ -0,0 +1,116 @@
import type { Metadata } from "next";
import { Fraunces, JetBrains_Mono } from "next/font/google";
import { supabase } from "@/lib/supabase";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
import SweetCornProductPage from "@/components/storefront/SweetCornProductPage";
const fraunces = Fraunces({
subsets: ["latin"],
variable: "--font-display",
display: "swap",
style: ["normal", "italic"],
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
weight: ["400", "500", "700"],
});
export const metadata: Metadata = {
// The Tuxedo layout's `template: "%s | Tuxedo Corn"` already appends the
// brand — keep the bare title here so the rendered <title> is correct.
title: "Fresh Sweet Corn Box 12 Ears",
description:
"Our 12-Ear Corn Box is packed with peak-season, super-sweet Olathe Sweet corn picked that morning and rushed to your door. Hand-selected, non-GMO, and 100% sweetness guaranteed.",
keywords: [
"sweet corn",
"Olathe Sweet",
"fresh corn box",
"12 ears of corn",
"farm fresh corn",
"Tuxedo Corn",
],
openGraph: {
title: "Fresh Sweet Corn Box 12 Ears",
description:
"Peak-season, super-sweet Olathe Sweet corn — picked that morning, rushed to your door. 100% sweetness guaranteed.",
type: "website",
images: [
{
url: "https://images.unsplash.com/photo-1551754655-cd27e38d2076?auto=format&fit=crop&w=1200&q=80",
width: 1200,
height: 630,
alt: "Fresh sweet corn box — 12 ears of Olathe Sweet corn",
},
],
},
twitter: {
card: "summary_large_image",
title: "Fresh Sweet Corn Box 12 Ears",
description:
"Peak-season, super-sweet Olathe Sweet corn. Hand-selected, non-GMO, 100% sweetness guaranteed.",
},
};
const BRAND_SLUG = "tuxedo";
type Brand = {
id: string;
name: string;
};
type Settings = {
logo_url?: string | null;
logo_url_dark?: string | null;
};
export default async function SweetCornBoxPage() {
// Fetch the brand record so we have a real brand_id to thread through
// the cart system. Falls back to a placeholder if Supabase is unreachable
// (so the page still renders in dev / disconnected previews).
let brandId = "00000000-0000-0000-0000-000000000000";
let brandName = "Tuxedo Corn";
let logoUrl: string | null = null;
let logoUrlDark: string | null = null;
try {
const [brandRes, settingsRes] = await Promise.all([
supabase.from("brands").select("id, name").eq("slug", BRAND_SLUG).single(),
getBrandSettingsPublic(BRAND_SLUG),
]);
if (brandRes.data) {
const b = brandRes.data as Brand;
brandId = b.id;
brandName = b.name;
}
if (settingsRes.success && settingsRes.settings) {
const s = settingsRes.settings as Settings;
logoUrl = s.logo_url ?? null;
logoUrlDark = s.logo_url_dark ?? null;
}
} catch {
// ignore — fall through with defaults
}
return (
<div
className={`${fraunces.variable} ${jetbrainsMono.variable} font-sans`}
style={{
// The product page uses editorial typography: a display serif
// (Fraunces) for headlines and a mono (JetBrains Mono) for data
// stamps. Body copy falls back to the global SF Pro stack.
["--font-display" as never]: fraunces.style.fontFamily,
["--font-mono" as never]: jetbrainsMono.style.fontFamily,
}}
>
<SweetCornProductPage
brandId={brandId}
brandName={brandName}
logoUrl={logoUrl}
logoUrlDark={logoUrlDark}
/>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { NextAuthConfig } from "next-auth";
import Google from "next-auth/providers/google";
/**
* Edge-compatible Auth.js v5 configuration.
*
* This file is imported by `src/middleware.ts`, which runs in the Edge runtime.
* It must NOT import the `@auth/pg-adapter` (which uses `pg`, a Node-only lib)
* or any other Node-only module. Database wiring lives in `src/lib/auth.ts`.
*
* If you need to add a provider that uses Node-only APIs (e.g. an adapter
* implementation), define it in `src/lib/auth.ts` instead and add a thin
* placeholder here so the middleware can still reference it.
*/
const isDev = process.env.NODE_ENV !== "production";
const allowDevLogin = process.env.ALLOW_DEV_LOGIN !== "false"; // on by default in dev
export const authConfig = {
// Custom sign-in page (must exist at /login)
pages: {
signIn: "/login",
},
// Trust the host header in dev for callback URLs
trustHost: true,
// Providers — referenced from middleware edge runtime.
// The Google provider only needs the env vars at runtime; it does not pull
// in any Node-only code. The dev Credentials provider is added in
// `src/lib/auth.ts` (server-side only) — it's not safe to import
// `next-auth/providers/credentials` from the edge runtime.
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID ?? process.env.AUTH_GOOGLE_ID,
clientSecret:
process.env.GOOGLE_CLIENT_SECRET ?? process.env.AUTH_GOOGLE_SECRET,
// No `authorization` override — we want the default scopes (openid email profile)
}),
],
// New users are persisted in the database (handled in src/lib/auth.ts)
// Default to JWT here so middleware can run in edge runtime; the full
// server-side handler in src/lib/auth.ts switches this to "database".
session: { strategy: "jwt" },
callbacks: {
/**
* Forward the user id from the database user record into the JWT on
* initial sign-in. With database sessions this is what populates
* `session.user.id` for downstream server actions.
*/
async jwt({ token, user }) {
if (user) {
token.id = (user as { id?: string }).id ?? token.sub;
}
return token;
},
async session({ session, token }) {
if (session.user && token?.sub) {
(session.user as { id?: string }).id = token.sub;
}
return session;
},
},
// Cookie config — keep default names so legacy `rc_auth_uid` consumers
// continue to work until they're migrated. New Auth.js cookies default to
// `authjs.session-token` (dev) and `__Secure-authjs.session-token` (prod).
} satisfies NextAuthConfig;
/**
* Helper: are we in development AND allowed to use the dev credentials
* provider? Exposed so server-side `src/lib/auth.ts` can decide whether to
* include the provider in its provider list.
*/
export function isDevLoginEnabled(): boolean {
return isDev && allowDevLogin;
}
+288
View File
@@ -0,0 +1,288 @@
"use client";
import { useState, useCallback } from "react";
import { createLocation } from "@/actions/locations";
import GlassModal from "@/components/admin/GlassModal";
type Props = {
isOpen: boolean;
onClose: () => void;
brandId: string;
onSuccess?: (locationId: string) => void;
};
export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState("");
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [stateVal, setStateVal] = useState("");
const [zip, setZip] = useState("");
const [phone, setPhone] = useState("");
const [contactName, setContactName] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [notes, setNotes] = useState("");
const reset = useCallback(() => {
setName("");
setAddress("");
setCity("");
setStateVal("");
setZip("");
setPhone("");
setContactName("");
setContactEmail("");
setNotes("");
setError(null);
}, []);
const handleClose = useCallback(() => {
if (loading) return;
reset();
onClose();
}, [loading, reset, onClose]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("Venue name is required.");
return;
}
setLoading(true);
try {
const result = await createLocation(brandId, {
name: name.trim(),
address: address.trim() || null,
city: city.trim() || null,
state: stateVal.trim().toUpperCase() || null,
zip: zip.trim() || null,
phone: phone.trim() || null,
contact_name: contactName.trim() || null,
contact_email: contactEmail.trim() || null,
notes: notes.trim() || null,
active: true,
});
if (result.success) {
onSuccess?.(result.id);
reset();
onClose();
} else {
setError(result.error ?? "Failed to create location");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
},
[brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, reset, onClose]
);
const inputStyle = {
background: "rgba(0, 0, 0, 0.02)",
border: "1px solid rgba(0, 0, 0, 0.06)",
outline: "none",
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.target.style.background = "rgba(16, 185, 129, 0.04)";
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.target.style.background = "rgba(0, 0, 0, 0.02)";
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
e.target.style.boxShadow = "none";
};
if (!isOpen) return null;
return (
<GlassModal
title="Add Venue"
subtitle="Reusable pick-up location. Once saved, you can attach stops to it from the Stops tab."
onClose={handleClose}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
>
{error}
</div>
)}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tractor Supply"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="13778 E I-25 Frontage Rd"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-3 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Wellington"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<input
type="text"
value={stateVal}
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
placeholder="CO"
maxLength={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<input
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80549"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="(970) 555-1234"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
<input
type="text"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
placeholder="Store manager"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
<input
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="manager@example.com"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Park on west side. Use side entrance after 9am."
rows={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all resize-none"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
<button
type="button"
onClick={handleClose}
disabled={loading}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: loading
? "rgba(16, 185, 129, 0.4)"
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
boxShadow: loading
? "none"
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
opacity: loading ? 0.7 : 1,
}}
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Creating
</span>
) : (
"Create Venue"
)}
</button>
</div>
</form>
</GlassModal>
);
}
+248 -182
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { createStop } from "@/actions/stops/create-stop"; import { createStop } from "@/actions/stops/create-stop";
import GlassModal from "@/components/admin/GlassModal"; import GlassModal from "@/components/admin/GlassModal";
@@ -21,12 +21,20 @@ type Props = {
onSuccess?: (stopId: string) => void; onSuccess?: (stopId: string) => void;
}; };
/* Pin icon for the modal header */
const PinIcon = () => (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, onSuccess }: Props) { export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, onSuccess }: Props) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [city, setCity] = useState(""); const [city, setCity] = useState("");
const [state, setState] = useState(""); const [stateField, setStateField] = useState("");
const [location, setLocation] = useState(""); const [location, setLocation] = useState("");
const [date, setDate] = useState(""); const [date, setDate] = useState("");
const [time, setTime] = useState(""); const [time, setTime] = useState("");
@@ -36,11 +44,13 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
const [cutoffTime, setCutoffTime] = useState(""); const [cutoffTime, setCutoffTime] = useState("");
const [status, setStatus] = useState<"draft" | "active">("draft"); const [status, setStatus] = useState<"draft" | "active">("draft");
const cityRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
// Reset form when modal opens with optional duplicate source // Reset form when modal opens with optional duplicate source
setCity(duplicateFrom?.city ?? ""); setCity(duplicateFrom?.city ?? "");
setState(duplicateFrom?.state ?? ""); setStateField(duplicateFrom?.state ?? "");
setLocation(duplicateFrom?.location ?? ""); setLocation(duplicateFrom?.location ?? "");
setDate(duplicateFrom?.date ?? ""); setDate(duplicateFrom?.date ?? "");
setTime(duplicateFrom?.time ?? ""); setTime(duplicateFrom?.time ?? "");
@@ -49,272 +59,328 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
setCutoffTime(duplicateFrom?.cutoff_time ?? ""); setCutoffTime(duplicateFrom?.cutoff_time ?? "");
setStatus("draft"); setStatus("draft");
setError(null); setError(null);
// Auto-focus city field for fast data entry
requestAnimationFrame(() => cityRef.current?.focus());
} }
/* eslint-enable react-hooks/set-state-in-effect */ /* eslint-enable react-hooks/set-state-in-effect */
}, [isOpen, duplicateFrom]); }, [isOpen, duplicateFrom]);
const handleSubmit = useCallback(async (e: React.FormEvent) => { const handleSubmit = useCallback(
e.preventDefault(); async (e: React.FormEvent) => {
setError(null); e.preventDefault();
setError(null);
if (!city.trim() || !state.trim() || !location.trim() || !date) { if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
setError("City, state, location, and date are required."); setError("City, state, location, and date are required.");
return; return;
}
setLoading(true);
try {
const result = await createStop(brandId, {
city: city.trim(),
state: state.trim(),
location: location.trim(),
date,
time: time || "08:00",
address: address || undefined,
zip: zip || undefined,
cutoff_time: cutoffTime || undefined,
active: status === "active",
});
if (result.success) {
onSuccess?.(result.id);
onClose();
} else {
setError(result.error ?? "Failed to create stop");
} }
} catch {
setError("Network error. Please try again."); setLoading(true);
} finally { try {
setLoading(false); const result = await createStop(brandId, {
} city: city.trim(),
}, [brandId, city, state, location, date, time, address, zip, cutoffTime, status, onSuccess, onClose]); state: stateField.trim(),
location: location.trim(),
date,
time: time || "08:00",
address: address || undefined,
zip: zip || undefined,
cutoff_time: cutoffTime || undefined,
active: status === "active",
});
if (result.success) {
onSuccess?.(result.id);
onClose();
} else {
setError(result.error ?? "Failed to create stop");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
},
[
brandId,
city,
stateField,
location,
date,
time,
address,
zip,
cutoffTime,
status,
onSuccess,
onClose,
]
);
const isDuplicate = Boolean(duplicateFrom); const isDuplicate = Boolean(duplicateFrom);
const title = isDuplicate ? "Duplicate Stop" : "Add New Stop"; const title = isDuplicate ? "Duplicate Stop" : "Add Stop";
const subtitle = isDuplicate && duplicateFrom const eyebrow = isDuplicate && duplicateFrom
? `From ${duplicateFrom.city}, ${duplicateFrom.state}` ? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
: "Create a new tour stop for your route."; : "New stop on the route";
const submitLabel = loading
const inputStyle = { ? isDuplicate ? "Duplicating…" : "Creating…"
background: 'rgba(0, 0, 0, 0.02)', : isDuplicate
border: '1px solid rgba(0, 0, 0, 0.06)', ? "Duplicate Stop"
outline: 'none', : status === "active"
}; ? "Create & Publish"
: "Save as Draft";
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
e.target.style.background = 'rgba(16, 185, 129, 0.04)';
e.target.style.border = '1px solid rgba(16, 185, 129, 0.5)';
e.target.style.boxShadow = '0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)';
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
e.target.style.background = 'rgba(0, 0, 0, 0.02)';
e.target.style.border = '1px solid rgba(0, 0, 0, 0.06)';
e.target.style.boxShadow = 'none';
};
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<GlassModal title={title} subtitle={subtitle} onClose={onClose}> <GlassModal
<form onSubmit={handleSubmit} className="space-y-4"> title={title}
eyebrow={eyebrow}
onClose={onClose}
maxWidth="max-w-xl"
compact
>
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
{error && ( {error && (
<div className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm" <div
style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.2)' }}> role="alert"
{error} className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{ background: "rgba(220, 38, 38, 0.06)", border: "1px solid rgba(220, 38, 38, 0.15)" }}
>
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
</svg>
<span>{error}</span>
</div> </div>
)} )}
<div className="grid grid-cols-3 gap-4"> {/* Row 1 — Where: City + State */}
<div className="col-span-2 space-y-1.5"> <div className="grid grid-cols-[1fr_5.5rem] gap-3">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label> <div className="ha-field">
<label htmlFor="stop-city" className="ha-field-label">
<PinIcon />
<span>City</span>
<span className="ha-field-label-required">*</span>
</label>
<input <input
ref={cityRef}
id="stop-city"
type="text" type="text"
value={city} value={city}
onChange={(e) => setCity(e.target.value)} onChange={(e) => setCity(e.target.value)}
placeholder="Denver" placeholder="Denver"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" className="ha-field-input"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required required
autoComplete="address-level2"
/> />
</div> </div>
<div className="space-y-1.5"> <div className="ha-field">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label> <label htmlFor="stop-state" className="ha-field-label">
<span>State</span>
<span className="ha-field-label-required">*</span>
</label>
<input <input
id="stop-state"
type="text" type="text"
value={state} value={stateField}
onChange={(e) => setState(e.target.value.toUpperCase())} onChange={(e) => setStateField(e.target.value.toUpperCase())}
placeholder="CO" placeholder="CO"
maxLength={2} maxLength={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" className="ha-field-input ha-field-input-mono text-center"
style={inputStyle} style={{ textTransform: "uppercase" }}
onFocus={handleFocus}
onBlur={handleBlur}
required required
autoComplete="address-level1"
/> />
</div> </div>
</div> </div>
<div className="space-y-1.5"> {/* Row 2 — Where at: Location (full) */}
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Location</label> <div className="ha-field">
<label htmlFor="stop-location" className="ha-field-label">
<span>Location / Venue</span>
<span className="ha-field-label-required">*</span>
</label>
<input <input
id="stop-location"
type="text" type="text"
value={location} value={location}
onChange={(e) => setLocation(e.target.value)} onChange={(e) => setLocation(e.target.value)}
placeholder="Whole Foods Market" placeholder="Whole Foods Market — Highlands"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" className="ha-field-input"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required required
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* Row 3 — When: Date + Time (with small clock icon) */}
<div className="space-y-1.5"> <div className="grid grid-cols-[1fr_1fr] gap-3">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Date</label> <div className="ha-field">
<label htmlFor="stop-date" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
</svg>
<span>Pickup Date</span>
<span className="ha-field-label-required">*</span>
</label>
<input <input
id="stop-date"
type="date" type="date"
value={date} value={date}
onChange={(e) => setDate(e.target.value)} onChange={(e) => setDate(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all" className="ha-field-input ha-field-input-mono"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required required
/> />
</div> </div>
<div className="space-y-1.5"> <div className="ha-field">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Pickup Time</label> <label htmlFor="stop-time" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" strokeLinecap="round" />
</svg>
<span>Pickup Time</span>
</label>
<input <input
id="stop-time"
type="time" type="time"
value={time} value={time}
onChange={(e) => setTime(e.target.value)} onChange={(e) => setTime(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all" className="ha-field-input ha-field-input-mono"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-3 gap-4"> {/* Row 4 — Address + ZIP + Cutoff in 3 columns */}
<div className="col-span-2 space-y-1.5"> <div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Address</label> <div className="ha-field">
<label htmlFor="stop-address" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
<path d="M9 22V12h6v10" strokeLinejoin="round" />
</svg>
<span>Street Address</span>
</label>
<input <input
id="stop-address"
type="text" type="text"
value={address} value={address}
onChange={(e) => setAddress(e.target.value)} onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St" placeholder="123 Main St"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" className="ha-field-input"
style={inputStyle} autoComplete="street-address"
onFocus={handleFocus}
onBlur={handleBlur}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="ha-field">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label> <label htmlFor="stop-zip" className="ha-field-label">
<span>ZIP</span>
</label>
<input <input
id="stop-zip"
type="text" type="text"
value={zip} value={zip}
onChange={(e) => setZip(e.target.value)} onChange={(e) => setZip(e.target.value)}
placeholder="80202" placeholder="80202"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all" maxLength={10}
style={inputStyle} className="ha-field-input ha-field-input-mono"
onFocus={handleFocus} autoComplete="postal-code"
onBlur={handleBlur} />
</div>
<div className="ha-field">
<label htmlFor="stop-cutoff" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" strokeLinecap="round" />
</svg>
<span>Cutoff</span>
</label>
<input
id="stop-cutoff"
type="time"
value={cutoffTime}
onChange={(e) => setCutoffTime(e.target.value)}
className="ha-field-input ha-field-input-mono"
/> />
</div> </div>
</div> </div>
<div className="space-y-1.5"> {/* Row 5 — Status: segmented control (compact, replaces two giant buttons) */}
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Cutoff Time</label> <div className="ha-field pt-0.5">
<input <div className="flex items-center justify-between">
type="time" <label className="ha-field-label">
value={cutoffTime} <span>Visibility</span>
onChange={(e) => setCutoffTime(e.target.value)} </label>
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all" <span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
style={inputStyle} Draft is hidden from customers
onFocus={handleFocus} </span>
onBlur={handleBlur} </div>
/> <div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
<p className="mt-1 text-xs text-stone-400 ml-1">Orders close this time the day before pickup</p>
</div>
{/* Status toggle */}
<div className="space-y-2">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Status</label>
<div className="flex gap-3">
<button <button
type="button" type="button"
role="radio"
aria-checked={status === "draft"}
onClick={() => setStatus("draft")} onClick={() => setStatus("draft")}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all" className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
style={{
border: status === "draft" ? '1px solid rgba(245, 158, 11, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
background: status === "draft" ? 'rgba(245, 158, 11, 0.08)' : 'rgba(0, 0, 0, 0.02)',
}}
> >
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{ <span className="ha-segment-dot" />
background: status === "draft" ? 'rgba(245, 158, 11, 0.2)' : 'rgba(0, 0, 0, 0.05)', <span>Save as Draft</span>
color: status === "draft" ? '#b45309' : 'rgba(0, 0, 0, 0.4)',
}}>Draft</span>
<span className="text-sm font-medium" style={{ color: status === "draft" ? '#92400e' : 'rgba(0, 0, 0, 0.4)' }}>Save as draft</span>
</button> </button>
<button <button
type="button" type="button"
role="radio"
aria-checked={status === "active"}
onClick={() => setStatus("active")} onClick={() => setStatus("active")}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all" className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
style={{
border: status === "active" ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
background: status === "active" ? 'rgba(16, 185, 129, 0.08)' : 'rgba(0, 0, 0, 0.02)',
}}
> >
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{ <span className="ha-segment-dot" />
background: status === "active" ? 'rgba(16, 185, 129, 0.2)' : 'rgba(0, 0, 0, 0.05)', <span>Publish Now</span>
color: status === "active" ? '#047857' : 'rgba(0, 0, 0, 0.4)',
}}>Active</span>
<span className="text-sm font-medium" style={{ color: status === "active" ? '#065f46' : 'rgba(0, 0, 0, 0.4)' }}>Publish now</span>
</button> </button>
</div> </div>
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}> <div className="ha-modal-footer">
<button <span className="ha-modal-footer-hint">
type="button" <kbd>Esc</kbd>
onClick={onClose} to close
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100" </span>
> <div className="flex items-center gap-2">
Cancel <button
</button> type="button"
<button onClick={onClose}
type="submit" className="ha-btn-ghost"
disabled={loading} >
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all" Cancel
style={{ </button>
background: loading <button
? 'rgba(16, 185, 129, 0.4)' type="submit"
: 'linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)', disabled={loading}
boxShadow: loading className="ha-btn-primary"
? 'none' >
: '0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)', {loading ? (
opacity: loading ? 0.7 : 1, <>
}} <svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
> <circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
{loading ? ( <path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
<span className="flex items-center gap-2"> </svg>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"> <span>{submitLabel}</span>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> </>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> ) : (
</svg> <>
Creating... {status === "active" ? (
</span> <svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
) : ( <path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
isDuplicate ? "Duplicate Stop" : "Create Stop" </svg>
)} ) : (
</button> <svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
<span>{submitLabel}</span>
</>
)}
</button>
</div>
</div> </div>
</form> </form>
</GlassModal> </GlassModal>
+29 -1
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import BrandSelector from "./BrandSelector";
// Elegant warm sidebar design // Elegant warm sidebar design
// Colors: parchment 100 bg, soft linen text, powder petal accent // Colors: parchment 100 bg, soft linen text, powder petal accent
@@ -217,9 +218,12 @@ const ICON_MAP: Record<string, React.ReactNode> = {
type SidebarProps = { type SidebarProps = {
userRole?: string | null; userRole?: string | null;
brandIds?: string[];
activeBrandId?: string | null;
brands?: Array<{ id: string; name: string; slug: string; logo_url: string | null }>;
}; };
export default function AdminSidebar({ userRole }: SidebarProps) { export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
@@ -230,9 +234,15 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
const roleLabel = userRole === "platform_admin" ? "Platform Admin" const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin" : userRole === "brand_admin" ? "Brand Admin"
: userRole === "multi_brand_admin" ? "Multi-Brand Manager"
: userRole === "store_employee" ? "Store Employee" : userRole === "store_employee" ? "Store Employee"
: null; : null;
const isMultiBrandAdmin = userRole === "multi_brand_admin";
const showAllBrandsOption = userRole === "platform_admin";
const showBrandSelector =
brands && (showAllBrandsOption || (brandIds && brandIds.length >= 2));
const isActive = useCallback((href: string) => { const isActive = useCallback((href: string) => {
if (href === "/admin") return pathname === "/admin"; if (href === "/admin") return pathname === "/admin";
return pathname.startsWith(href); return pathname.startsWith(href);
@@ -394,6 +404,24 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
</Link> </Link>
</div> </div>
{/* Brand selector (multi-brand admins + platform_admin) */}
{showBrandSelector && (
<div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
<p
className="text-[10px] font-semibold uppercase tracking-widest mb-1.5 px-1"
style={{ color: "rgba(195, 195, 193, 0.6)" }}
>
Active Brand
</p>
<BrandSelector
brands={brands!}
activeBrandId={activeBrandId ?? null}
showAllBrandsOption={showAllBrandsOption}
isMultiBrandAdmin={isMultiBrandAdmin}
/>
</div>
)}
{/* Nav links with keyboard navigation */} {/* Nav links with keyboard navigation */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin"> <nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
<ul className="space-y-1" role="list"> <ul className="space-y-1" role="list">
+218
View File
@@ -0,0 +1,218 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { setActiveBrand } from "@/actions/set-active-brand";
export type BrandSelectorItem = {
id: string;
name: string;
slug: string;
logo_url: string | null;
};
type BrandSelectorProps = {
brands: BrandSelectorItem[];
/** Currently-active brand id. `null` means "All brands" (platform_admin only). */
activeBrandId: string | null;
/** When true, shows the "All brands" pseudo-option at the top. */
showAllBrandsOption: boolean;
/** Whether the user has multi-brand access. Controls the "Multi-brand manager" badge. */
isMultiBrandAdmin: boolean;
};
/**
* Brand selector dropdown.
*
* Renders a compact pill showing the active brand (or "All brands" for
* platform_admin) and opens a list of accessible brands on click.
*
* On select: calls `setActiveBrand` server action then `router.refresh()`
* so all server components re-read the new cookie and reload their data.
* The URL is NOT changed the cookie is the source of truth.
*/
export default function BrandSelector({
brands,
activeBrandId,
showAllBrandsOption,
isMultiBrandAdmin,
}: BrandSelectorProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Close on escape
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open]);
// No brands + no "All brands" option = don't render
if (!showAllBrandsOption && brands.length === 0) return null;
if (!showAllBrandsOption && brands.length < 2) return null;
const activeBrand = brands.find((b) => b.id === activeBrandId);
const label =
showAllBrandsOption && !activeBrand
? "All brands"
: activeBrand?.name ?? "Select brand";
async function selectBrand(brandId: string | null) {
setOpen(false);
if (brandId === activeBrandId) return;
setPending(true);
try {
const res = await setActiveBrand(brandId);
if (res.success) {
router.refresh();
} else {
// Keep UI usable if the server rejected; log for debugging
console.error("setActiveBrand failed:", res.error);
}
} finally {
setPending(false);
}
}
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={pending}
aria-haspopup="listbox"
aria-expanded={open}
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg border transition-colors text-left disabled:opacity-60"
style={{
backgroundColor: "rgba(208, 203, 180, 0.1)",
borderColor: "rgba(208, 203, 180, 0.25)",
color: "var(--admin-sidebar-text)",
}}
>
<span
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
aria-hidden="true"
>
{showAllBrandsOption && !activeBrand ? "*" : label.charAt(0).toUpperCase()}
</span>
<span className="flex-1 min-w-0 text-xs font-medium truncate">
{label}
</span>
{isMultiBrandAdmin && (
<span
className="text-[9px] font-medium px-1.5 py-0.5 rounded-md flex-shrink-0"
style={{
backgroundColor: "rgba(202, 117, 67, 0.18)",
color: "var(--admin-accent)",
}}
title="Manages multiple brands"
>
multi
</span>
)}
<svg
className={`w-3 h-3 flex-shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
{open && (
<div
role="listbox"
className="absolute top-full left-0 right-0 mt-1.5 rounded-xl border shadow-2xl z-50 overflow-hidden"
style={{
backgroundColor: "var(--admin-sidebar-bg)",
borderColor: "rgba(208, 203, 180, 0.25)",
}}
>
{showAllBrandsOption && (
<button
type="button"
role="option"
aria-selected={!activeBrand}
onClick={() => selectBrand(null)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
style={{
color: !activeBrand ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
backgroundColor: !activeBrand ? "rgba(202, 117, 67, 0.10)" : "transparent",
}}
>
<span
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
aria-hidden="true"
>
*
</span>
<span className="flex-1">All brands</span>
{!activeBrand && (
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: "var(--admin-accent)" }}
aria-hidden="true"
/>
)}
</button>
)}
{brands.map((b) => {
const isActive = b.id === activeBrandId;
return (
<button
key={b.id}
type="button"
role="option"
aria-selected={isActive}
onClick={() => selectBrand(b.id)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
style={{
color: isActive ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
backgroundColor: isActive ? "rgba(202, 117, 67, 0.10)" : "transparent",
}}
>
<span
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
aria-hidden="true"
>
{b.name.charAt(0).toUpperCase()}
</span>
<span className="flex-1 truncate">{b.name}</span>
{isActive && (
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: "var(--admin-accent)" }}
aria-hidden="true"
/>
)}
</button>
);
})}
</div>
)}
</div>
);
}
+298
View File
@@ -0,0 +1,298 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { updateLocation } from "@/actions/locations";
import GlassModal from "@/components/admin/GlassModal";
export type LocationForEdit = {
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;
};
type Props = {
isOpen: boolean;
onClose: () => void;
location: LocationForEdit | null;
brandId: string;
onSuccess?: () => void;
};
export default function EditLocationModal({ isOpen, onClose, location, brandId, onSuccess }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState("");
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [stateVal, setStateVal] = useState("");
const [zip, setZip] = useState("");
const [phone, setPhone] = useState("");
const [contactName, setContactName] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [notes, setNotes] = useState("");
useEffect(() => {
/* eslint-disable react-hooks/set-state-in-effect */
if (location && isOpen) {
setName(location.name ?? "");
setAddress(location.address ?? "");
setCity(location.city ?? "");
setStateVal(location.state ?? "");
setZip(location.zip ?? "");
setPhone(location.phone ?? "");
setContactName(location.contact_name ?? "");
setContactEmail(location.contact_email ?? "");
setNotes(location.notes ?? "");
setError(null);
}
/* eslint-enable react-hooks/set-state-in-effect */
}, [location, isOpen]);
const handleClose = useCallback(() => {
if (loading) return;
setError(null);
onClose();
}, [loading, onClose]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!location) return;
setError(null);
if (!name.trim()) {
setError("Venue name is required.");
return;
}
setLoading(true);
try {
const result = await updateLocation(location.id, brandId, {
name: name.trim(),
address: address.trim() || null,
city: city.trim() || null,
state: stateVal.trim().toUpperCase() || null,
zip: zip.trim() || null,
phone: phone.trim() || null,
contact_name: contactName.trim() || null,
contact_email: contactEmail.trim() || null,
notes: notes.trim() || null,
active: location.active,
});
if (result.success) {
onSuccess?.();
onClose();
} else {
setError(result.error ?? "Failed to update location");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
},
[location, brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, onClose]
);
const inputStyle = {
background: "rgba(0, 0, 0, 0.02)",
border: "1px solid rgba(0, 0, 0, 0.06)",
outline: "none",
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.target.style.background = "rgba(16, 185, 129, 0.04)";
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.target.style.background = "rgba(0, 0, 0, 0.02)";
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
e.target.style.boxShadow = "none";
};
if (!isOpen || !location) return null;
return (
<GlassModal
title="Edit Venue"
subtitle="Changes apply to every stop currently linked to this venue."
onClose={handleClose}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
>
{error}
</div>
)}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-3 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<input
type="text"
value={stateVal}
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
maxLength={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<input
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
<input
type="text"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
<input
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all resize-none"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
<button
type="button"
onClick={handleClose}
disabled={loading}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: loading
? "rgba(16, 185, 129, 0.4)"
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
boxShadow: loading
? "none"
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
opacity: loading ? 0.7 : 1,
}}
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Saving
</span>
) : (
"Save Changes"
)}
</button>
</div>
</form>
</GlassModal>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useEffect } from "react";
type Props = {
title: string;
titleIcon?: React.ReactNode;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
maxWidth?: string;
variant?: "default" | "elegant";
};
export default function ElegantModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg", variant = "default" }: Props) {
useEffect(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [onClose]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
const isElegant = variant === "elegant";
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
onClick={handleBackdropClick}
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
<div
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col ${isElegant ? "border border-stone-200/60" : ""}`}
>
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-600 to-amber-500 rounded-t-2xl" />
<div className={`flex items-center justify-between px-6 sm:px-8 py-5 shrink-0 ${isElegant ? "border-b border-stone-100" : ""}`}>
<div className="flex items-center gap-3 min-w-0">
{titleIcon && (
<div className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-lg shrink-0 bg-amber-50">
{titleIcon}
</div>
)}
<div className="min-w-0">
{isElegant ? (
<h2 className="text-sm font-normal tracking-wide text-stone-500 uppercase" style={{ fontFamily: "Georgia, serif" }}>
{title}
</h2>
) : (
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 truncate" style={{ letterSpacing: "-0.02em" }}>
{title}
</h2>
)}
{subtitle && (
<p className={`mt-0.5 text-sm text-stone-400 hidden sm:block ${isElegant ? "font-normal" : ""}`}>{subtitle}</p>
)}
</div>
</div>
<button
onClick={onClose}
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2 hover:bg-stone-100 text-stone-500"
>
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="relative px-6 sm:px-8 py-6 overflow-y-auto flex-1">
{children}
</div>
</div>
</div>
);
}
+63 -17
View File
@@ -9,9 +9,22 @@ type Props = {
onClose: () => void; onClose: () => void;
children: React.ReactNode; children: React.ReactNode;
maxWidth?: string; maxWidth?: string;
/** Compact mode: tighter padding, smaller title, no accent bar — for dense forms. */
compact?: boolean;
/** Optional eyebrow text rendered above the title in compact mode. */
eyebrow?: string;
}; };
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg" }: Props) { export default function GlassModal({
title,
titleIcon,
subtitle,
onClose,
children,
maxWidth = "max-w-lg",
compact = false,
eyebrow,
}: Props) {
// Lock body scroll // Lock body scroll
useEffect(() => { useEffect(() => {
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
@@ -33,24 +46,35 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8" className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 md:p-6"
onClick={handleBackdropClick} onClick={handleBackdropClick}
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }} style={{
backgroundColor: "rgba(60, 56, 37, 0.45)",
backdropFilter: "blur(2px)",
WebkitBackdropFilter: "blur(2px)",
}}
> >
{/* Modal card - solid white with shadow for high contrast */}
<div <div
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col`} className={`relative w-full ${maxWidth} ${
compact
? "max-h-[min(640px,calc(100vh-2rem))]"
: "max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)]"
} rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.18),0_8px_16px_-4px_rgba(0,0,0,0.05)] flex flex-col overflow-hidden`}
> >
{/* Subtle top border accent */} {/* Accent bar — only for non-compact (compact forms have their own internal accent) */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" /> {!compact && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
)}
{/* Header */} {/* Header */}
<div <div
className="flex items-center justify-between px-4 sm:px-6 md:px-8 py-4 sm:py-6 shrink-0" className={`flex items-center justify-between shrink-0 ${
compact ? "px-5 pt-4 pb-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6"
}`}
style={{ borderBottom: "1px solid var(--admin-border-light)" }} style={{ borderBottom: "1px solid var(--admin-border-light)" }}
> >
<div className="flex items-center gap-2 sm:gap-3 min-w-0"> <div className="flex items-center gap-2 sm:gap-3 min-w-0">
{titleIcon && ( {titleIcon && !compact && (
<div <div
className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-xl shrink-0" className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-xl shrink-0"
style={{ backgroundColor: "var(--admin-accent-light)" }} style={{ backgroundColor: "var(--admin-accent-light)" }}
@@ -59,21 +83,39 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
</div> </div>
)} )}
<div className="min-w-0"> <div className="min-w-0">
{compact && eyebrow && (
<p className="ha-eyebrow mb-0.5 truncate">{eyebrow}</p>
)}
<h2 <h2
className="text-lg sm:text-xl font-semibold text-[var(--admin-text-primary)] truncate" className={`${
style={{ letterSpacing: "-0.02em" }} compact
? "ha-display text-lg truncate"
: "text-lg sm:text-xl font-semibold truncate"
} text-[var(--admin-text-primary)]`}
style={compact ? undefined : { letterSpacing: "-0.02em" }}
> >
{title} {title}
</h2> </h2>
{subtitle && ( {subtitle && !compact && (
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">{subtitle}</p> <p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">
{subtitle}
</p>
)} )}
</div> </div>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2" className={`flex shrink-0 items-center justify-center rounded-full transition-all duration-150 ml-2 ${
style={{ backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }} compact
? "h-7 w-7 text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)]"
: "h-8 w-8 sm:h-9 sm:w-9"
}`}
style={
compact
? undefined
: { backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }
}
aria-label="Close modal"
> >
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -81,8 +123,12 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
</button> </button>
</div> </div>
{/* Content - scrollable */} {/* Content */}
<div className="relative px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8 overflow-y-auto flex-1"> <div
className={`relative overflow-y-auto flex-1 ${
compact ? "px-5 pb-5 pt-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8"
}`}
>
{children} {children}
</div> </div>
</div> </div>
+387
View File
@@ -0,0 +1,387 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useTransition, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { deleteLocation } from "@/actions/locations";
import AddLocationModal from "@/components/admin/AddLocationModal";
import EditLocationModal, { type LocationForEdit } from "@/components/admin/EditLocationModal";
import {
AdminSearchInput,
AdminFilterTabs,
AdminButton,
AdminIconButton,
useToast,
Skeleton,
} from "@/components/admin/design-system";
export type AdminLocation = {
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;
stop_count: number;
};
type Props = {
locations: AdminLocation[];
brandId: string;
};
type StatusFilter = "all" | "active" | "inactive";
export default function LocationsTab({ locations, brandId }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<LocationForEdit | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const PAGE_SIZE = 50;
useEffect(() => {
setIsLoading(true);
const t = setTimeout(() => setIsLoading(false), 250);
return () => clearTimeout(t);
}, [page, statusFilter, search]);
const counts = useMemo(
() => ({
all: locations.length,
active: locations.filter((l) => l.active).length,
inactive: locations.filter((l) => !l.active).length,
}),
[locations]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return locations.filter((l) => {
const matchesSearch =
!q ||
l.name.toLowerCase().includes(q) ||
(l.address ?? "").toLowerCase().includes(q) ||
(l.city ?? "").toLowerCase().includes(q) ||
(l.contact_name ?? "").toLowerCase().includes(q);
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && l.active) ||
(statusFilter === "inactive" && !l.active);
return matchesSearch && matchesStatus;
});
}, [locations, search, statusFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const tabs = [
{ value: "all", label: "All", count: counts.all },
{ value: "active", label: "Active", count: counts.active },
{ value: "inactive", label: "Inactive", count: counts.inactive },
];
function handleAdded() {
setShowAdd(false);
showSuccess("Venue created");
startTransition(() => router.refresh());
}
function handleEdited() {
setEditing(null);
showSuccess("Venue updated");
startTransition(() => router.refresh());
}
async function handleDelete(loc: AdminLocation) {
if (!confirm(`Delete venue "${loc.name}"? This cannot be undone.`)) return;
setPendingDeleteId(loc.id);
setDeleteError(null);
const res = await deleteLocation(loc.id, brandId);
setPendingDeleteId(null);
if (res.success) {
showSuccess("Venue deleted");
startTransition(() => router.refresh());
} else {
setDeleteError(res.error ?? "Delete failed");
showError("Delete failed", res.error ?? "Unknown error");
}
}
return (
<>
{/* Filter bar */}
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
<AdminSearchInput
placeholder="Search venues by name, city, or contact…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
onClear={() => {
setSearch("");
setPage(0);
}}
showClear
className="flex-1 min-w-48 max-w-72"
/>
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => {
setStatusFilter(value as StatusFilter);
setPage(0);
}}
tabs={tabs}
size="sm"
showCounts
/>
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">
{filtered.length} venue{filtered.length !== 1 ? "s" : ""}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<AdminIconButton
variant="secondary"
size="sm"
label="Previous page"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</AdminIconButton>
<span className="text-xs text-[var(--admin-text-muted)] px-1">
{page + 1}/{totalPages}
</span>
<AdminIconButton
variant="secondary"
size="sm"
label="Next page"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</AdminIconButton>
</div>
)}
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAdd(true)}
icon={
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
>
Add Venue
</AdminButton>
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-4 font-semibold">Venue</th>
<th className="px-5 py-4 font-semibold">Address</th>
<th className="px-5 py-4 font-semibold">Contact</th>
<th className="px-5 py-4 font-semibold text-center">Stops</th>
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{isLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<tr key={i}>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-48 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4 text-center"><Skeleton variant="rect" className="w-8 h-5 mx-auto rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
</tr>
))
) : filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-12 text-center">
<div className="flex flex-col items-center gap-2 text-[var(--admin-text-muted)]">
<svg className="h-10 w-10 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="text-sm">
{search || statusFilter !== "all"
? "No venues match your filters."
: "No venues yet. Add your first venue to get started."}
</p>
</div>
</td>
</tr>
) : (
paginated.map((loc) => (
<tr key={loc.id} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4">
<div className="font-semibold text-[var(--admin-text-primary)]">{loc.name}</div>
{(loc.city || loc.state) && (
<div className="text-xs text-[var(--admin-text-muted)]">
{[loc.city, loc.state].filter(Boolean).join(", ")}
</div>
)}
</td>
<td className="px-5 py-4">
{loc.address ? (
<div>
<div className="text-[var(--admin-text-secondary)]">{loc.address}</div>
{loc.zip && (
<div className="text-xs text-[var(--admin-text-muted)]">{loc.zip}</div>
)}
</div>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)}
</td>
<td className="px-5 py-4">
{loc.contact_name || loc.phone || loc.contact_email ? (
<div className="space-y-0.5">
{loc.contact_name && (
<div className="text-[var(--admin-text-secondary)]">{loc.contact_name}</div>
)}
{loc.phone && (
<div className="text-xs text-[var(--admin-text-muted)]">{loc.phone}</div>
)}
{loc.contact_email && (
<div className="text-xs text-[var(--admin-text-muted)] truncate max-w-[200px]">
{loc.contact_email}
</div>
)}
</div>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)}
</td>
<td className="px-5 py-4 text-center">
{loc.stop_count > 0 ? (
<span
className="inline-flex items-center justify-center min-w-[28px] px-2 py-0.5 rounded-full text-xs font-semibold"
style={{
background: "rgba(16, 185, 129, 0.12)",
color: "#047857",
}}
>
{loc.stop_count}
</span>
) : (
<span className="text-[var(--admin-text-muted)] text-xs">0</span>
)}
</td>
<td className="px-5 py-4">
{loc.active ? (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: "rgba(16, 185, 129, 0.12)", color: "#047857" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#10b981" }} />
Active
</span>
) : (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: "rgba(120, 113, 108, 0.12)", color: "#57534e" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#a8a29e" }} />
Inactive
</span>
)}
</td>
<td className="px-5 py-4 text-right">
<div className="flex justify-end gap-1">
<button
onClick={() => setEditing(loc as LocationForEdit)}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
title="Edit venue"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(loc)}
disabled={pendingDeleteId === loc.id}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50"
title="Delete venue"
>
{pendingDeleteId === loc.id ? (
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a2 2 0 012-2h2a2 2 0 012 2v3" />
</svg>
)}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
{showAdd && (
<AddLocationModal
isOpen={showAdd}
onClose={() => setShowAdd(false)}
brandId={brandId}
onSuccess={handleAdded}
/>
)}
{editing && (
<EditLocationModal
isOpen={!!editing}
onClose={() => setEditing(null)}
location={editing}
brandId={brandId}
onSuccess={handleEdited}
/>
)}
</>
);
}
+718
View File
@@ -0,0 +1,718 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import { createPortal } from "react-dom";
export type ProductFormModalProps = {
open: boolean;
mode: "add" | "edit";
onClose: () => void;
onSubmit: (values: ProductFormValues) => Promise<{ success: boolean; error?: string }>;
/** Defaults / initial values (edit mode passes the current product) */
initial?: Partial<ProductFormValues>;
/** List of selectable brands (only shown to platform admins) */
brands?: { id: string; name: string }[];
/** When true the brand picker is hidden and the admin's brand is used */
lockBrand?: boolean;
/** Hard-locked brand id (for brand_admin / store_employee) */
lockedBrandId?: string;
/** Pre-uploaded image URL (edit mode) */
initialImageUrl?: string | null;
/** Server action: upload a file and return the public URL */
onUploadImage: (file: File) => Promise<{ success: boolean; imageUrl?: string; error?: string }>;
};
export type ProductFormValues = {
name: string;
description: string;
price: string;
type: "pickup" | "wholesale" | "subscription";
brand_id: string;
active: boolean;
is_taxable: boolean;
image_url: string | null;
available_from?: string | null;
available_until?: string | null;
};
const TYPE_OPTIONS: Array<{
value: ProductFormValues["type"];
label: string;
italic: string;
icon: React.ReactNode;
}> = [
{
value: "pickup",
label: "Pickup",
italic: "Farm & stops",
icon: (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l1.5-4h15L21 9" />
<path d="M3 9v11a1 1 0 001 1h16a1 1 0 001-1V9" />
<path d="M3 9h18" />
<path d="M9 14h6" />
</svg>
),
},
{
value: "wholesale",
label: "Wholesale",
italic: "B2B portal",
icon: (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7h18l-2 12H5L3 7z" />
<path d="M8 7V5a4 4 0 018 0v2" />
</svg>
),
},
{
value: "subscription",
label: "Subscription",
italic: "Recurring",
icon: (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 11-3-6.7" />
<path d="M21 4v5h-5" />
</svg>
),
},
];
export default function ProductFormModal({
open,
mode,
onClose,
onSubmit,
initial,
brands = [],
lockBrand = false,
lockedBrandId = "",
initialImageUrl = null,
onUploadImage,
}: ProductFormModalProps) {
// Form state
const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [price, setPrice] = useState(initial?.price ?? "");
const [type, setType] = useState<ProductFormValues["type"]>(initial?.type ?? "pickup");
const [brandId, setBrandId] = useState(
initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? ""
);
const [active, setActive] = useState(initial?.active ?? true);
const [isTaxable, setIsTaxable] = useState(initial?.is_taxable ?? true);
const [availableFrom, setAvailableFrom] = useState(initial?.available_from ? initial.available_from.split('T')[0] : "");
const [availableUntil, setAvailableUntil] = useState(initial?.available_until ? initial.available_until.split('T')[0] : "");
// Image state
const [imagePreview, setImagePreview] = useState<string | null>(initialImageUrl);
const [imageUrl, setImageUrl] = useState<string | null>(initialImageUrl);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isDrag, setIsDrag] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Submit state
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Reset state when opening with new initial values
useEffect(() => {
if (!open) return;
setName(initial?.name ?? "");
setDescription(initial?.description ?? "");
setPrice(initial?.price ?? "");
setType((initial?.type as ProductFormValues["type"]) ?? "pickup");
setBrandId(initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? "");
setActive(initial?.active ?? true);
setIsTaxable(initial?.is_taxable ?? true);
setAvailableFrom(initial?.available_from ? initial.available_from.split('T')[0] : "");
setAvailableUntil(initial?.available_until ? initial.available_until.split('T')[0] : "");
setImagePreview(initialImageUrl);
setImageUrl(initialImageUrl);
setUploading(false);
setUploadError(null);
setError(null);
setSaving(false);
setIsDrag(false);
}, [open, initial, initialImageUrl, lockedBrandId, brands]);
// Body scroll lock + escape key
useEffect(() => {
if (!open) return;
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = original;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose]);
const handleFile = useCallback(
async (file: File) => {
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
setUploadError("PNG, JPEG, or WebP only.");
return;
}
if (file.size > 5 * 1024 * 1024) {
setUploadError("File must be under 5MB.");
return;
}
setUploadError(null);
setUploading(true);
// Local preview first
const localUrl = URL.createObjectURL(file);
setImagePreview(localUrl);
const result = await onUploadImage(file);
setUploading(false);
if (result.success && result.imageUrl) {
setImageUrl(result.imageUrl);
setImagePreview(result.imageUrl);
URL.revokeObjectURL(localUrl);
} else {
setUploadError(result.error ?? "Upload failed.");
setImagePreview(imageUrl); // revert
}
},
[onUploadImage, imageUrl]
);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("Product name is required.");
return;
}
if (!price || isNaN(parseFloat(price)) || parseFloat(price) < 0) {
setError("Valid price is required.");
return;
}
if (!lockBrand && !brandId) {
setError("Please select a brand.");
return;
}
setSaving(true);
const res = await onSubmit({
name: name.trim(),
description: description.trim(),
price,
type,
brand_id: lockBrand ? lockedBrandId : brandId,
active,
is_taxable: isTaxable,
image_url: imageUrl,
available_from: availableFrom ? new Date(availableFrom).toISOString() : null,
available_until: availableUntil ? new Date(availableUntil).toISOString() : null,
});
setSaving(false);
if (!res.success) {
setError(res.error ?? "Something went wrong.");
return;
}
onClose();
}
if (!mounted || !open) return null;
const showBrandPicker = !lockBrand;
const lockedBrandName = brands.find((b) => b.id === lockedBrandId)?.name ?? lockedBrandId;
return createPortal(
<div
className="fixed inset-0 z-[80] flex items-center justify-center p-3 sm:p-6 atelier-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{
backgroundColor: "rgba(28, 25, 23, 0.55)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
}}
>
<div
role="dialog"
aria-modal="true"
aria-label={mode === "add" ? "Add product" : "Edit product"}
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl shadow-[0_30px_80px_-20px_rgba(28,25,23,0.45)] flex flex-col overflow-hidden border border-stone-200/60"
>
{/* grain overlay */}
<div className="atelier-grain" aria-hidden />
{/* HEADER */}
<div className="relative shrink-0 px-6 sm:px-10 pt-6 sm:pt-8 pb-5 sm:pb-6">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2.5 mb-3">
<span className="atelier-section-num">
{mode === "add" ? "Nouveau" : "Mise à jour"} · MMXXVI
</span>
</div>
<h2 className="atelier-title text-3xl sm:text-4xl md:text-5xl">
{mode === "add" ? (
<>
Add a <span className="atelier-italic">product</span>
</>
) : (
<>
Edit <span className="atelier-italic">{initial?.name || "product"}</span>
</>
)}
</h2>
<p className="atelier-italic mt-2 text-sm sm:text-[15px] max-w-md leading-relaxed">
{mode === "add"
? "Compose a new entry for the harvest catalog — every detail, like a recipe."
: "Refine the details of this entry. The catalog is a living record."}
</p>
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 flex h-9 w-9 items-center justify-center rounded-full transition-all duration-200 hover:bg-stone-900 hover:text-white text-stone-500"
aria-label="Close"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="relative shrink-0 px-6 sm:px-10">
<hr className="atelier-rule" />
</div>
{/* BODY — two columns */}
<form
onSubmit={handleSubmit}
className="relative flex-1 overflow-y-auto"
onDragEnter={(e) => {
e.preventDefault();
setIsDrag(true);
}}
>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] gap-0">
{/* LEFT — Image hero */}
<div className="relative px-6 sm:px-10 pt-6 sm:pt-8 pb-6 lg:border-r border-stone-200/60 lg:pr-8">
<div className="atelier-section-num mb-4">01 · Media</div>
<div
onDragOver={(e) => {
e.preventDefault();
setIsDrag(true);
}}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setIsDrag(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDrag(false);
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
}}
onClick={() => !uploading && fileInputRef.current?.click()}
className={[
"atelier-drop",
isDrag ? "is-drag" : "",
uploadError ? "is-error" : "",
imagePreview ? "has-image" : "",
].join(" ")}
>
{/* status pill */}
<div
className={[
"atelier-status-pill",
imagePreview ? "" : "is-empty",
].join(" ")}
>
<span className="atelier-dot" />
{imagePreview
? uploading
? "Uploading"
: "Image set"
: "No image yet"}
</div>
{uploading ? (
<div className="flex flex-col items-center gap-3">
<div className="h-6 w-6 rounded-full border-2 border-stone-300 border-t-[#224E2F] animate-spin" />
<p className="atelier-drop-title">Pouring into the cellar</p>
</div>
) : imagePreview ? (
<div className="absolute inset-0 p-3 sm:p-4">
<div className="relative h-full w-full">
<Image
src={imagePreview}
alt="Product preview"
fill
style={{ objectFit: "contain" }}
className="rounded-md"
/>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setImagePreview(null);
setImageUrl(null);
}}
className="absolute bottom-4 left-1/2 -translate-x-1/2 inline-flex items-center gap-1.5 rounded-full bg-stone-900/85 backdrop-blur-sm px-3 py-1.5 text-[11px] font-mono uppercase tracking-wider text-white hover:bg-stone-900 transition-colors"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
Remove
</button>
</div>
) : (
<>
{/* ornamental drop zone content */}
<svg
className="h-10 w-10 text-[#224E2F]/70"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 3v13" />
<path d="M7 8l5-5 5 5" />
<path d="M4 16v3a2 2 0 002 2h12a2 2 0 002-2v-3" />
<circle cx="12" cy="20" r="0.8" fill="currentColor" />
</svg>
<p className="atelier-drop-eyebrow">Drag &amp; drop</p>
<p className="atelier-drop-title">
or click to choose<br />a harvest portrait
</p>
<p className="atelier-drop-hint">PNG · JPEG · WebP · 5MB max</p>
</>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
e.target.value = "";
}}
/>
</div>
{uploadError && (
<p className="mt-3 atelier-hint" style={{ color: "#B91C1C" }}>
{uploadError}
</p>
)}
</div>
{/* RIGHT — Form fields */}
<div className="px-6 sm:px-10 pt-6 sm:pt-8 pb-6 lg:pl-10 space-y-7 atelier-stagger">
{error && (
<div className="atelier-error">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{error}</span>
</div>
)}
{/* 02 IDENTITY */}
<section>
<div className="flex items-baseline justify-between mb-4">
<div className="atelier-section-num">02 · Identity</div>
</div>
<div>
<label
htmlFor="atelier-name"
className="atelier-section-label block mb-1"
>
Product name
</label>
<input
id="atelier-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Dozen Sweet Corn"
className="atelier-input atelier-input--display"
autoComplete="off"
/>
</div>
<div className="mt-5">
<label
htmlFor="atelier-desc"
className="atelier-section-label block mb-1"
>
Description
</label>
<textarea
id="atelier-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="A note from the field — origin, variety, picking notes…"
rows={2}
className="atelier-input resize-none"
style={{ minHeight: 56 }}
/>
</div>
</section>
{/* 03 COMMERCE */}
<section>
<div className="atelier-section-num mb-4">03 · Commerce</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label
htmlFor="atelier-price"
className="atelier-section-label block mb-1"
>
Price
</label>
<div className="relative">
<span className="atelier-price-sigil">$</span>
<input
id="atelier-price"
type="number"
step="0.01"
min="0"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="0.00"
className="atelier-price"
/>
</div>
</div>
{showBrandPicker ? (
<div>
<label
htmlFor="atelier-brand"
className="atelier-section-label block mb-1"
>
Brand
</label>
<select
id="atelier-brand"
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
className="atelier-select"
>
{brands.length === 0 && <option value="">Loading brands</option>}
{brands.length > 0 && <option value="">Select a brand</option>}
{brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
<p className="atelier-hint">
You administer multiple brands choose the one this product belongs to.
</p>
</div>
) : (
<div>
<span className="atelier-section-label block mb-1">Brand</span>
<div className="py-2.5 border-b border-stone-300/40">
<span className="font-[family-name:var(--font-fraunces)] text-[15px] text-stone-700">
{lockedBrandName || "—"}
</span>
</div>
</div>
)}
</div>
<div className="mt-6">
<span className="atelier-section-label block mb-2">Type</span>
<div className="grid grid-cols-3 gap-2.5">
{TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setType(opt.value)}
className={[
"atelier-type-card",
type === opt.value ? "is-selected" : "",
].join(" ")}
aria-pressed={type === opt.value}
>
<div className="flex items-center justify-between w-full">
<span className="atelier-type-icon">{opt.icon}</span>
<svg
className="atelier-type-check h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<div className="atelier-type-name">{opt.label}</div>
<div
className={[
"text-[10px] font-[family-name:var(--font-fragment-mono)] uppercase tracking-wider mt-0.5",
type === opt.value ? "text-white/60" : "text-stone-500",
].join(" ")}
>
{opt.italic}
</div>
</div>
</button>
))}
</div>
</div>
</section>
{/* 04 SETTINGS */}
<section>
<div className="atelier-section-num mb-4">04 · Settings</div>
<div className="flex flex-wrap items-center gap-6 sm:gap-10">
<button
type="button"
onClick={() => setActive((v) => !v)}
className={["atelier-toggle", active ? "is-on" : ""].join(" ")}
aria-pressed={active}
>
<span className="atelier-toggle-track">
<span className="atelier-toggle-thumb" />
</span>
<span className="atelier-toggle-label">Active</span>
</button>
<button
type="button"
onClick={() => setIsTaxable((v) => !v)}
className={["atelier-toggle is-gold", isTaxable ? "is-on" : ""].join(" ")}
aria-pressed={isTaxable}
>
<span className="atelier-toggle-track">
<span className="atelier-toggle-thumb" />
</span>
<span className="atelier-toggle-label">Taxable</span>
</button>
<p className="atelier-hint flex-1 min-w-[200px]">
Active products appear in the storefront. Taxable items add sales tax at checkout.
</p>
</div>
{/* Seasonal Availability */}
<div className="mt-6 pt-6 border-t border-stone-200/40">
<span className="atelier-section-label block mb-3">Seasonal Availability</span>
<p className="atelier-hint mb-4">Set when this product is available (e.g., mid-July through mid-September)</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="atelier-available-from" className="block text-[11px] font-mono uppercase tracking-wider text-stone-500 mb-1.5">Start Date</label>
<input
id="atelier-available-from"
type="date"
value={availableFrom}
onChange={(e) => setAvailableFrom(e.target.value)}
className="atelier-input"
/>
</div>
<div>
<label htmlFor="atelier-available-until" className="block text-[11px] font-mono uppercase tracking-wider text-stone-500 mb-1.5">End Date</label>
<input
id="atelier-available-until"
type="date"
value={availableUntil}
onChange={(e) => setAvailableUntil(e.target.value)}
className="atelier-input"
/>
</div>
</div>
{(availableFrom || availableUntil) && (
<button
type="button"
onClick={() => { setAvailableFrom(""); setAvailableUntil(""); }}
className="mt-2 text-[11px] font-mono uppercase tracking-wider text-stone-400 hover:text-stone-600"
>
Clear dates
</button>
)}
</div>
</section>
</div>
</div>
</form>
{/* FOOTER */}
<div className="relative shrink-0 px-6 sm:px-10 pt-4 pb-6 sm:pb-7 border-t border-stone-200/60 bg-[#FAF7F0]/80 backdrop-blur-sm">
<div className="flex items-center justify-between gap-4">
<button
type="button"
onClick={onClose}
className="atelier-discard"
>
discard changes
</button>
<div className="flex items-center gap-3">
{/* readiness indicator */}
<div className="hidden sm:flex items-center gap-2 text-[11px] font-mono uppercase tracking-wider text-stone-500">
<span
className={[
"h-1.5 w-1.5 rounded-full",
name.trim() && price
? "bg-emerald-600 shadow-[0_0_0_3px_rgba(22,163,74,0.15)]"
: "bg-stone-300",
].join(" ")}
/>
{name.trim() && price ? "Ready" : "Required fields pending"}
</div>
<button
type="submit"
form="atelier-product-form"
onClick={handleSubmit}
disabled={saving || uploading || !name.trim() || !price}
className="atelier-cta"
>
<span className="atelier-cta-shimmer" />
{saving ? (
<>
<span className="h-3.5 w-3.5 rounded-full border-2 border-white/40 border-t-white animate-spin" />
{mode === "add" ? "Composing…" : "Saving…"}
</>
) : (
<>
{mode === "add" ? "Add to catalog" : "Save changes"}
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
);
}
+209 -382
View File
@@ -1,13 +1,14 @@
"use client"; "use client";
import { useState, useCallback, useRef, useTransition } from "react"; import { useState, useCallback, useTransition, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { createProduct } from "@/actions/products/create-product"; import { createProduct } from "@/actions/products/create-product";
import { updateProduct } from "@/actions/products/update-product"; import { updateProduct } from "@/actions/products/update-product";
import { deleteProduct } from "@/actions/products"; import { deleteProduct } from "@/actions/products";
import { uploadProductImage } from "@/actions/products/upload-image"; import { uploadProductImage } from "@/actions/products/upload-image";
import GlassModal from "@/components/admin/GlassModal"; import ProductFormModal, { type ProductFormValues } from "@/components/admin/ProductFormModal";
import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, useToast, Skeleton } from "@/components/admin/design-system"; import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, useToast, Skeleton } from "@/components/admin/design-system";
type Product = { type Product = {
@@ -20,16 +21,8 @@ type Product = {
image_url?: string | null; image_url?: string | null;
brand_id: string; brand_id: string;
is_taxable: boolean; is_taxable: boolean;
}; available_from?: string | null;
available_until?: string | null;
type FormData = {
name: string;
description: string;
price: string;
type: string;
active: boolean;
image_url: string;
is_taxable: boolean;
}; };
type ViewMode = "table" | "cards"; type ViewMode = "table" | "cards";
@@ -74,7 +67,17 @@ const PackageIconHeader = () => (
</svg> </svg>
); );
export default function ProductsClient({ products, brandId }: { products: Product[]; brandId?: string }) { export default function ProductsClient({
products,
brandId,
brands = [],
isPlatformAdmin = false,
}: {
products: Product[];
brandId?: string;
brands?: { id: string; name: string }[];
isPlatformAdmin?: boolean;
}) {
const router = useRouter(); const router = useRouter();
const { success: showSuccess, error: showError } = useToast(); const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
@@ -83,28 +86,10 @@ export default function ProductsClient({ products, brandId }: { products: Produc
const [viewMode, setViewMode] = useState<ViewMode>("table"); const [viewMode, setViewMode] = useState<ViewMode>("table");
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null); const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [formData, setFormData] = useState<FormData>({
name: "",
description: "",
price: "",
type: "pickup",
active: true,
image_url: "",
is_taxable: true,
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Image upload states
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [pendingImageUrl, setPendingImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const filtered = products.filter((p) => { const filtered = products.filter((p) => {
const matchesSearch = const matchesSearch =
!search || !search ||
@@ -147,155 +132,104 @@ export default function ProductsClient({ products, brandId }: { products: Produc
}); });
} }
async function handleFileSelect(file: File) { const handleUploadImage = useCallback(
const validTypes = ["image/png", "image/jpeg", "image/webp"]; async (file: File): Promise<{ success: boolean; imageUrl?: string; error?: string }> => {
if (!validTypes.includes(file.type)) { const validTypes = ["image/png", "image/jpeg", "image/webp"];
setUploadError("Only PNG, JPEG, and WebP images are allowed."); if (!validTypes.includes(file.type)) {
return; return { success: false, error: "Only PNG, JPEG, and WebP images are allowed." };
} }
if (file.size > 5 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
setUploadError("Image must be under 5MB."); return { success: false, error: "Image must be under 5MB." };
return; }
}
setUploadError(null); const resizedBuffer = await resizeImage(file, 1200);
setUploading(true); const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
const resizedBuffer = await resizeImage(file, 1200); const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" }); const result = await uploadProductImage(productIdForUpload, resizedFile);
if (result.success) {
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__"; return { success: true, imageUrl: result.imageUrl };
const result = await uploadProductImage(productIdForUpload, resizedFile); }
return { success: false, error: result.error };
setUploading(false); },
[editingProduct]
if (result.success) { );
setPendingImageUrl(result.imageUrl);
setImagePreview(result.imageUrl);
setFormData({ ...formData, image_url: result.imageUrl });
} else {
setUploadError(result.error ?? "Upload failed.");
}
}
const openAddModal = useCallback(() => { const openAddModal = useCallback(() => {
setEditingProduct(null); setEditingProduct(null);
setFormData({
name: "",
description: "",
price: "",
type: "pickup",
active: true,
image_url: "",
is_taxable: true,
});
setImagePreview(null);
setPendingImageUrl(null);
setUploadError(null);
setError(null);
setShowModal(true); setShowModal(true);
}, []); }, []);
const openEditModal = useCallback((product: Product) => { const openEditModal = useCallback((product: Product) => {
setEditingProduct(product); setEditingProduct(product);
setFormData({
name: product.name,
description: product.description || "",
price: String(product.price),
type: product.type,
active: product.active,
image_url: product.image_url || "",
is_taxable: product.is_taxable,
});
setImagePreview(product.image_url || null);
setPendingImageUrl(null);
setUploadError(null);
setError(null);
setShowModal(true); setShowModal(true);
}, []); }, []);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
setShowModal(false); setShowModal(false);
setEditingProduct(null); setEditingProduct(null);
setImagePreview(null);
setPendingImageUrl(null);
setUploadError(null);
setError(null);
}, []); }, []);
const handleSubmit = async (e: React.FormEvent) => { const handleModalSubmit = useCallback(
e.preventDefault(); async (values: ProductFormValues): Promise<{ success: boolean; error?: string }> => {
setError(null); setIsLoading(true);
setSaving(true); try {
setIsLoading(true); const price = parseFloat(values.price);
if (isNaN(price) || price < 0) {
return { success: false, error: "Please enter a valid price" };
}
if (!values.name.trim()) {
return { success: false, error: "Product name is required" };
}
const price = parseFloat(formData.price); // Platform admins pick a brand in the modal; everyone else is locked.
if (isNaN(price) || price < 0) { const effectiveBrandId = isPlatformAdmin ? values.brand_id : brandId;
setError("Please enter a valid price"); if (!effectiveBrandId) {
setSaving(false); return { success: false, error: "Please choose a brand for this product." };
setIsLoading(false); }
return;
}
if (!formData.name.trim()) { let result;
setError("Product name is required"); if (editingProduct) {
setSaving(false); result = await updateProduct(editingProduct.id, effectiveBrandId, {
setIsLoading(false); name: values.name.trim(),
return; description: values.description.trim(),
} price,
type: values.type,
active: values.active,
image_url: values.image_url,
is_taxable: values.is_taxable,
});
} else {
result = await createProduct(effectiveBrandId, {
name: values.name.trim(),
description: values.description.trim(),
price,
type: values.type,
active: values.active,
image_url: values.image_url,
is_taxable: values.is_taxable,
});
}
try { if (!result.success) {
if (!brandId) { showError("Failed to save product", result.error ?? "Please try again");
setError("Brand ID is required"); return { success: false, error: result.error };
setSaving(false); }
showSuccess(
editingProduct ? "Product updated" : "Product created",
`${values.name} has been saved`
);
startTransition(() => router.refresh());
return { success: true };
} catch {
return { success: false, error: "Network error. Please try again." };
} finally {
setIsLoading(false); setIsLoading(false);
return;
} }
},
let result; [editingProduct, brandId, isPlatformAdmin, router, showError, showSuccess]
const imageUrl = pendingImageUrl || formData.image_url || null; );
if (editingProduct) {
result = await updateProduct(editingProduct.id, brandId, {
name: formData.name.trim(),
description: formData.description.trim(),
price,
type: formData.type,
active: formData.active,
image_url: imageUrl,
is_taxable: formData.is_taxable,
});
} else {
result = await createProduct(brandId, {
name: formData.name.trim(),
description: formData.description.trim(),
price,
type: formData.type,
active: formData.active,
image_url: imageUrl,
is_taxable: formData.is_taxable,
});
}
if (!result.success) {
setError(result.error ?? "Failed to save product");
showError("Failed to save product", result.error ?? "Please try again");
setSaving(false);
setIsLoading(false);
return;
}
showSuccess(editingProduct ? "Product updated" : "Product created", `${formData.name} has been saved`);
closeModal();
setIsLoading(false);
startTransition(() => router.refresh());
} catch {
setError("Network error. Please try again.");
showError("Network error", "Please check your connection and try again");
setSaving(false);
setIsLoading(false);
}
};
const handleDelete = async (productId: string) => { const handleDelete = async (productId: string) => {
setDeletingId(productId); setDeletingId(productId);
@@ -399,203 +333,38 @@ export default function ProductsClient({ products, brandId }: { products: Produc
/> />
)} )}
{/* Modal */} {/* Modal — Atelier des Récoltes (editorial product form) */}
{showModal && ( <ProductFormModal
<GlassModal open={showModal}
title={editingProduct ? "Edit Product" : "Add Product"} mode={editingProduct ? "edit" : "add"}
subtitle={editingProduct ? `Editing ${editingProduct.name}` : "Create a new product"} onClose={closeModal}
onClose={closeModal} onSubmit={handleModalSubmit}
> onUploadImage={handleUploadImage}
<form onSubmit={handleSubmit} className="space-y-4"> brands={brands}
{error && ( lockBrand={!isPlatformAdmin}
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600 flex items-start gap-3"> lockedBrandId={brandId ?? ""}
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> initial={
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> editingProduct
</svg> ? {
{error} name: editingProduct.name,
</div> description: editingProduct.description || "",
)} price: String(editingProduct.price),
type: (editingProduct.type as "pickup" | "wholesale" | "subscription") ?? "pickup",
<div> brand_id: editingProduct.brand_id,
<label className="block text-xs font-semibold text-stone-700 mb-1.5"> active: editingProduct.active,
Name <span className="text-red-500">*</span> is_taxable: editingProduct.is_taxable,
</label> available_from: editingProduct.available_from ?? null,
<input available_until: editingProduct.available_until ?? null,
type="text" }
value={formData.name} : undefined
onChange={(e) => setFormData({ ...formData, name: e.target.value })} }
placeholder="Product name" initialImageUrl={editingProduct?.image_url ?? null}
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] transition-colors ${ />
error && !formData.name.trim()
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Product description"
rows={2}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
Price <span className="text-red-500">*</span>
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
<input
type="number"
step="0.01"
min="0"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
placeholder="0.00"
className={`w-full rounded-xl border pl-8 pr-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
error && (!formData.price || parseFloat(formData.price) < 0)
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
required
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Type</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
>
<option value="pickup">Pickup</option>
<option value="wholesale">Wholesale</option>
<option value="subscription">Subscription</option>
</select>
</div>
</div>
{/* Image Upload */}
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Product Image</label>
<p className="text-[10px] text-stone-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB</p>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}}
onClick={() => !uploading && fileInputRef.current?.click()}
className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${uploadError ? "border-red-400" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)] hover:bg-[var(--admin-accent)]/5"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
<span className="text-sm text-stone-500">Uploading...</span>
</>
) : imagePreview ? (
<div className="relative">
<div className="relative h-32 sm:h-40 w-auto">
<Image src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</div>
<span className="block text-center text-xs text-stone-500 mt-2">Click or drop to replace</span>
</div>
) : (
<>
{Icons.upload("h-8 w-8 text-stone-400")}
<span className="text-sm text-stone-500">Drag & drop or click to upload</span>
</>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
e.target.value = "";
}}
/>
</div>
{uploadError && (
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
)}
{(imagePreview || formData.image_url) && (
<button
type="button"
onClick={() => {
setImagePreview(null);
setPendingImageUrl(null);
setFormData({ ...formData, image_url: "" });
}}
className="mt-2 text-xs text-red-500 hover:underline"
>
Remove image
</button>
)}
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
/>
<span className="text-sm font-medium text-stone-700">Active</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.is_taxable}
onChange={(e) => setFormData({ ...formData, is_taxable: e.target.checked })}
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
/>
<span className="text-sm font-medium text-stone-700">Taxable</span>
</label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-stone-100">
<AdminButton
type="button"
variant="secondary"
onClick={closeModal}
>
Cancel
</AdminButton>
<AdminButton
type="submit"
variant="primary"
isLoading={saving}
disabled={!formData.name.trim() || !formData.price}
>
{editingProduct ? "Save Changes" : "Create Product"}
</AdminButton>
</div>
</form>
</GlassModal>
)}
</div> </div>
); );
} }
// ── Table View ───────────────────────────────────────────────────────────────── // ── Table View ─────────────────────────────────────────────────────────────────
function TableView({ function TableView({
@@ -615,8 +384,51 @@ function TableView({
onDeleteCancel: () => void; onDeleteCancel: () => void;
deletingId: string | null; deletingId: string | null;
}) { }) {
// Track the position of the open row's three-dots button so the popup can be
// rendered via portal at body level (escapes any overflow:hidden ancestors
// and any table-row stacking-context quirks).
const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null);
const [mounted, setMounted] = useState(false);
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!deleteConfirm) {
setMenuPos(null);
return;
}
const btn = buttonRefs.current[deleteConfirm];
if (btn) {
const rect = btn.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
}
}, [deleteConfirm]);
// Reposition on scroll/resize so the popup stays anchored to its button.
useEffect(() => {
if (!deleteConfirm) return;
const updatePos = () => {
const btn = buttonRefs.current[deleteConfirm];
if (btn) {
const rect = btn.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
}
};
window.addEventListener("scroll", updatePos, true);
window.addEventListener("resize", updatePos);
return () => {
window.removeEventListener("scroll", updatePos, true);
window.removeEventListener("resize", updatePos);
};
}, [deleteConfirm]);
const openProduct = deleteConfirm ? products.find((p) => p.id === deleteConfirm) : null;
return ( return (
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-white"> <div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-white">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-stone-50"> <thead className="bg-stone-50">
<tr> <tr>
@@ -700,44 +512,15 @@ function TableView({
Edit Edit
</AdminButton> </AdminButton>
<button <button
ref={(el) => { buttonRefs.current[product.id] = el; }}
onClick={() => onDelete(product.id)} onClick={() => onDelete(product.id)}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors" className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
aria-label="Product actions"
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg> </svg>
</button> </button>
{deleteConfirm === product.id && (
<>
<div className="fixed inset-0 z-30" onClick={onDeleteCancel} />
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-1 text-xs text-stone-500">
This will remove the product. If attached to orders, it will be hidden.
</p>
<div className="mt-3 flex gap-2">
<button
onClick={onDeleteCancel}
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<AdminButton
onClick={() => onDeleteConfirm(product.id)}
disabled={deletingId === product.id}
variant="danger"
size="sm"
className="flex-1"
>
{deletingId === product.id ? "..." : "Delete"}
</AdminButton>
</div>
</div>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>
@@ -745,6 +528,50 @@ function TableView({
)} )}
</tbody> </tbody>
</table> </table>
{/* Delete confirmation popup rendered via portal at body level with
position: fixed so it sits above all table rows and escapes the
overflow-visible container. */}
{mounted && deleteConfirm && openProduct && menuPos &&
createPortal(
<>
<div
className="fixed inset-0 z-[60]"
onClick={onDeleteCancel}
/>
<div
role="dialog"
aria-label="Confirm delete"
className="fixed z-[70] w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4"
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
>
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{openProduct.name}&quot;?
</p>
<p className="mt-1 text-xs text-stone-500">
This will remove the product. If attached to orders, it will be hidden.
</p>
<div className="mt-3 flex gap-2">
<button
onClick={onDeleteCancel}
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<AdminButton
onClick={() => onDeleteConfirm(openProduct.id)}
disabled={deletingId === openProduct.id}
variant="danger"
size="sm"
className="flex-1"
>
{deletingId === openProduct.id ? "..." : "Delete"}
</AdminButton>
</div>
</div>
</>,
document.body
)}
</div> </div>
); );
} }
+31
View File
@@ -0,0 +1,31 @@
type Stat = {
label: string;
value: number | string;
emphasis?: boolean;
};
type Props = {
stats: Stat[];
/** Right-aligned slot (e.g. an inline "Next stop" pill or refresh action) */
right?: React.ReactNode;
};
export default function StatsStrip({ stats, right }: Props) {
return (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
{stats.map((s, i) => (
<span key={i} className="flex items-baseline gap-1.5">
<span
className={`font-bold tabular-nums ${s.emphasis ? "text-emerald-700" : "text-[var(--admin-text-primary)]"}`}
>
{s.value}
</span>
<span className="text-[var(--admin-text-muted)]">{s.label}</span>
</span>
))}
</div>
{right && <div className="ml-auto">{right}</div>}
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import GlassModal from "@/components/admin/GlassModal";
import StopEditForm from "@/components/admin/StopEditForm";
import StopProductAssignment from "@/components/admin/StopProductAssignment";
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
import { getStopDetails } from "@/actions/stops/get-stop-details";
import { useToast } from "@/components/admin/design-system";
import type { StopDetail, AssignedProduct } from "@/actions/stops/get-stop-details";
type Tab = "details" | "products" | "message";
type Props = {
stopId: string;
/** Optional: when the user clicks Duplicate from inside the modal. */
onDuplicate?: (stopId: string) => void;
onClose: () => void;
};
export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props) {
const router = useRouter();
const { success: showSuccess } = useToast();
const [, startTransition] = useTransition();
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [stop, setStop] = useState<StopDetail | null>(null);
const [allProducts, setAllProducts] = useState<{ id: string; name: string; type: string; price: number }[]>([]);
const [assignedProducts, setAssignedProducts] = useState<AssignedProduct[]>([]);
const [brands, setBrands] = useState<{ id: string; name: string; slug: string }[]>([]);
const [callerUid, setCallerUid] = useState<string>("");
const [tab, setTab] = useState<Tab>("details");
useEffect(() => {
let cancelled = false;
setLoading(true);
setLoadError(null);
getStopDetails(stopId)
.then((res) => {
if (cancelled) return;
if (!res.success) {
setLoadError(res.error);
setLoading(false);
return;
}
setStop(res.stop);
setAllProducts(res.allProducts);
setAssignedProducts(res.assignedProducts);
setBrands(res.brands);
setCallerUid(res.callerUid);
setLoading(false);
})
.catch((err) => {
if (cancelled) return;
setLoadError(err?.message ?? "Failed to load stop");
setLoading(false);
});
return () => {
cancelled = true;
};
}, [stopId]);
function refresh() {
getStopDetails(stopId).then((res) => {
if (!res.success) return;
setStop(res.stop);
setAllProducts(res.allProducts);
setAssignedProducts(res.assignedProducts);
setBrands(res.brands);
setCallerUid(res.callerUid);
});
}
function handleEditSaved() {
refresh();
showSuccess("Stop updated", "Changes have been saved");
startTransition(() => router.refresh());
}
const subtitle = stop?.brands
? (Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands.name) ?? undefined
: undefined;
return (
<GlassModal
title={loading ? "Loading…" : stop ? `${stop.city}, ${stop.state}` : "Stop"}
subtitle={subtitle ?? "Stop details"}
onClose={onClose}
maxWidth="max-w-3xl"
>
{loading ? (
<div className="space-y-3">
<div className="h-4 w-1/3 animate-pulse rounded bg-stone-200" />
<div className="h-4 w-2/3 animate-pulse rounded bg-stone-200" />
<div className="h-4 w-1/2 animate-pulse rounded bg-stone-200" />
</div>
) : loadError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{loadError}
</div>
) : stop ? (
<div className="space-y-5">
{/* Tabs */}
<div
className="flex items-center gap-1 rounded-xl border border-[var(--admin-border)] bg-stone-50 p-1"
role="tablist"
>
<TabButton active={tab === "details"} onClick={() => setTab("details")}>
Details
</TabButton>
<TabButton active={tab === "products"} onClick={() => setTab("products")}>
<span className="inline-flex items-center gap-1.5">
Products
{assignedProducts.length > 0 && (
<span className="rounded-full bg-[var(--admin-accent)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--admin-accent)]">
{assignedProducts.length}
</span>
)}
</span>
</TabButton>
<TabButton active={tab === "message"} onClick={() => setTab("message")}>
Message
</TabButton>
</div>
{tab === "details" && (
<DetailsPanel
stop={stop}
brands={brands}
onDuplicate={onDuplicate}
onSaved={handleEditSaved}
/>
)}
{tab === "products" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
Assigned Products
</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Manage which products are available at this stop.
</p>
<div className="mt-4">
<StopProductAssignment
stopId={stop.id}
allProducts={allProducts}
assignedProducts={assignedProducts}
callerUid={callerUid}
/>
</div>
</div>
)}
{tab === "message" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
Message Customers
</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Send updates to customers with pending pickups at this stop.
</p>
<div className="mt-4">
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
</div>
</div>
)}
</div>
) : null}
</GlassModal>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all ${
active
? "bg-white text-[var(--admin-accent)] shadow-sm border border-[var(--admin-border)]"
: "text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)]"
}`}
>
{children}
</button>
);
}
function DetailsPanel({
stop,
brands,
onDuplicate,
onSaved,
}: {
stop: StopDetail;
brands: { id: string; name: string; slug: string }[];
onDuplicate?: (stopId: string) => void;
onSaved: () => void;
}) {
return (
<div className="space-y-5">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">
{stop.city}, {stop.state}
</h3>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">{stop.location}</p>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${
stop.active
? "bg-emerald-100 text-emerald-700"
: "bg-stone-200 text-stone-500"
}`}
>
{stop.active ? "Active" : "Inactive"}
</span>
</div>
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Date</dt>
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.date}</dd>
</div>
<div>
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Time</dt>
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.time}</dd>
</div>
{stop.address && (
<div className="col-span-2">
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Address</dt>
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
{stop.address}
{stop.zip ? `, ${stop.zip}` : ""}
</dd>
</div>
)}
{stop.cutoff_time && (
<div className="col-span-2">
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Cutoff</dt>
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
{new Date(stop.cutoff_time).toLocaleString()}
</dd>
</div>
)}
</dl>
</div>
<div className="flex justify-end">
{onDuplicate ? (
<button
type="button"
onClick={() => onDuplicate(stop.id)}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
>
Duplicate Stop
</button>
) : (
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
>
Duplicate Stop
</a>
)}
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Edit Stop</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Update stop details, location, and availability.
</p>
<div className="mt-4">
<StopEditForm
stop={{
id: stop.id,
city: stop.city,
state: stop.state,
date: stop.date,
time: stop.time,
location: stop.location,
slug: stop.slug,
active: stop.active,
brand_id: stop.brand_id,
address: stop.address,
zip: stop.zip,
cutoff_time: stop.cutoff_time,
}}
brands={brands}
onSaved={onSaved}
/>
</div>
</div>
</div>
);
}
+4 -1
View File
@@ -27,9 +27,11 @@ type StopEditFormProps = {
cutoff_time?: string | null; cutoff_time?: string | null;
}; };
brands: Brand[]; brands: Brand[];
/** Optional callback fired after a successful save. */
onSaved?: () => void;
}; };
export default function StopEditForm({ stop, brands }: StopEditFormProps) { export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProps) {
const router = useRouter(); const router = useRouter();
const { success: showSuccess, error: showError } = useToast(); const { success: showSuccess, error: showError } = useToast();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -99,6 +101,7 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) {
showSuccess("Stop updated", `${city}, ${state} has been saved`); showSuccess("Stop updated", `${city}, ${state} has been saved`);
setSaved(true); setSaved(true);
setSaving(false); setSaving(false);
onSaved?.();
router.refresh(); router.refresh();
} }
+373 -168
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useMemo, useEffect } from "react";
import Image from "next/image";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
type Product = { type Product = {
@@ -8,12 +9,13 @@ type Product = {
name: string; name: string;
type: string; type: string;
price: number; price: number;
image_url?: string | null;
}; };
type AssignedProduct = { type AssignedProduct = {
id: string; id: string;
product_id: string; product_id: string;
products: { name: string; type: string; price: number } | null; products: { name: string; type: string; price: number; image_url?: string | null } | null;
}; };
type StopProductAssignmentProps = { type StopProductAssignmentProps = {
@@ -23,6 +25,20 @@ type StopProductAssignmentProps = {
callerUid: string; callerUid: string;
}; };
type Filter = "all" | "available" | "assigned";
/* Helpers — monospace price + initial-letter avatar */
const formatPrice = (n: number) =>
`$${Number(n ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const initialOf = (name: string) => (name?.trim()?.charAt(0) ?? "·").toUpperCase();
const TYPE_LABEL: Record<string, string> = {
pickup: "Pickup",
wholesale: "Wholesale",
subscription: "Sub",
};
export default function StopProductAssignment({ export default function StopProductAssignment({
stopId, stopId,
allProducts, allProducts,
@@ -30,208 +46,397 @@ export default function StopProductAssignment({
callerUid, callerUid,
}: StopProductAssignmentProps) { }: StopProductAssignmentProps) {
const [products, setProducts] = useState(assignedProducts); const [products, setProducts] = useState(assignedProducts);
const [selected, setSelected] = useState(""); const [search, setSearch] = useState("");
const [assigning, setAssigning] = useState(false); const [filter, setFilter] = useState<Filter>("all");
const [removing, setRemoving] = useState<string | null>(null); const [pendingId, setPendingId] = useState<string | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const assignedIds = new Set(products.map((p) => p.product_id)); const assignedIds = useMemo(
const availableProducts = allProducts.filter((p) => !assignedIds.has(p.id)); () => new Set(products.map((p) => p.product_id)),
[products]
);
async function handleAssign(e: React.FormEvent) { // Counters for the filter chips
e.preventDefault(); const counts = useMemo(() => {
if (!selected) return; const assignedCount = products.length;
const availableCount = allProducts.filter((p) => !assignedIds.has(p.id)).length;
return {
all: allProducts.length,
available: availableCount,
assigned: assignedCount,
};
}, [allProducts, products, assignedIds]);
setAssigning(true); // Apply search + filter
const visibleProducts = useMemo(() => {
const q = search.trim().toLowerCase();
return allProducts.filter((p) => {
const isAssigned = assignedIds.has(p.id);
if (filter === "available" && isAssigned) return false;
if (filter === "assigned" && !isAssigned) return false;
if (!q) return true;
return p.name.toLowerCase().includes(q) || p.type.toLowerCase().includes(q);
});
}, [allProducts, search, filter, assignedIds]);
async function assign(productId: string) {
if (!productId || assignedIds.has(productId) || pendingId) return;
const product = allProducts.find((p) => p.id === productId);
if (!product) return;
setPendingId(productId);
setError(null); setError(null);
// First run diagnostic to understand the actual state try {
const diagRes = await fetch( const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/debug_stop_product_assignment`, `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
{ {
method: "POST", method: "POST",
headers: { headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
p_stop_id: stopId, p_stop_id: stopId,
p_product_id: selected, p_product_id: productId,
p_caller_uid: callerUid, p_caller_uid: callerUid,
}), }),
}
);
const data = await res.json();
if (!res.ok || data.success === false) {
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
const errDetail = data?.details ?? data?.hint ?? data?.code ?? "";
setError(`Failed to assign: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
setPendingId(null);
return;
} }
);
const diag = await diagRes.json();
// Now attempt the actual assignment // Optimistic insert: build the entry from the product we already have
const res = await fetch( // locally, keyed by the row id returned from the RPC. Use functional
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`, // setState so concurrent calls compose correctly.
{ setProducts((prev) => {
method: "POST", if (prev.some((p) => p.product_id === productId)) return prev;
headers: { return [
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, ...prev,
"Content-Type": "application/json", {
}, id: data.id,
body: JSON.stringify({ product_id: productId,
p_stop_id: stopId, products: {
p_product_id: selected, name: product.name,
p_caller_uid: callerUid, type: product.type,
}), price: product.price,
} image_url: product.image_url ?? null,
); },
},
];
});
setPendingId(null);
const data = await res.json(); logAuditEvent({
table_name: "product_stops",
if (!res.ok || data.success === false) { record_id: data.id,
const diagInfo = diag && typeof diag === 'object' && 'reason' in diag action: "INSERT",
? `[${(diag as any).reason}] (admin_found=${(diag as any).admin_found}, role=${(diag as any).admin_role}, admin_brand=${(diag as any).admin_brand_id}, stop_brand=${(diag as any).stop_brand_id}, product_brand=${(diag as any).product_brand_id})` old_data: null,
: ''; new_data: { stop_id: stopId, product_id: productId },
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`; brand_id: null,
const errDetail = data?.details ?? data?.hint ?? data?.code ?? ''; });
const fullError = diagInfo } catch {
? `Failed to assign: ${errMsg} ${diagInfo}${errDetail ? ' | ' + errDetail : ''}` setError("Network error while assigning product.");
: `Failed to assign: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`; setPendingId(null);
setError(fullError);
setAssigning(false);
return;
} }
// Refresh product list from get_stop_products RPC
const refreshRes = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_stop_products`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stopId }),
}
);
const refreshData = await refreshRes.json();
setProducts(refreshData.products ?? []);
setSelected("");
setAssigning(false);
logAuditEvent({
table_name: "product_stops",
record_id: data.id,
action: "INSERT",
old_data: null,
new_data: { stop_id: stopId, product_id: selected },
brand_id: null,
});
} }
async function handleRemove(productId: string) { async function remove(productId: string) {
if (removingId) return;
const entry = products.find((p) => p.product_id === productId); const entry = products.find((p) => p.product_id === productId);
if (!entry) return; if (!entry) return;
setRemoving(productId); setRemovingId(productId);
setError(null); setError(null);
const res = await fetch( try {
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`, const res = await fetch(
{ `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
method: "POST", {
headers: { method: "POST",
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, headers: {
"Content-Type": "application/json", apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
}, "Content-Type": "application/json",
body: JSON.stringify({ },
p_stop_id: stopId, body: JSON.stringify({
p_product_id: productId, p_stop_id: stopId,
p_caller_uid: callerUid, p_product_id: productId,
}), p_caller_uid: callerUid,
}),
}
);
const data = await res.json();
if (!res.ok || data.success === false) {
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
const errDetail = data?.details ?? data?.hint ?? data?.code ?? "";
setError(`Failed to remove: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
setRemovingId(null);
return;
} }
);
const data = await res.json(); setProducts((prev) => prev.filter((p) => p.product_id !== productId));
setRemovingId(null);
if (!res.ok || data.success === false) { logAuditEvent({
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`; table_name: "product_stops",
const errDetail = data?.details ?? data?.hint ?? data?.code ?? ''; record_id: entry.id,
setError(`Failed to remove: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`); action: "DELETE",
setRemoving(null); old_data: { stop_id: stopId, product_id: productId },
return; new_data: null,
brand_id: null,
});
} catch {
setError("Network error while removing product.");
setRemovingId(null);
} }
setProducts(products.filter((p) => p.product_id !== productId));
setRemoving(null);
logAuditEvent({
table_name: "product_stops",
record_id: entry.id,
action: "DELETE",
old_data: { stop_id: stopId, product_id: productId },
new_data: null,
brand_id: null,
});
} }
function toggle(productId: string) {
if (assignedIds.has(productId)) {
remove(productId);
} else {
assign(productId);
}
}
function clearAll() {
if (!products.length || removingId || pendingId) return;
// Snapshot ids at click time, then remove each one sequentially so the
// server sees one request at a time.
const idsToRemove = products.map((p) => p.product_id);
(async () => {
for (const id of idsToRemove) {
// Stop if a per-item failure already surfaced an error
if (error) break;
await remove(id);
}
})();
}
// Keyboard: pressing / focuses search
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
e.preventDefault();
const el = document.getElementById("stop-product-search") as HTMLInputElement | null;
el?.focus();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const hasAssigned = products.length > 0;
const hasProducts = allProducts.length > 0;
return ( return (
<div className="space-y-6"> <div className="ha-picker">
{error && ( {error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400"> <div
{error} role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{
background: "rgba(220, 38, 38, 0.06)",
border: "1px solid rgba(220, 38, 38, 0.15)",
}}
>
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
</svg>
<span className="font-mono text-[11px]">{error}</span>
</div> </div>
)} )}
{products.length > 0 ? ( {/* Header: editorial title + search */}
<div className="space-y-2"> <div className="ha-picker-header">
<p className="text-sm font-medium text-zinc-300"> <div className="ha-picker-title">
Assigned Products ({products.length}) <span className="ha-picker-title-num font-display">
{String(products.length).padStart(2, "0")}
</span>
<span className="ha-picker-title-label">
{products.length === 1 ? "Product on this stop" : "Products on this stop"}
</span>
</div>
<div className="ha-picker-search">
<span className="ha-picker-search-icon" aria-hidden="true">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
</svg>
</span>
<input
id="stop-product-search"
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search products…"
className="ha-picker-search-input"
autoComplete="off"
/>
</div>
</div>
{/* Filter chips */}
<div className="ha-picker-filterbar">
<button
type="button"
onClick={() => setFilter("all")}
className={`ha-picker-chip ${filter === "all" ? "ha-picker-chip--active" : ""}`}
>
All
<span className="ha-picker-chip-count">{counts.all}</span>
</button>
<button
type="button"
onClick={() => setFilter("available")}
className={`ha-picker-chip ${filter === "available" ? "ha-picker-chip--active" : ""}`}
>
Available
<span className="ha-picker-chip-count">{counts.available}</span>
</button>
<button
type="button"
onClick={() => setFilter("assigned")}
className={`ha-picker-chip ${filter === "assigned" ? "ha-picker-chip--active" : ""}`}
>
On this stop
<span className="ha-picker-chip-count">{counts.assigned}</span>
</button>
<span className="ha-modal-footer-hint ml-auto">
Press <kbd>/</kbd> to search
</span>
</div>
{/* Product grid */}
{!hasProducts ? (
<div className="ha-picker-empty">
<p className="ha-picker-empty-mark">No products yet</p>
<p className="ha-picker-empty-text">
Create products in the catalog first, then come back here to assign them to this stop.
</p>
</div>
) : visibleProducts.length === 0 ? (
<div className="ha-picker-empty">
<p className="ha-picker-empty-mark">
{filter === "assigned" ? "Nothing assigned" : "No matches"}
</p>
<p className="ha-picker-empty-text">
{search.trim()
? `No products match "${search.trim()}". Try a different term.`
: filter === "assigned"
? "This stop has no products assigned yet. Click a card in the All tab to add one."
: "All products are already assigned to this stop."}
</p> </p>
{products.map((ap) => (
<div
key={ap.id}
className="flex items-center justify-between rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
>
<div>
<p className="font-medium text-zinc-100">
{ap.products?.name ?? "Unknown"}
</p>
<p className="text-xs text-zinc-500">
{ap.products?.type} ·{" "}
${Number(ap.products?.price ?? 0).toFixed(2)}
</p>
</div>
<button
onClick={() => handleRemove(ap.product_id)}
disabled={removing === ap.product_id}
className="shrink-0 rounded-lg px-3 py-1 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
>
{removing === ap.product_id ? "..." : "Remove"}
</button>
</div>
))}
</div> </div>
) : ( ) : (
<p className="text-sm text-zinc-500">No products assigned yet.</p> <div className="ha-picker-grid">
{visibleProducts.map((product) => {
const isAssigned = assignedIds.has(product.id);
const isBusy = pendingId === product.id || removingId === product.id;
return (
<button
key={product.id}
type="button"
onClick={() => !isBusy && toggle(product.id)}
disabled={isBusy}
aria-pressed={isAssigned}
className={`ha-product-card ${isAssigned ? "ha-product-card--assigned" : ""} ${
isBusy ? "ha-product-card--disabled" : ""
}`}
>
{/* Image / Initial */}
<span className="ha-product-card-image">
{product.image_url ? (
<Image
src={product.image_url}
alt={product.name}
fill
sizes="48px"
style={{ objectFit: "cover" }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
initialOf(product.name)
)}
</span>
{/* Body */}
<span className="ha-product-card-body">
<span className="ha-product-card-name">{product.name}</span>
<span className="ha-product-card-meta">
<span className="ha-product-card-type">{TYPE_LABEL[product.type] ?? product.type}</span>
<span className="ha-product-card-price">{formatPrice(product.price)}</span>
</span>
</span>
{/* Status icon */}
{isBusy ? (
<span className="ha-product-card-plus" aria-hidden="true">
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
</span>
) : isAssigned ? (
<>
<span className="ha-product-card-check" aria-hidden="true">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="ha-product-card-remove" aria-hidden="true">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M6 6l12 12M6 18 18 6" strokeLinecap="round" />
</svg>
</span>
</>
) : (
<span className="ha-product-card-plus" aria-hidden="true">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
</span>
)}
</button>
);
})}
</div>
)} )}
{availableProducts.length > 0 && ( {/* Summary footer when assigned items exist */}
<form onSubmit={handleAssign} className="flex gap-3"> {hasAssigned && (
<select <div className="ha-picker-summary">
value={selected} <span className="ha-picker-summary-left">
onChange={(e) => setSelected(e.target.value)} <svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900" <path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
> </svg>
<option value="">Select a product...</option> <span>
{availableProducts.map((p) => ( <span className="ha-picker-summary-count">{products.length}</span>{" "}
<option key={p.id} value={p.id}> {products.length === 1 ? "product is" : "products are"} live at this stop
{p.name} ({p.type}) </span>
</option> </span>
))}
</select>
<button <button
type="submit" type="button"
disabled={!selected || assigning} onClick={clearAll}
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50" disabled={!!removingId}
className="ha-picker-summary-clear"
> >
{assigning ? "..." : "Assign"} Remove all
</button> </button>
</form> </div>
)} )}
</div> </div>
); );
+329 -158
View File
@@ -1,11 +1,21 @@
"use client"; "use client";
/* eslint-disable react-hooks/set-state-in-effect */ /* eslint-disable react-hooks/set-state-in-effect */
import React, { useState, useTransition, useEffect } from "react"; import React, { useState, useTransition, useEffect, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { publishStop } from "@/actions/stops"; import { publishStop } from "@/actions/stops";
import { AdminSearchInput, AdminFilterTabs, AdminButton, AdminIconButton, useToast, Skeleton } from "@/components/admin/design-system"; import {
AdminSearchInput,
AdminFilterTabs,
AdminButton,
AdminIconButton,
useToast,
Skeleton,
} from "@/components/admin/design-system";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal";
import StopDetailModal from "@/components/admin/StopDetailModal";
type Stop = { type Stop = {
id: string; id: string;
@@ -18,14 +28,27 @@ type Stop = {
deleted_at?: string | null; deleted_at?: string | null;
brand_id: string; brand_id: string;
status?: string; status?: string;
brands: { name: string } | { name: string }[]; address?: string | null;
brands: { name: string } | { name: string }[] | null;
}; };
type Props = { type Props = {
stops: Stop[]; stops: Stop[];
/**
* Hide the internal search/filter bar at the top of the table. Use when
* the parent component already renders shared filters (e.g. StopsViewClient).
*/
hideInternalFilterBar?: boolean;
}; };
export default function StopTableClient({ stops }: Props) { const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = {
all: "all",
active: "active",
inactive: "inactive",
draft: "draft",
};
export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
const router = useRouter(); const router = useRouter();
const { success: showSuccess, error: showError } = useToast(); const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
@@ -36,57 +59,66 @@ export default function StopTableClient({ stops }: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set()); const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
const [bulkPublishing, setBulkPublishing] = useState(false); const [bulkPublishing, setBulkPublishing] = useState(false);
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [openStopId, setOpenStopId] = useState<string | null>(null);
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
// Simulate loading when filters change
useEffect(() => { useEffect(() => {
setIsLoading(true); setIsLoading(true);
const timer = setTimeout(() => setIsLoading(false), 300); const timer = setTimeout(() => setIsLoading(false), 220);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [page, statusFilter, search]); }, [page, statusFilter, search]);
const filtered = stops.filter((s) => { const filtered = useMemo(() => {
const matchesSearch = const q = search.trim().toLowerCase();
!search || return stops.filter((s) => {
s.city.toLowerCase().includes(search.toLowerCase()) || const matchesSearch =
s.location.toLowerCase().includes(search.toLowerCase()); !q ||
const matchesStatus = s.city.toLowerCase().includes(q) ||
statusFilter === "all" || s.location.toLowerCase().includes(q);
(statusFilter === "active" && s.active && s.status !== "draft") || const matchesStatus =
(statusFilter === "inactive" && !s.active && s.status !== "draft") || statusFilter === "all" ||
(statusFilter === "draft" && s.status === "draft"); (statusFilter === "active" && s.active && s.status !== "draft") ||
return matchesSearch && matchesStatus; (statusFilter === "inactive" && !s.active && s.status !== "draft") ||
}); (statusFilter === "draft" && s.status === "draft");
return matchesSearch && matchesStatus;
});
}, [stops, search, statusFilter]);
const statusCounts = { const statusCounts = useMemo(
all: stops.length, () => ({
active: stops.filter(s => s.active && s.status !== "draft").length, all: stops.length,
inactive: stops.filter(s => !s.active && s.status !== "draft").length, active: stops.filter((s) => s.active && s.status !== "draft").length,
draft: stops.filter(s => s.status === "draft").length, inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
}; draft: stops.filter((s) => s.status === "draft").length,
}),
[stops]
);
const tabs = [ const tabs = useMemo(
{ value: "all", label: "All", count: statusCounts.all }, () => [
{ value: "active", label: "Active", count: statusCounts.active }, { value: "all", label: "All", count: statusCounts.all },
{ value: "inactive", label: "Inactive", count: statusCounts.inactive }, { value: "active", label: "Active", count: statusCounts.active },
{ value: "draft", label: "Draft", count: statusCounts.draft }, { value: "inactive", label: "Inactive", count: statusCounts.inactive },
]; { value: "draft", label: "Draft", count: statusCounts.draft },
],
[statusCounts]
);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE); const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const draftStops = stops.filter(s => s.status === "draft" && s.active);
// Bulk selection
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (selectedStops.size === paginatedStops.length) { if (selectedStops.size === paginatedStops.length) {
setSelectedStops(new Set()); setSelectedStops(new Set());
} else { } else {
setSelectedStops(new Set(paginatedStops.map(s => s.id))); setSelectedStops(new Set(paginatedStops.map((s) => s.id)));
} }
}; };
const toggleStopSelection = (stopId: string) => { const toggleStopSelection = (stopId: string) => {
setSelectedStops(prev => { setSelectedStops((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(stopId)) { if (next.has(stopId)) {
next.delete(stopId); next.delete(stopId);
@@ -99,13 +131,11 @@ export default function StopTableClient({ stops }: Props) {
async function handleBulkPublish() { async function handleBulkPublish() {
if (selectedStops.size === 0) return; if (selectedStops.size === 0) return;
setBulkPublishing(true); setBulkPublishing(true);
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
for (const stopId of selectedStops) { for (const stopId of selectedStops) {
const stop = stops.find(s => s.id === stopId); const stop = stops.find((s) => s.id === stopId);
if (stop && stop.status === "draft") { if (stop && stop.status === "draft") {
const result = await publishStop(stopId, stop.brand_id); const result = await publishStop(stopId, stop.brand_id);
if (result.success) { if (result.success) {
@@ -115,83 +145,125 @@ export default function StopTableClient({ stops }: Props) {
} }
} }
} }
setBulkPublishing(false); setBulkPublishing(false);
setSelectedStops(new Set()); setSelectedStops(new Set());
if (failCount === 0) { if (failCount === 0) {
showSuccess(`${successCount} stop${successCount !== 1 ? 's' : ''} published`); showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
} else { } else {
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`); showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
} }
startTransition(() => router.refresh()); startTransition(() => router.refresh());
} }
function handleDeleted() { function handleDeleted() {
setDeleteError(null); setDeleteError(null);
startTransition(() => { startTransition(() => router.refresh());
router.refresh(); }
});
function refresh() {
startTransition(() => router.refresh());
} }
return ( return (
<> <>
{/* Filter bar */} {/* Filter bar */}
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center"> {!hideInternalFilterBar && (
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)]">
<AdminSearchInput <AdminSearchInput
placeholder="Search stops..." placeholder="Search by city or venue…"
value={search} value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }} onChange={(e) => {
onClear={() => { setSearch(""); setPage(0); }} setSearch(e.target.value);
showClear={true} setPage(0);
className="flex-1 min-w-48 max-w-64" }}
onClear={() => {
setSearch("");
setPage(0);
}}
showClear
className="flex-1 min-w-[180px] max-w-[280px]"
/> />
<AdminFilterTabs <AdminFilterTabs
activeTab={statusFilter} activeTab={statusFilter}
onTabChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(0); }} onTabChange={(value) => {
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
setPage(0);
}}
tabs={tabs} tabs={tabs}
size="sm" size="sm"
showCounts={true} showCounts
/> />
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">{filtered.length} stops</span> <span className="text-xs text-[var(--admin-text-muted)] ml-auto tabular-nums">
{filtered.length} stop{filtered.length !== 1 ? "s" : ""}
</span>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<AdminIconButton <AdminIconButton
variant="secondary" variant="secondary"
size="sm" size="sm"
label="Previous page" label="Previous page"
onClick={() => setPage(p => Math.max(0, p - 1))} onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0 || isLoading} disabled={page === 0 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]" className="!rounded-lg border border-[var(--admin-border)]"
> >
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg> <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</AdminIconButton> </AdminIconButton>
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span> <span className="text-xs text-[var(--admin-text-muted)] px-1 tabular-nums">
{page + 1}/{totalPages}
</span>
<AdminIconButton <AdminIconButton
variant="secondary" variant="secondary"
size="sm" size="sm"
label="Next page" label="Next page"
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1 || isLoading} disabled={page >= totalPages - 1 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]" className="!rounded-lg border border-[var(--admin-border)]"
> >
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg> <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</AdminIconButton> </AdminIconButton>
</div> </div>
)} )}
<AdminButton
variant="secondary"
size="sm"
onClick={() => setShowImport(true)}
icon={
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
}
>
Upload Schedule
</AdminButton>
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAdd(true)}
icon={
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
>
Add Stop
</AdminButton>
</div> </div>
)}
{/* Bulk actions bar */} {/* Bulk actions bar */}
{selectedStops.size > 0 && ( {selectedStops.size > 0 && (
<div className="mx-5 my-3 flex items-center justify-between rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-4 py-3"> <div className="mx-4 my-3 flex items-center justify-between rounded-xl border border-emerald-200 bg-emerald-50/60 px-4 py-2.5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-semibold text-[var(--admin-accent-text)]"> <span className="text-sm font-semibold text-emerald-800 tabular-nums">
{selectedStops.size} stop{selectedStops.size !== 1 ? 's' : ''} selected {selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
</span> </span>
<button <button
onClick={() => setSelectedStops(new Set())} onClick={() => setSelectedStops(new Set())}
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]" className="text-xs text-stone-500 hover:text-stone-700"
> >
Clear Clear
</button> </button>
@@ -209,7 +281,7 @@ export default function StopTableClient({ stops }: Props) {
{/* Delete error */} {/* Delete error */}
{deleteError && ( {deleteError && (
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]"> <div className="mx-4 my-3 rounded-lg border border-red-500/30 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{deleteError}{" "} {deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline"> <button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss Dismiss
@@ -219,45 +291,59 @@ export default function StopTableClient({ stops }: Props) {
{/* Table */} {/* Table */}
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"> <thead className="bg-[var(--admin-bg-subtle)]/40 text-[var(--admin-text-muted)] text-xs uppercase tracking-wide">
<tr> <tr>
<th className="w-10 px-5 py-4"> <th className="w-10 px-4 py-3">
<input <input
type="checkbox" type="checkbox"
checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0} checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0}
onChange={toggleSelectAll} onChange={toggleSelectAll}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer" className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
aria-label="Select all"
/> />
</th> </th>
<th className="px-5 py-4 font-semibold">City</th> <th className="px-4 py-3 font-semibold">When</th>
<th className="px-5 py-4 font-semibold">Location</th> <th className="px-4 py-3 font-semibold">Where</th>
<th className="px-5 py-4 font-semibold">Date</th> <th className="px-4 py-3 font-semibold">Venue</th>
<th className="px-5 py-4 font-semibold">Time</th> <th className="px-4 py-3 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold">Brand</th> <th className="w-12 px-4 py-3" />
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--admin-border)]"> <tbody className="divide-y divide-[var(--admin-border)]">
{isLoading ? ( {isLoading ? (
Array.from({ length: 8 }).map((_, i) => ( Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="hover:bg-[var(--admin-bg-subtle)] transition-colors"> <tr key={i}>
<td className="px-5 py-4"><Skeleton variant="rect" className="h-5 w-5" /></td> <td className="px-4 py-3.5"><Skeleton variant="rect" className="h-4 w-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-24 h-4" /></td> <td className="px-4 py-3.5"><Skeleton variant="text" className="w-24 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td> <td className="px-4 py-3.5"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td> <td className="px-4 py-3.5"><Skeleton variant="text" className="w-40 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td> <td className="px-4 py-3.5"><Skeleton variant="rect" className="w-16 h-5 rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-20 h-4" /></td> <td className="px-4 py-3.5"><Skeleton variant="rect" className="w-6 h-6" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
</tr> </tr>
)) ))
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]"> <td colSpan={6} className="px-4 py-14 text-center">
{search || statusFilter !== "all" <div className="flex flex-col items-center gap-3 text-[var(--admin-text-muted)]">
? "No stops match your search." <div className="h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
: "No stops found. Create one to get started."} <svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="text-sm font-semibold text-stone-700">
{search || statusFilter !== "all"
? "No stops match your filters"
: "No stops yet"}
</p>
<p className="mt-0.5 text-xs text-stone-500">
{search || statusFilter !== "all"
? "Try a different search or clear the filters above."
: "Use the buttons above to upload a schedule or add your first stop."}
</p>
</div>
</div>
</td> </td>
</tr> </tr>
) : ( ) : (
@@ -268,52 +354,101 @@ export default function StopTableClient({ stops }: Props) {
onDeleted={handleDeleted} onDeleted={handleDeleted}
onDeleteError={setDeleteError} onDeleteError={setDeleteError}
isSelected={selectedStops.has(stop.id)} isSelected={selectedStops.has(stop.id)}
onToggleSelect={() => toggleStopSelection(stop.id)} onToggleSelect={(e) => {
e.stopPropagation();
toggleStopSelection(stop.id);
}}
onOpen={() => setOpenStopId(stop.id)}
/> />
)) ))
)} )}
</tbody> </tbody>
</table> </table>
{showImport && (
<ScheduleImportModal
brandId={stops[0]?.brand_id ?? ""}
onClose={() => setShowImport(false)}
onComplete={refresh}
/>
)}
<AddStopModal
isOpen={showAdd}
onClose={() => setShowAdd(false)}
brandId={stops[0]?.brand_id ?? ""}
onSuccess={refresh}
/>
{openStopId && (
<StopDetailModal
stopId={openStopId}
onClose={() => setOpenStopId(null)}
/>
)}
</> </>
); );
} }
function formatDate(iso: string): { date: string; weekday: string } {
// Stop date is stored as YYYY-MM-DD; parse without TZ shift
const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10));
if (!y || !m || !d) return { date: iso, weekday: "" };
const dt = new Date(y, m - 1, d);
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
const month = dt.toLocaleDateString("en-US", { month: "short" });
return { date: `${month} ${d}`, weekday };
}
function formatTime(t: string): string {
// Stored as HH:MM (24h). Display as 10:00 AM.
if (!t) return "";
const [hStr, mStr] = t.split(":");
const h = parseInt(hStr, 10);
if (Number.isNaN(h)) return t;
const period = h >= 12 ? "PM" : "AM";
const hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${hh}:${mStr} ${period}`;
}
function StopRowBase({ function StopRowBase({
stop, stop,
onDeleted, onDeleted,
onDeleteError, onDeleteError,
isSelected, isSelected,
onToggleSelect, onToggleSelect,
onOpen,
}: { }: {
stop: Stop; stop: Stop;
onDeleted: () => void; onDeleted: () => void;
onDeleteError: (msg: string) => void; onDeleteError: (msg: string) => void;
isSelected: boolean; isSelected: boolean;
onToggleSelect: () => void; onToggleSelect: (e: React.MouseEvent) => void;
onOpen: () => void;
}) { }) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast(); const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [openMenu, setOpenMenu] = useState<string | null>(null); const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null); const [publishingId, setPublishingId] = useState<string | null>(null);
async function handlePublish(stopId: string) { const { date, weekday } = formatDate(stop.date);
setPublishingId(stopId); const time = formatTime(stop.time);
setOpenMenu(null);
const result = await publishStop(stopId, stop.brand_id); async function handlePublish() {
setPublishingId(stop.id);
const result = await publishStop(stop.id, stop.brand_id);
setPublishingId(null); setPublishingId(null);
if (result.success) { if (result.success) {
showSuccess("Stop published", "The stop is now visible on the storefront"); showSuccess("Stop published", "The stop is now visible on the storefront");
startTransition(() => router.refresh()); onDeleted();
} else { } else {
showError("Failed to publish", result.error ?? "Please try again"); showError("Failed to publish", result.error ?? "Please try again");
} }
} }
async function handleDelete(stopId: string) { async function handleDelete() {
setDeletingId(stopId); setDeletingId(stop.id);
const res = await fetch( const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`, `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
{ {
@@ -322,7 +457,7 @@ function StopRowBase({
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: stop.brand_id }), body: JSON.stringify({ p_stop_id: stop.id, p_brand_id: stop.brand_id }),
} }
); );
const data = await res.json(); const data = await res.json();
@@ -338,69 +473,88 @@ function StopRowBase({
} }
return ( return (
<tr className={`hover:bg-[var(--admin-bg-subtle)] transition-colors relative ${isSelected ? 'bg-[var(--admin-accent-light)]/30' : ''}`}> <tr
<td className="px-5 py-4"> onClick={onOpen}
className={`group cursor-pointer transition-colors ${isSelected ? "bg-emerald-50/60" : "hover:bg-[var(--admin-bg-subtle)]/60"}`}
>
<td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
onChange={onToggleSelect} onChange={() => {}}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer" onClick={onToggleSelect}
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
aria-label={`Select stop in ${stop.city}`}
/> />
</td> </td>
<td className="px-5 py-4">
<Link <td className="px-4 py-3.5">
href={`/admin/stops/${stop.id}`} <div className="flex flex-col leading-tight">
className="font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] transition-colors" <span className="font-semibold text-[var(--admin-text-primary)] tabular-nums">{date}</span>
> <span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">
{stop.city}, {stop.state} {weekday} {time && `· ${time}`}
</Link> </span>
</div>
</td> </td>
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">{stop.location}</td> <td className="px-4 py-3.5">
<div className="flex flex-col leading-tight">
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.date}</td> <span className="font-semibold text-[var(--admin-text-primary)]">{stop.city}</span>
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">{stop.state}</span>
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.time}</td> </div>
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">
{Array.isArray(stop.brands)
? stop.brands[0]?.name
: stop.brands?.name}
</td> </td>
<td className="px-5 py-4"> <td className="px-4 py-3.5">
<div className="flex flex-col leading-tight">
<span className="text-[var(--admin-text-secondary)]">{stop.location}</span>
{stop.address && (
<span className="text-[11px] text-[var(--admin-text-muted)] truncate max-w-[260px]">
{stop.address}
</span>
)}
</div>
</td>
<td className="px-4 py-3.5">
<span <span
className={`rounded-full px-3 py-1 text-xs font-medium ${ className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ${
stop.status === "draft" stop.status === "draft"
? "bg-amber-100 text-amber-700 border border-amber-200" ? "bg-amber-50 text-amber-700 border border-amber-200"
: stop.active : stop.active
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]" ? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]" : "bg-stone-100 text-stone-500 border border-stone-200"
}`} }`}
> >
<span
className={`h-1.5 w-1.5 rounded-full ${
stop.status === "draft"
? "bg-amber-500"
: stop.active
? "bg-emerald-500"
: "bg-stone-400"
}`}
/>
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"} {stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
</span> </span>
</td> </td>
<td className="px-5 py-4 text-right"> <td className="px-4 py-3.5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="relative inline-flex items-center justify-end gap-2"> <div className="relative inline-flex items-center justify-end gap-1">
<Link
href={`/admin/stops/${stop.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
Edit
</Link>
<AdminIconButton <AdminIconButton
variant="ghost" variant="ghost"
size="sm" size="sm"
label="More options" label="More options"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
setOpenMenu(openMenu === stop.id ? null : stop.id); setOpenMenu(openMenu === stop.id ? null : stop.id);
}} }}
className="opacity-60 group-hover:opacity-100"
> >
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/> <circle cx="10" cy="4" r="1.5" />
<circle cx="10" cy="10" r="1.5" />
<circle cx="10" cy="16" r="1.5" />
</svg> </svg>
</AdminIconButton> </AdminIconButton>
@@ -408,36 +562,42 @@ function StopRowBase({
<> <>
<div <div
className="fixed inset-0 z-10" className="fixed inset-0 z-10"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }} onClick={(e) => {
e.stopPropagation();
setOpenMenu(null);
setConfirmDelete(null);
}}
/> />
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden"> <div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
{stop.status === "draft" && ( {stop.status === "draft" && (
<AdminButton <button
variant="ghost" onClick={(e) => {
size="sm" e.stopPropagation();
fullWidth handlePublish();
onClick={() => handlePublish(stop.id)} }}
disabled={publishingId === stop.id} disabled={publishingId === stop.id}
className="!justify-start !text-[var(--admin-accent)] hover:!bg-[var(--admin-accent-light)]" className="w-full text-left px-4 py-2.5 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors disabled:opacity-50"
> >
{publishingId === stop.id ? "Publishing..." : "Publish"} {publishingId === stop.id ? "Publishing" : "Publish"}
</AdminButton> </button>
)} )}
<a <Link
href={`/admin/stops/new?duplicate=${stop.id}`} href={`/admin/stops/new?duplicate=${stop.id}`}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors" onClick={(e) => e.stopPropagation()}
className="flex items-center px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
> >
Duplicate Duplicate
</a> </Link>
<AdminButton <button
variant="ghost" onClick={(e) => {
size="sm" e.stopPropagation();
fullWidth setOpenMenu(null);
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }} setConfirmDelete(stop.id);
className="!justify-start !text-[var(--admin-danger)] hover:!bg-[var(--admin-danger)]/10" }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
> >
Delete Delete
</AdminButton> </button>
</div> </div>
</> </>
)} )}
@@ -446,7 +606,11 @@ function StopRowBase({
<> <>
<div <div
className="fixed inset-0 z-30" className="fixed inset-0 z-30"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }} onClick={(e) => {
e.stopPropagation();
setConfirmDelete(null);
setOpenMenu(null);
}}
/> />
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4"> <div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
<p className="text-sm font-semibold text-[var(--admin-text-primary)]"> <p className="text-sm font-semibold text-[var(--admin-text-primary)]">
@@ -455,22 +619,29 @@ function StopRowBase({
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]"> <p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">
This will remove the stop. If it has active orders, you must resolve those first. This will remove the stop. If it has active orders, you must resolve those first.
</p> </p>
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2 justify-end">
<AdminButton <AdminButton
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }} onClick={(e) => {
e.stopPropagation();
setConfirmDelete(null);
setOpenMenu(null);
}}
> >
Cancel Cancel
</AdminButton> </AdminButton>
<AdminButton <AdminButton
variant="danger" variant="danger"
size="sm" size="sm"
onClick={() => handleDelete(stop.id)} onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
disabled={deletingId === stop.id} disabled={deletingId === stop.id}
isLoading={deletingId === stop.id} isLoading={deletingId === stop.id}
> >
{deletingId === stop.id ? "..." : "Delete"} {deletingId === stop.id ? "Deleting…" : "Delete"}
</AdminButton> </AdminButton>
</div> </div>
</div> </div>
@@ -0,0 +1,754 @@
"use client";
import { useState, useMemo, useEffect, useRef, useCallback, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop } from "@/actions/stops";
import { useToast } from "@/components/admin/design-system";
import type { StopForView } from "./StopsViewClient";
type Props = {
stops: StopForView[];
};
/* ================================================================== */
/* Date helpers — work with YYYY-MM-DD strings to avoid TZ drift */
/* ================================================================== */
function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function parseYmd(s: string): Date | null {
// Treat YYYY-MM-DD as a local date (no UTC shift)
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return null;
const [, y, mo, d] = m;
return new Date(Number(y), Number(mo) - 1, Number(d));
}
function todayYmd(): string {
return ymd(new Date());
}
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const MONTH_NAMES_ITALIC = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function buildMonthGrid(month: Date): Date[] {
// Start at the Sunday on or before the 1st of the month
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const startDayOfWeek = first.getDay(); // 0 = Sun
const start = new Date(first);
start.setDate(1 - startDayOfWeek);
// 6 weeks = 42 cells
const cells: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
cells.push(d);
}
return cells;
}
function statusOf(stop: StopForView): "active" | "draft" | "inactive" {
if (stop.status === "draft") return "draft";
if (stop.active) return "active";
return "inactive";
}
function statusLabel(s: "active" | "draft" | "inactive"): string {
return s === "draft" ? "Draft" : s === "active" ? "Active" : "Inactive";
}
function brandName(s: StopForView): string {
return Array.isArray(s.brands) ? s.brands[0]?.name ?? "—" : s.brands?.name ?? "—";
}
function formatTime12(t: string | null | undefined): string {
if (!t) return "—";
// t is "HH:MM" or "HH:MM:SS"
const [hStr = "0", mStr = "00"] = t.split(":");
const h = Number(hStr);
const ampm = h >= 12 ? "pm" : "am";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${mStr} ${ampm}`;
}
function formatTimeCompact(t: string | null | undefined): string {
if (!t) return "—";
const [hStr = "0", mStr = "00"] = t.split(":");
const h = Number(hStr);
const ampm = h >= 12 ? "p" : "a";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${mStr}${ampm}`;
}
function formatDateLong(d: Date): string {
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
const month = MONTH_NAMES[d.getMonth()];
return `${weekday}, ${month} ${d.getDate()}`;
}
function formatDateLongSpelled(d: Date): string {
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
const month = MONTH_NAMES[d.getMonth()];
return `${weekday}, ${month} ${d.getDate()}, ${d.getFullYear()}`;
}
/* ================================================================== */
/* Component */
/* ================================================================== */
const MAX_EVENTS_PER_CELL = 3;
export default function StopsCalendarClient({ stops }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [viewMonth, setViewMonth] = useState(() => {
const today = new Date();
return new Date(today.getFullYear(), today.getMonth(), 1);
});
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [activePopover, setActivePopover] = useState<{
stopId: string;
cellRect: DOMRect;
} | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
// Bucket stops by YYYY-MM-DD
const stopsByDate = useMemo(() => {
const map = new Map<string, StopForView[]>();
for (const s of stops) {
if (!s.date) continue;
const list = map.get(s.date) ?? [];
list.push(s);
map.set(s.date, list);
}
// Sort each day by time
for (const list of map.values()) {
list.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
}
return map;
}, [stops]);
const todayStr = useMemo(() => todayYmd(), []);
const monthCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
// Earliest/latest stop dates — used to enable/disable nav
const dateRange = useMemo(() => {
if (stops.length === 0) return null;
const dates = stops.map((s) => s.date).filter(Boolean).sort();
return { min: dates[0], max: dates[dates.length - 1] };
}, [stops]);
const isFirstMonth = useMemo(() => {
if (!dateRange?.min) return false;
const minDate = parseYmd(dateRange.min);
if (!minDate) return false;
return viewMonth.getFullYear() === minDate.getFullYear() && viewMonth.getMonth() === minDate.getMonth();
}, [dateRange, viewMonth]);
const isLastMonth = useMemo(() => {
if (!dateRange?.max) return false;
const maxDate = parseYmd(dateRange.max);
if (!maxDate) return false;
return viewMonth.getFullYear() === maxDate.getFullYear() && viewMonth.getMonth() === maxDate.getMonth();
}, [dateRange, viewMonth]);
function goPrevMonth() {
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1));
}
function goNextMonth() {
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1));
}
function goToday() {
const today = new Date();
setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1));
setSelectedDate(todayYmd());
}
// Close popover on outside click / Escape
useEffect(() => {
if (!activePopover) return;
function onDown(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target.closest("[data-event-popover]") || target.closest("[data-event-chip]")) return;
setActivePopover(null);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setActivePopover(null);
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [activePopover]);
// Close drawer on Escape
useEffect(() => {
if (!selectedDate) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setSelectedDate(null);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [selectedDate]);
// Lock body scroll when drawer is open
useEffect(() => {
if (selectedDate) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}
}, [selectedDate]);
const openStop = useCallback((stop: StopForView, anchorRect: DOMRect) => {
setActivePopover({ stopId: stop.id, cellRect: anchorRect });
}, []);
const handlePublish = useCallback(
async (stop: StopForView) => {
setActivePopover(null);
setPublishingId(stop.id);
const result = await publishStop(stop.id, stop.brand_id);
setPublishingId(null);
if (result.success) {
showSuccess("Stop published", `${stop.city}, ${stop.state} is now visible to customers`);
startTransition(() => router.refresh());
} else {
showError("Failed to publish", result.error ?? "Please try again");
}
},
[router, showSuccess, showError]
);
const activeStop = useMemo(
() => (activePopover ? stops.find((s) => s.id === activePopover.stopId) ?? null : null),
[activePopover, stops]
);
const selectedDayStops = useMemo(() => {
if (!selectedDate) return [];
return stopsByDate.get(selectedDate) ?? [];
}, [selectedDate, stopsByDate]);
const selectedDayDate = useMemo(() => (selectedDate ? parseYmd(selectedDate) : null), [selectedDate]);
// Compute popover position with viewport edge detection
const popoverStyle = useMemo<React.CSSProperties | null>(() => {
if (!activePopover) return null;
const POPOVER_W = 288; // 18rem
const MARGIN = 8;
const rect = activePopover.cellRect;
const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
// Default: place below the chip, left-aligned
let left = rect.left + rect.width / 2 - POPOVER_W / 2;
const top = rect.bottom + MARGIN;
if (left + POPOVER_W > vw - 8) left = vw - POPOVER_W - 8;
if (left < 8) left = 8;
return { left, top };
}, [activePopover]);
const popoverArrowStyle = useMemo<React.CSSProperties | null>(() => {
if (!activePopover || !popoverStyle) return null;
const rect = activePopover.cellRect;
const arrowLeft = rect.left + rect.width / 2 - (popoverStyle.left as number) - 5;
return { left: Math.max(8, Math.min(arrowLeft, 270)) };
}, [activePopover, popoverStyle]);
// === Render ===
return (
<>
<div className="ha-calendar">
{/* Header */}
<div className="ha-calendar-header">
<div className="ha-calendar-title-block">
<span className="ha-calendar-eyebrow">
Tour Almanac · {stops.length} {stops.length === 1 ? "stop" : "stops"} on file
</span>
<h2 className="ha-calendar-title">
<span className="ha-calendar-title-italic">{MONTH_NAMES_ITALIC[viewMonth.getMonth()]}</span>
<span className="ha-calendar-title-year">{viewMonth.getFullYear()}</span>
</h2>
</div>
<div className="ha-calendar-nav">
<button
type="button"
className="ha-calendar-nav-btn"
onClick={goPrevMonth}
disabled={isFirstMonth}
aria-label="Previous month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button type="button" className="ha-calendar-today" onClick={goToday}>
<span className="ha-calendar-today-dot" style={{ background: "currentColor" }} />
Today
</button>
<button
type="button"
className="ha-calendar-nav-btn"
onClick={goNextMonth}
disabled={isLastMonth}
aria-label="Next month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{/* DOW header */}
<div className="ha-calendar-dow" role="row">
{DOW_LABELS.map((label) => (
<div key={label} className="ha-calendar-dow-cell" role="columnheader">
{label}
</div>
))}
</div>
{/* Grid */}
<div className="ha-calendar-grid" role="grid">
{monthCells.map((d) => {
const key = ymd(d);
const dayStops = stopsByDate.get(key) ?? [];
const isOut = d.getMonth() !== viewMonth.getMonth();
const isToday = key === todayStr;
const hasStops = dayStops.length > 0;
const dow = d.getDay();
const isWeekend = dow === 0 || dow === 6;
const isSelected = selectedDate === key;
return (
<div
key={key}
role="gridcell"
tabIndex={hasStops ? 0 : -1}
onClick={() => hasStops && setSelectedDate(key)}
onKeyDown={(e) => {
if (hasStops && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setSelectedDate(key);
}
}}
className={[
"ha-calendar-cell",
isOut ? "ha-calendar-cell--out" : "",
isWeekend ? "ha-calendar-cell--weekend" : "",
hasStops ? "ha-calendar-cell--has-stops" : "",
isToday ? "ha-calendar-cell--today" : "",
isSelected ? "ha-calendar-cell--selected" : "",
]
.filter(Boolean)
.join(" ")}
aria-label={hasStops ? `${formatDateLong(d)}${dayStops.length} stop${dayStops.length !== 1 ? "s" : ""}` : formatDateLong(d)}
>
<div className="ha-calendar-daynum">
<span>{d.getDate()}</span>
{isToday && <span className="ha-calendar-today-dot" />}
</div>
<div className="ha-calendar-events">
{dayStops.slice(0, MAX_EVENTS_PER_CELL).map((stop) => (
<EventChip
key={stop.id}
stop={stop}
isPublishing={publishingId === stop.id}
onOpen={(rect) => openStop(stop, rect)}
isActive={activePopover?.stopId === stop.id}
/>
))}
{dayStops.length > MAX_EVENTS_PER_CELL && (
<button
type="button"
className="ha-calendar-event-more"
onClick={(e) => {
e.stopPropagation();
setSelectedDate(key);
}}
>
+{dayStops.length - MAX_EVENTS_PER_CELL} more
</button>
)}
</div>
</div>
);
})}
</div>
{/* Legend */}
<div className="ha-calendar-legend">
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-accent)" }} />
Active
</span>
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "#f59e0b" }} />
Draft
</span>
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-text-muted)" }} />
Inactive
</span>
<span style={{ marginLeft: "auto" }} className="hidden sm:inline">
Tip click any day with stops to see the full route · click an event for details
</span>
</div>
</div>
{/* Event popover */}
{activeStop && activePopover && popoverStyle && (
<EventPopover
stop={activeStop}
isPublishing={publishingId === activeStop.id}
onPublish={() => handlePublish(activeStop)}
onOpenRoute={() => {
setActivePopover(null);
setSelectedDate(activeStop.date);
}}
style={popoverStyle}
arrowStyle={popoverArrowStyle}
/>
)}
{/* Day-route drawer */}
{selectedDate && selectedDayDate && (
<DayRouteDrawer
date={selectedDayDate}
dateStr={selectedDate}
stops={selectedDayStops}
onClose={() => setSelectedDate(null)}
onPublish={handlePublish}
/>
)}
</>
);
}
/* ================================================================== */
/* EventChip — single stop on a day cell */
/* ================================================================== */
function EventChip({
stop,
onOpen,
isActive,
isPublishing,
}: {
stop: StopForView;
onOpen: (rect: DOMRect) => void;
isActive: boolean;
isPublishing: boolean;
}) {
const ref = useRef<HTMLButtonElement>(null);
const status = statusOf(stop);
return (
<button
ref={ref}
type="button"
data-event-chip
onClick={(e) => {
e.stopPropagation();
if (!isPublishing && ref.current) onOpen(ref.current.getBoundingClientRect());
}}
className={`ha-calendar-event ha-calendar-event--${status} ${isActive ? "!bg-[var(--admin-accent)]/20" : ""}`}
title={`${stop.city}, ${stop.state}${stop.location}`}
>
<span className="ha-calendar-event-time">{formatTimeCompact(stop.time)}</span>
<span className="ha-calendar-event-text">{stop.city}, {stop.state}</span>
</button>
);
}
/* ================================================================== */
/* EventPopover — floating detail card */
/* ================================================================== */
function EventPopover({
stop,
isPublishing,
onPublish,
onOpenRoute,
style,
arrowStyle,
}: {
stop: StopForView;
isPublishing: boolean;
onPublish: () => void;
onOpenRoute: () => void;
style: React.CSSProperties;
arrowStyle: React.CSSProperties | null;
}) {
const status = statusOf(stop);
return (
<div
data-event-popover
className="ha-event-popover"
style={style}
onClick={(e) => e.stopPropagation()}
>
{arrowStyle && <div className="ha-event-popover-arrow" style={{ ...arrowStyle, top: -5 }} />}
<div className="ha-event-popover-eyebrow">
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{
background:
status === "active" ? "var(--admin-accent)" : status === "draft" ? "#f59e0b" : "var(--admin-text-muted)",
}}
/>
{statusLabel(status)} · {brandName(stop)}
</div>
<h3 className="ha-event-popover-title">
{stop.city}, {stop.state}
</h3>
<p className="ha-event-popover-location">{stop.location}</p>
<div className="ha-event-popover-grid">
<div className="ha-event-popover-field">
<span className="ha-event-popover-field-label">Pickup</span>
<span className="ha-event-popover-field-value">{formatTime12(stop.time)}</span>
</div>
<div className="ha-event-popover-field">
<span className="ha-event-popover-field-label">Cutoff</span>
<span className="ha-event-popover-field-value">{stop.cutoff_time ? formatTime12(stop.cutoff_time) : "—"}</span>
</div>
{stop.address && (
<div className="ha-event-popover-field" style={{ gridColumn: "1 / -1" }}>
<span className="ha-event-popover-field-label">Address</span>
<span className="ha-event-popover-field-value" style={{ fontFamily: "var(--font-geist), sans-serif", fontSize: "0.75rem" }}>
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
</span>
</div>
)}
</div>
<div className="ha-event-popover-actions">
{status === "draft" && (
<button
type="button"
onClick={onPublish}
disabled={isPublishing}
className="ha-event-popover-btn ha-event-popover-btn--primary"
>
{isPublishing ? "Publishing…" : "Publish"}
</button>
)}
<button
type="button"
onClick={onOpenRoute}
className="ha-event-popover-btn"
>
View route
</button>
<Link
href={`/admin/stops/${stop.id}`}
className="ha-event-popover-btn ha-event-popover-btn--primary"
>
Edit
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
);
}
/* ================================================================== */
/* DayRouteDrawer — full day's route on the side */
/* ================================================================== */
function DayRouteDrawer({
date,
dateStr,
stops,
onClose,
onPublish,
}: {
date: Date;
dateStr: string;
stops: StopForView[];
onClose: () => void;
onPublish: (stop: StopForView) => void;
}) {
const sorted = useMemo(() => [...stops].sort((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
const counts = useMemo(() => {
const active = sorted.filter((s) => s.status !== "draft" && s.active).length;
const draft = sorted.filter((s) => s.status === "draft").length;
const total = sorted.length;
return { active, draft, total };
}, [sorted]);
const brands = useMemo(() => Array.from(new Set(sorted.map(brandName))), [sorted]);
const routeNumber = useMemo(() => {
// Editorial "Route 03" — count of stops with status badge
return String(sorted.length).padStart(2, "0");
}, [sorted.length]);
const earliest = sorted[0]?.time;
const latest = sorted[sorted.length - 1]?.time;
return (
<>
<div className="ha-drawer-backdrop" onClick={onClose} aria-hidden="true" />
<aside className="ha-drawer" role="dialog" aria-label={`Route for ${formatDateLongSpelled(date)}`}>
<header className="ha-drawer-header">
<div>
<div className="ha-drawer-eyebrow">Route {routeNumber} · {dateStr}</div>
<h2 className="ha-drawer-title">{formatDateLongSpelled(date)}</h2>
<p className="ha-drawer-subtitle">
{sorted.length} {sorted.length === 1 ? "stop" : "stops"} on the route
{earliest && latest && sorted.length > 1 && (
<> · {formatTime12(earliest)} {formatTime12(latest)}</>
)}
</p>
</div>
<button type="button" onClick={onClose} className="ha-drawer-close" aria-label="Close">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<div className="ha-drawer-body">
{sorted.length === 0 ? (
<div className="ha-drawer-empty">
<p className="ha-drawer-empty-mark">No route on this day</p>
<p className="ha-drawer-empty-text">
No stops are scheduled for {formatDateLongSpelled(date)}.
</p>
</div>
) : (
<>
{/* Summary cards */}
<div className="ha-route-summary">
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{counts.total}</span>
<span className="ha-route-summary-label">Stops</span>
</div>
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{counts.active}</span>
<span className="ha-route-summary-label">Live</span>
</div>
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{brands.length}</span>
<span className="ha-route-summary-label">Brands</span>
</div>
</div>
{/* Route spine */}
<div className="ha-route-list">
{sorted.map((stop, idx) => {
const status = statusOf(stop);
return (
<article key={stop.id} className="ha-route-stop">
<div className="ha-route-stop-spine">
<div className={`ha-route-stop-marker ha-route-stop-marker--${status}`}>
{String(idx + 1).padStart(2, "0")}
</div>
<div className="ha-route-stop-line" />
</div>
<div className="ha-route-stop-content">
<div className="ha-route-stop-time">{formatTime12(stop.time)}</div>
<h3 className="ha-route-stop-name">
{stop.city}, {stop.state}
</h3>
<p className="ha-route-stop-location">{stop.location}</p>
<div className="ha-route-stop-meta">
<span className="ha-route-stop-meta-pill">{brandName(stop)}</span>
<span
className="ha-route-stop-meta-pill"
style={{
background:
status === "active"
? "var(--admin-accent-light)"
: status === "draft"
? "rgba(245, 158, 11, 0.1)"
: "var(--admin-bg-subtle)",
color:
status === "active"
? "var(--admin-accent-text)"
: status === "draft"
? "#92400e"
: "var(--admin-text-secondary)",
borderColor:
status === "active"
? "var(--admin-accent)"
: status === "draft"
? "rgba(245, 158, 11, 0.3)"
: "var(--admin-border-light)",
}}
>
{statusLabel(status)}
</span>
{stop.cutoff_time && (
<span className="ha-route-stop-meta-pill">
Cutoff {formatTime12(stop.cutoff_time)}
</span>
)}
{stop.address && (
<span className="ha-route-stop-meta-pill">
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
</span>
)}
</div>
<div className="ha-route-stop-actions">
{status === "draft" && (
<button
type="button"
onClick={() => onPublish(stop)}
className="ha-route-stop-action ha-route-stop-action--primary"
>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Publish
</button>
)}
<Link
href={`/admin/stops/${stop.id}`}
className="ha-route-stop-action ha-route-stop-action--primary"
>
Edit
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
<Link
href={`/admin/stops/new?duplicate=${stop.id}`}
className="ha-route-stop-action"
>
Duplicate
</Link>
</div>
</div>
</article>
);
})}
</div>
</>
)}
</div>
</aside>
</>
);
}

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