7 Commits

Author SHA1 Message Date
tyler 367a5625f3 Add self-hosted Postgres docker-compose
Deploy to route.crispygoat.com / deploy (push) Successful in 3m1s
- docker-compose.yml: Postgres 16 Alpine with named volume, healthcheck
- .env.example: POSTGRES_* and DATABASE_URL template
- .gitignore: exclude db_data/ volume

Starting fresh, no data migration. App still wired to Supabase;
DB is ready for migrations to be applied.
2026-06-05 01:19:10 +00:00
tyler e8f2d76637 Fix workflow: use actual secrets, fix env file writing
Deploy to route.crispygoat.com / deploy (push) Successful in 3m21s
2026-06-04 18:53:11 -06:00
tyler beac3e48f1 Load .env.production from server before build
Deploy to route.crispygoat.com / deploy (push) Failing after 38s
2026-06-04 18:52:02 -06:00
tyler fddb9178c0 Use npm install instead of npm ci (no lockfile)
Deploy to route.crispygoat.com / deploy (push) Failing after 2m56s
2026-06-04 18:48:21 -06:00
tyler 8ad8dbb25b Remove npm cache from workflow (local runner)
Deploy to route.crispygoat.com / deploy (push) Failing after 3s
2026-06-04 18:47:46 -06:00
tyler 988310fd50 Add Gitea Actions deploy workflow
Deploy to route.crispygoat.com / deploy (push) Failing after 56s
2026-06-04 18:42:58 -06:00
tyler 2de32b0c66 Add rocket emoji to tagline 2026-06-05 00:38:29 +00:00
113 changed files with 1496 additions and 15236 deletions
+9 -76
View File
@@ -1,82 +1,15 @@
# ============================================================================
# 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.).
# ============================================================================
# --- Self-hosted Postgres (Docker) ---
POSTGRES_USER=routecommerce
POSTGRES_PASSWORD=change-me-strong-password
POSTGRES_DB=route_commerce
# Used by the app and push-migrations.js to talk to the local DB
DATABASE_URL=postgresql://routecommerce:change-me-strong-password@127.0.0.1:5432/route_commerce?schema=public
# ── App ─────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_BASE_URL=http://localhost:4000
NEXT_PUBLIC_SITE_URL=http://localhost:4000
# ── Database (Postgres, direct — Supabase is being removed) ────────────────
# Single connection string used by `pg.Pool` in src/lib/auth.ts and the
# admin-permissions / data-service layer. Format:
# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce
# ── Auth.js (NextAuth v5) ───────────────────────────────────────────────────
# Generate with: npx auth secret
# Or: openssl rand -base64 32
AUTH_SECRET=replace-me-with-a-32-byte-base64-string
# Base URL used to build OAuth callback URLs. In dev:
AUTH_URL=http://localhost:4000
# In production, set to https://yourdomain.com
# Google OAuth provider.
# 1. Go to https://console.cloud.google.com/apis/credentials
# 2. Create an OAuth 2.0 Client ID (type: Web application)
# 3. Add Authorized redirect URI: http://localhost:3000/api/auth/callback/google
# 4. Copy client id + client secret below
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Auth.js also reads AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET if you prefer the
# default NextAuth variable names. The code in src/auth.config.ts falls
# back to those names if GOOGLE_CLIENT_ID is unset.
# Set to "false" to disable the in-app dev credentials provider even in
# development. Default: enabled in dev only.
ALLOW_DEV_LOGIN=true
# ── Supabase (legacy, being removed) ────────────────────────────────────────
# Still used by the existing admin pages, server actions, and the
# `getAdminUser` flow. Once the auth migration is complete and the
# @supabase/* packages are removed, these can go away.
# --- Supabase (legacy, can be removed once app is rewired) ---
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=
# --- App secrets ---
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
MINIMAX_BASE_URL=
+8 -237
View File
@@ -17,270 +17,41 @@ jobs:
with:
node-version: '22'
- name: Start Docker stack
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
run: |
APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR
# Free the dev-stack port (3001) and the port the previous deploy used
# (so a new deploy can pick it back up if it's the lowest free port)
PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "")
for port in 3001 $PREV_PORT; do
if [ -n "$port" ] && ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${port}[[:space:]]"; then
echo "Port $port in use, freeing..."
fuser -k -9 $port/tcp 2>/dev/null || true
docker ps -aq --filter "publish=$port" 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true
fi
done
# Hard-stop the previous stack. Errors are NOT swallowed: if down
# fails, picking a port against a half-torn-down stack is exactly
# what produces the TOCTOU "address already in use" we keep hitting.
docker compose -f $APP_DIR/docker-compose.yml down --remove-orphans
# Belt-and-braces: anything with the postgrest name that survived.
docker ps -aq --filter "name=route_commerce_postgrest" | xargs -r docker rm -f >/dev/null 2>&1 || true
# docker-proxy sometimes leaves a listener behind for the published port.
pkill -9 -f 'docker-proxy.*3011' 2>/dev/null || true
pkill -9 -f 'docker-proxy.*3012' 2>/dev/null || true
pkill -9 -f 'docker-proxy.*3013' 2>/dev/null || true
sleep 3
# Verify the postgrest container is actually gone before we pick a port.
if docker ps -aq --filter "name=route_commerce_postgrest" | grep -q .; then
echo "ERROR: route_commerce_postgrest still running after down"
docker ps --filter "name=route_commerce_postgrest"
exit 1
fi
# Find the first free host port starting from 3011. Persist the choice
# so the Build and Deploy steps below can use the same URL.
POSTGREST_HOST_PORT=3011
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if ! ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${POSTGREST_HOST_PORT}[[:space:]]"; then
break
fi
echo "Port $POSTGREST_HOST_PORT in use, trying next... (attempt $attempt)"
POSTGREST_HOST_PORT=$((POSTGREST_HOST_PORT + 1))
if [ $POSTGREST_HOST_PORT -gt 30200 ]; then
echo "ERROR: no free port in 3011-30200 range"
exit 1
fi
sleep 1
done
echo "Using PostgREST host port: $POSTGREST_HOST_PORT"
echo "$POSTGREST_HOST_PORT" > .postgrest-port
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
export POSTGREST_HOST_PORT
# Seed config files into APP_DIR if missing (they live in the repo, not in APP_DIR)
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
[ -f $APP_DIR/docker-compose.yml ] || cp deploy/docker-compose.yml $APP_DIR/docker-compose.yml
cd $APP_DIR
[ -f .env ] || cp .env.example .env
# Append production secrets to .env (overriding .env.example defaults)
{
echo "POSTGRES_USER=${POSTGRES_USER}"
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
echo "POSTGRES_DB=${POSTGRES_DB}"
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT"
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL"
} >> .env
# Bring the stack up fresh — --force-recreate ensures no stale
# network/container references from prior failed attempts
docker compose up -d --force-recreate db postgrest minio minio_init
# Wait for Postgres healthcheck
for i in $(seq 1 30); do
if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then
echo "Postgres is ready"
break
fi
sleep 2
done
- name: Apply migrations
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
run: |
APP_DIR=/home/tyler/route-commerce
# Seed supabase/ into APP_DIR if missing (the deploy step copies it after, but
# we need it here for migrations)
[ -d $APP_DIR/supabase ] || cp -r supabase $APP_DIR/supabase
cd $APP_DIR
# PAGER= prevents psql from launching less/more in a non-interactive shell,
# which hangs indefinitely waiting for keypress. Batch all files into one
# connection for speed instead of one psql invocation per file.
export PAGER=
export PGPASSWORD="${POSTGRES_PASSWORD}"
PG="psql -h 127.0.0.1 -U ${POSTGRES_USER} -d ${POSTGRES_DB} --no-psqlrc -v ON_ERROR_STOP=0 -q"
$PG -f supabase/migrations/000_preflight_supabase_compat.sql || true
[ -f supabase/captured_schema.sql ] && $PG -f supabase/captured_schema.sql || true
# Concatenate all numbered migrations and run in one session
cat supabase/migrations/[0-9]*.sql | $PG
- name: Install dependencies
run: npm install
- name: Build
run: npm run build
env:
NODE_ENV: production
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# Auth.js v5 (NextAuth). Fall back to Better Auth names if the
# Gitea secret hasn't been renamed yet.
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
# Supabase (legacy, still used by admin pages/server actions until
# the Auth.js migration is finished)
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
# Storage (MinIO / S3)
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
# Stripe
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
# Resend
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
# AI providers
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
# Email sender
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
run: |
POSTGREST_HOST_PORT=$(cat .postgrest-port)
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
npm run build
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
# Auth.js v5 (with Better Auth fallback for the secret name)
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
# Storage
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
# PostgREST
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
# Supabase (legacy)
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
# Stripe
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
# Resend
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
# AI
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
run: |
APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR
# Use the port chosen by Start Docker stack (persisted to .postgrest-port)
POSTGREST_HOST_PORT=$(cat .postgrest-port)
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
# Write env file from secrets (preserves existing .env for docker compose)
{
printf "DATABASE_URL=%s\n" "$DATABASE_URL"
printf "NEXT_PUBLIC_API_URL=%s\n" "$NEXT_PUBLIC_API_URL"
printf "POSTGRES_USER=%s\n" "$POSTGRES_USER"
printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD"
printf "POSTGRES_DB=%s\n" "$POSTGRES_DB"
printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER"
printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD"
printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
printf "AUTH_SECRET=%s\n" "$AUTH_SECRET"
printf "AUTH_URL=%s\n" "$AUTH_URL"
printf "NEXT_PUBLIC_AUTH_URL=%s\n" "$NEXT_PUBLIC_AUTH_URL"
printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID"
printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET"
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN"
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY"
printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX"
printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL"
printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT"
printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI"
printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE"
printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL"
printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY"
printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY"
printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET"
printf "STRIPE_PUBLISHABLE_KEY=%s\n" "$STRIPE_PUBLISHABLE_KEY"
printf "RESEND_API_KEY=%s\n" "$RESEND_API_KEY"
printf "RESEND_WEBHOOK_SECRET=%s\n" "$RESEND_WEBHOOK_SECRET"
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY"
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL"
printf "FROM_EMAIL=%s\n" "$FROM_EMAIL"
} > $APP_DIR/.env.production
# Write env file from secrets
printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL" > $APP_DIR/.env.production
printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY" >> $APP_DIR/.env.production
printf "SUPABASE_SERVICE_ROLE_KEY=%s\n" "$SUPABASE_SERVICE_ROLE_KEY" >> $APP_DIR/.env.production
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY" >> $APP_DIR/.env.production
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL" >> $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
+3 -1
View File
@@ -41,4 +41,6 @@ supabase/.temp/
# IDE / local config
.mcp.json
.env*
# Docker / self-hosted Postgres
db_data/
+15 -59
View File
@@ -2,23 +2,11 @@
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
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) · **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.
Tech stack: Next.js 16 (App Router) · Supabase (auth + Postgres + RLS) · Stripe · Square · Resend (email) · Tailwind CSS v4
---
@@ -33,12 +21,14 @@ npx tsc --noEmit # TypeScript check (no emit)
npx playwright test # Run E2E tests (Playwright)
```
> 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.
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL.
> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
> 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.
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.
No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`).
---
@@ -51,24 +41,10 @@ E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.sp
- `dev_session=brand_admin` — full access to assigned brand 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 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.
`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.
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
All database writes go through server actions in `src/actions/`. These:
@@ -79,19 +55,9 @@ 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.
### Database (Postgres, direct)
### SECURITY DEFINER RPCs + Brand Scoping
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:
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:
- 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
@@ -216,19 +182,10 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
### 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 "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.)
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`.
`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
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
@@ -243,7 +200,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Key Conventions
- 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/`.
- 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)
- `gen_random_uuid()` used in migrations for primary keys
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
- Status enums stored as TEXT — no PostgreSQL ENUM type
@@ -260,11 +217,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` |
| Middleware (route protection) | `src/middleware.ts` |
| 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}/`) |
| Server actions | `src/actions/*.ts` (one file per domain) |
| Admin pages | `src/app/admin/[module]/page.tsx` |
| Admin client components | `src/components/admin/*.tsx` |
| Migrations | `supabase/migrations/` (kept for now; will likely move to `db/migrations/` in a later pass) |
| Postgres pool / driver | `src/lib/db.ts` (TBD — create during the Supabase removal pass) |
| Migrations | `supabase/migrations/` |
| Supabase client | `src/lib/supabase.ts` |
| Email templates | `src/lib/email-templates.ts` |
| Date formatting | `src/lib/format-date.ts` |
| Feature flags | `src/lib/feature-flags.ts` |
@@ -281,8 +238,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## 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.
- **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.
- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have RLS disabled. All brand scoping must be enforced in server actions.
- **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.
- **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`.
+9 -72
View File
@@ -2,40 +2,11 @@
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
**Last updated:** 2026-06-06 (Supabase → Postgres pivot)
**Last updated:** 2026-06-03 (during Supabase migration apply session)
---
## 🚨 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)*
## Supabase CLI + Migrations Tooling
### Login + Link (done in this session)
- User ran `supabase login`
@@ -64,23 +35,15 @@ Key changes:
- Falls back to direct `pg` only if CLI path fails.
- Header comments updated with current recommended workflow.
**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):**
**Recommended commands now:**
```bash
# Supabase CLI path (legacy — do not use going forward)
supabase login
supabase link --project-ref wnzkhezyhnfzhkhiflrp
node supabase/push-migrations.js 148 # CLI path
node supabase/push-migrations.js 148 # or any prefix
# or
npm run migrate:one 148
```
```bash
# Direct pg path (this is the future — only the pg branch is kept alive in the script)
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
# or
DATABASE_URL=postgres://... npm run migrate:one 148
```
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
---
@@ -170,23 +133,19 @@ Verification queries (post-apply) confirmed:
---
## Current State / Gotchas (2026-06-06)
## Current State / Gotchas
- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only.
- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.)
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase.
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch.
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
- `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.
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project.
- 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.
- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies.
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
---
## How to Use This Memory
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
- **Read the Direction Pivot section first** — it supersedes the older Supabase-flavored instructions.
- Update this file with new key facts, applied migrations, or new gotchas.
- Feel free to add dated sections.
@@ -286,25 +245,3 @@ Follow-up pass on the original Codex review covering public site, buyer path, bi
### Migration 203 — applied via Supabase CLI
`203_plan_usage_active_products.sql` updates `get_brand_plan_info` to count `products` where `active = true AND deleted_at IS NULL`, matching the dashboard's "Active Products" stat. The `NOTIFY pgrst, 'reload schema'` ensures PostgREST picks up the change without restart.
## Gitea build fix — 2026-06-06
Gitea runner (`https://git.crispygoat.com/tyler/route-commerce.git`, branch `main`) was failing `next build` with two errors:
1. **DYNAMIC_SERVER_USAGE** on `/admin/settings/square-sync` (and the whole admin tree): `getAdminUser()` reads `cookies()` via `next/headers`. The admin layout tried to prerender statically, so the first child page that hit cookies aborted the build.
2. **Prerender ECONNREFUSED** on `/indian-river-direct/stops`: `getPublicStopsForBrand` / `getActiveStopsForSitemap` / `getBrandSettingsPublic` fetch `NEXT_PUBLIC_SUPABASE_URL` at build time. The Gitea runner passes a Supabase URL that resolves but is unreachable, so `fetch` throws `ECONNREFUSED` and the prerender aborts.
The earlier commit `2f3be54 fix(actions): skip Supabase fetch at build time when env vars unset` only added `if (!supabaseUrl || !supabaseKey) return [];` — but in CI the env vars **are** set, so the guard passed and the fetch was still attempted.
### Fixes applied
- `src/actions/stops.ts` — wrapped `getActiveStopsForSitemap` and `getPublicStopsForBrand` fetches in `try/catch` returning `[]` on error. Env-var guard kept as fast path.
- `src/actions/brand-settings.ts` — wrapped `getBrandSettingsPublic` fetch in `try/catch` returning `{ success: false }` on error.
- `src/app/admin/settings/square-sync/page.tsx` — added `export const dynamic = "force-dynamic";` (was missing).
- `src/app/admin/layout.tsx` — added `export const dynamic = "force-dynamic";` so the entire admin tree opts out of static prerender (layout calls `getAdminUser()` which reads cookies).
- `.gitea/workflows/deploy.yml` was simplified earlier in commit `2d837bc` to a thin wrapper around `deploy/deploy.sh`.
### Remote
- The crispygoat repo (`git@git.crispygoat.com:tyler/route-commerce.git`) and the GitHub `origin` repo are separate forks — `tyler/main` is the self-hosted Auth.js + Postgres branch, `origin/main` is the Supabase branch. Don't merge them; they share no deploy workflow.
- Push targets `tyler/main` to trigger the Gitea build.
+1 -1
View File
@@ -1,6 +1,6 @@
# Route Commerce
A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard.
A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard. 🚀
## What It Does
Binary file not shown.
-52
View File
@@ -1,52 +0,0 @@
# =============================================================================
# .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
@@ -1,6 +0,0 @@
# Runtime artefacts written by deploy.sh — do NOT commit these.
.deploy.lock
deploy.log
.postgrest-port
.nextjs-port
.env.production
-57
View File
@@ -1,57 +0,0 @@
# =============================================================================
# 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
@@ -1,54 +0,0 @@
# =============================================================================
# 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
@@ -1,429 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# deploy.sh — Idempotent PostgREST + Next.js production deploy
# =============================================================================
#
# Self-hosted single-server deploy. Triggered manually, by Gitea webhook, or
# by a Gitea Actions runner after a push to `main` (or `gitea-sync`).
#
# What it does, in order:
# 1. Acquires an exclusive flock (concurrent deploys die loudly).
# 2. CLEANUP: stops the dev stack on :3001 and the previous prod stack
# (port read from .postgrest-port / .nextjs-port).
# 3. PORT_SELECTION: picks the lowest free port in [3011..30200] for
# PostgREST, then the next free one for the Next.js frontend.
# 4. BUILD: runs `npm run build` with NEXT_PUBLIC_API_URL exported so it
# gets inlined into the client bundle.
# 5. DEPLOY: writes the chosen ports to .env.production, brings the
# compose stack up.
# 6. NGINX: renders the nginx config from a template (with the current
# ports), `nginx -t`s it, and reloads the host systemd nginx.
# 7. HEALTHCHECK: curls the new stack; if anything is down, rolls back.
# 8. IMAGE_PRUNE: optional, removes dangling images on success.
#
# Files written to the workspace root:
# .postgrest-port current PostgREST host port (atomic)
# .nextjs-port current Next.js host port (atomic)
# .env.production rendered env fed to docker compose
# .deploy.lock flock target
# deploy.log append-only log
# =============================================================================
set -Eeuo pipefail
IFS=$'\n\t'
# -----------------------------------------------------------------------------
# Configurable variables (override via environment before invoking)
# -----------------------------------------------------------------------------
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
COMPOSE_DIR="${COMPOSE_DIR:-${WORKSPACE}/deploy}"
COMPOSE_FILE="${COMPOSE_FILE:-${COMPOSE_DIR}/docker-compose.yml}"
NGINX_TEMPLATE="${NGINX_TEMPLATE:-${COMPOSE_DIR}/nginx.conf.template}"
NGINX_RENDERED="${NGINX_RENDERED:-/etc/nginx/sites-available/prod-app.conf}"
NGINX_LINK="${NGINX_LINK:-/etc/nginx/sites-enabled/prod-app.conf}"
NGINX_OWNER="${NGINX_OWNER:-www-data:www-data}"
PROJECT_NAME="${PROJECT_NAME:-prod-app}"
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
ENV_FILE="${ENV_FILE:-${WORKSPACE}/.env.production}"
LOCK_FILE="${LOCK_FILE:-${WORKSPACE}/.deploy.lock}"
LOG_FILE="${LOG_FILE:-${WORKSPACE}/deploy.log}"
DEV_PORT="${DEV_PORT:-3001}"
PORT_RANGE_START="${PORT_RANGE_START:-3011}"
PORT_RANGE_END="${PORT_RANGE_END:-30200}"
HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-60}" # seconds total
HEALTHCHECK_INTERVAL="${HEALTHCHECK_INTERVAL:-2}" # seconds between tries
# Image pruning (set PRUNE_IMAGES=0 to skip)
PRUNE_IMAGES="${PRUNE_IMAGES:-1}"
# Optional: pin the public URL the browser uses. If empty, we default to
# http://localhost:${POSTGREST_HOST_PORT}. For production with a real domain
# and nginx in front, set e.g. NEXT_PUBLIC_API_URL=https://app.example.com/api
NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-}"
# -----------------------------------------------------------------------------
# Logging — every line is timestamped, tee'd to stdout AND the log file.
# We replace the shell's fd 1/2 with a tee so any tool that prints (npm, docker,
# curl) lands in both places automatically.
# -----------------------------------------------------------------------------
mkdir -p "$(dirname "$LOG_FILE")"
exec > >(tee -a "$LOG_FILE") 2>&1
ts() { date '+%Y-%m-%d %H:%M:%S'; }
log() { printf '[%s] %s\n' "$(ts)" "$*"; }
hr() { printf '%s\n' '----------------------------------------------------------------'; }
section() { hr; log "== $* =="; hr; }
# Trap so we always release the lock and surface a useful message.
on_exit() {
local exit_code=$?
if (( exit_code != 0 )); then
log "DEPLOY FAILED with exit code ${exit_code}"
log "See ${LOG_FILE} for full output. Rollback hints:"
log " - Previous port was: ${PREVIOUS_POSTGREST_PORT:-<unknown>}"
log " - Current .postgrest-port value: $(read_port_file "$POSTGREST_PORT_FILE" || echo '<none>')"
log " - To restart the old stack manually:"
log " POSTGREST_HOST_PORT=${PREVIOUS_POSTGREST_PORT:-3011} \\"
log " NEXTJS_HOST_PORT=${PREVIOUS_NEXTJS_PORT:-3012} \\"
log " docker compose -p ${PROJECT_NAME} --env-file ${ENV_FILE} up -d"
else
log "DEPLOY OK — PostgREST on :${NEW_POSTGREST_PORT}, Next.js on :${NEW_NEXTJS_PORT}"
fi
# flock on fd 9 releases automatically when the script exits.
}
trap on_exit EXIT
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
read_port_file() {
# Echo the port in $1, or empty string if missing/garbage.
local f="$1"
[[ -f "$f" ]] || return 1
local v
v=$(tr -d '[:space:]' < "$f" 2>/dev/null || true)
[[ "$v" =~ ^[0-9]+$ ]] || return 1
printf '%s' "$v"
}
render_template() {
# Portable envsubst: replaces $VAR and ${VAR} references in stdin with
# values from the current environment. Only the variable names given as
# args are expanded (matches `envsubst` behavior). If real envsubst is
# available we use it for speed.
local vars="$1"
if command -v envsubst >/dev/null 2>&1; then
envsubst "$vars"
else
# Build a sed expression like: s/\${VAR}/$VAR/g; s/\bVAR\b/$VAR/g
local sed_expr=()
for v in $vars; do
v="${v#\$}"
v="${v#\{}"
v="${v%\}}"
sed_expr+=( -e "s|\${${v}}|${!v:-}|g" )
sed_expr+=( -e "s|\$${v}\b|${!v:-}|g" )
done
sed "${sed_expr[@]}"
fi
}
is_listening() {
# Returns 0 if port $1 has a TCP listener (v4 or v6) on this host.
local port="$1"
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${port}$"
}
next_free_port() {
# Walk PORT_RANGE_START..PORT_RANGE_END and return the first port nobody
# is listening on. Returns 1 if none are free.
local p
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
if ! is_listening "$p"; then
printf '%s' "$p"
return 0
fi
done
return 1
}
atomic_write() {
# Write stdin to $1 atomically: write to temp, fsync, rename. This is
# what lets us use .postgrest-port as a single source of truth — readers
# always see either the old value or the new value, never a half-written one.
local target="$1"
local tmp
tmp=$(mktemp "${target}.tmp.XXXXXX")
cat > "$tmp"
sync
mv -f "$tmp" "$target"
}
free_port() {
# Try several strategies to free a port:
# 1. docker compose down for our project (idempotent)
# 2. brute-force kill of any process bound to the port
local port="$1" label="$2"
if [[ -z "$port" ]]; then return 0; fi
log " ${label} port ${port}: stopping project '${PROJECT_NAME}' (if up)"
( cd "$COMPOSE_DIR" && docker compose -p "$PROJECT_NAME" down --remove-orphans --timeout 10 ) \
>/dev/null 2>&1 || true
if is_listening "$port"; then
log " ${label} port ${port}: still listening, attempting pkill"
# fuser prints PIDs holding the port; xargs kills them.
local pids
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
if [[ -n "$pids" ]]; then
# shellcheck disable=SC2086
kill $pids 2>/dev/null || true
sleep 1
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
[[ -n "$pids" ]] && kill -9 $pids 2>/dev/null || true
fi
fi
if is_listening "$port"; then
log " ${label} port ${port}: WARNING — still in use after cleanup"
return 1
fi
log " ${label} port ${port}: free"
return 0
}
healthcheck() {
# Hit $1 (URL) until it returns 2xx within HEALTHCHECK_TIMEOUT seconds.
local url="$1" label="$2" elapsed=0
log " ${label}: ${url}"
while (( elapsed < HEALTHCHECK_TIMEOUT )); do
if curl -fsS --max-time 5 -o /dev/null "$url"; then
log " ${label}: OK (after ${elapsed}s)"
return 0
fi
sleep "$HEALTHCHECK_INTERVAL"
elapsed=$(( elapsed + HEALTHCHECK_INTERVAL ))
done
log " ${label}: FAILED after ${HEALTHCHECK_TIMEOUT}s"
return 1
}
# -----------------------------------------------------------------------------
# Lock — refuse to run if another deploy is in flight.
# -----------------------------------------------------------------------------
section "LOCK"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "Another deploy holds ${LOCK_FILE}. Exiting."
exit 1
fi
log "Acquired exclusive lock on ${LOCK_FILE}"
# -----------------------------------------------------------------------------
# 0. Banner
# -----------------------------------------------------------------------------
section "DEPLOY START"
log "Workspace: ${WORKSPACE}"
log "Project: ${PROJECT_NAME}"
log "Compose: ${COMPOSE_FILE}"
log "Nginx tpl: ${NGINX_TEMPLATE}"
log "Port range: ${PORT_RANGE_START}..${PORT_RANGE_END}"
log "Caller: ${USER:-<unknown>}@$(hostname)"
# -----------------------------------------------------------------------------
# 1. CLEANUP — port 3001 (dev) and the previous prod ports.
# -----------------------------------------------------------------------------
section "CLEANUP"
free_port "$DEV_PORT" "dev"
PREVIOUS_POSTGREST_PORT=$(read_port_file "$POSTGREST_PORT_FILE" || true)
PREVIOUS_NEXTJS_PORT=$(read_port_file "$NEXTJS_PORT_FILE" || true)
log "Previous prod ports: PostgREST=${PREVIOUS_POSTGREST_PORT:-<none>} Next.js=${PREVIOUS_NEXTJS_PORT:-<none>}"
# Stale-port guard: if the file points to a port that is NOT in our standard
# range, or to a port that nothing is listening on anymore, we still tear
# down the project (cheap) but we don't try to free the port itself —
# someone else might be using it.
free_port "${PREVIOUS_POSTGREST_PORT:-}" "prev-postgrest"
free_port "${PREVIOUS_NEXTJS_PORT:-}" "prev-nextjs"
# -----------------------------------------------------------------------------
# 2. PORT_SELECTION — find the two lowest free ports.
# -----------------------------------------------------------------------------
section "PORT_SELECTION"
NEW_POSTGREST_PORT=$(next_free_port) || {
log "No free port in [${PORT_RANGE_START}..${PORT_RANGE_END}]. Bailing out."
exit 2
}
log "PostgREST: ${NEW_POSTGREST_PORT}"
# Re-check after allocation, since we want distinct ports for both services.
NEW_NEXTJS_PORT=""
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
if (( p == NEW_POSTGREST_PORT )); then continue; fi
if ! is_listening "$p"; then NEW_NEXTJS_PORT="$p"; break; fi
done
if [[ -z "$NEW_NEXTJS_PORT" ]]; then
log "No free port for Next.js after allocating ${NEW_POSTGREST_PORT}. Bailing out."
exit 2
fi
log "Next.js: ${NEW_NEXTJS_PORT}"
# -----------------------------------------------------------------------------
# 3. BUILD — Next.js, with NEXT_PUBLIC_API_URL inlined into the client bundle.
# -----------------------------------------------------------------------------
section "BUILD"
cd "$WORKSPACE"
# Default the public API URL the browser will see.
if [[ -z "$NEXT_PUBLIC_API_URL" ]]; then
NEXT_PUBLIC_API_URL="http://localhost:${NEW_POSTGREST_PORT}"
fi
log "NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}"
# Node-only check: don't try to build if there's no package.json.
if [[ -f package.json ]]; then
# Make sure the deps are present (idempotent — npm ci is a no-op when locked).
if [[ -f package-lock.json ]]; then
log "npm ci (locked install)"
npm ci --no-audit --no-fund
else
log "npm install (no lockfile present — consider committing package-lock.json)"
npm install --no-audit --no-fund
fi
log "npm run build"
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
npm run build
else
log "No package.json in ${WORKSPACE} — skipping build step."
fi
# -----------------------------------------------------------------------------
# 4. ENV FILE — render .env.production for the running containers.
# -----------------------------------------------------------------------------
section "ENV"
# Preserve any pre-existing secrets in .env.production. We only own the lines
# we write; everything else is left alone. (The simplest sane strategy.)
SECRETS_FILE=""
if [[ -f "$ENV_FILE" ]]; then
SECRETS_FILE=$(mktemp)
# Drop any lines we manage; keep the rest verbatim.
grep -v -E '^(POSTGREST_HOST_PORT|NEXTJS_HOST_PORT|NEXT_PUBLIC_API_URL)=' \
"$ENV_FILE" > "$SECRETS_FILE" || true
fi
{
printf '# Generated by deploy.sh on %s — safe to edit, lines below are managed\n' "$(ts)"
printf 'POSTGREST_HOST_PORT=%s\n' "$NEW_POSTGREST_PORT"
printf 'NEXTJS_HOST_PORT=%s\n' "$NEW_NEXTJS_PORT"
printf 'NEXT_PUBLIC_API_URL=%q\n' "$NEXT_PUBLIC_API_URL"
if [[ -n "$SECRETS_FILE" ]]; then
cat "$SECRETS_FILE"
rm -f "$SECRETS_FILE"
fi
} > "${ENV_FILE}.new"
mv -f "${ENV_FILE}.new" "$ENV_FILE"
chmod 600 "$ENV_FILE"
log "Wrote ${ENV_FILE}"
# -----------------------------------------------------------------------------
# 5. DEPLOY — bring the stack up.
# -----------------------------------------------------------------------------
section "DEPLOY"
cd "$COMPOSE_DIR"
log "docker compose -p ${PROJECT_NAME} up -d --build"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --build
# -----------------------------------------------------------------------------
# 6. NGINX — render config from template, test, reload.
# -----------------------------------------------------------------------------
section "NGINX"
if [[ -f "$NGINX_TEMPLATE" ]]; then
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
render_template '${POSTGREST_HOST_PORT} ${NEXTJS_HOST_PORT} ${NEXT_PUBLIC_API_URL}' \
< "$NGINX_TEMPLATE" > "$NGINX_RENDERED"
log "Rendered: ${NGINX_RENDERED}"
chown "$NGINX_OWNER" "$NGINX_RENDERED" 2>/dev/null || true
chmod 644 "$NGINX_RENDERED"
# Wire it into sites-enabled if not already linked.
if [[ ! -L "$NGINX_LINK" && ! -e "$NGINX_LINK" ]]; then
log "Enabling site: ${NGINX_LINK} -> ${NGINX_RENDERED}"
ln -s "$NGINX_RENDERED" "$NGINX_LINK"
fi
log "nginx -t"
nginx -t
log "systemctl reload nginx"
systemctl reload nginx
else
log "No nginx template at ${NGINX_TEMPLATE} — skipping reverse proxy step."
fi
# -----------------------------------------------------------------------------
# 7. HEALTHCHECK — direct + via nginx (when applicable).
# -----------------------------------------------------------------------------
section "HEALTHCHECK"
# Direct checks (bypass nginx, catch compose issues)
healthcheck "http://127.0.0.1:${NEW_POSTGREST_PORT}/" "postgrest-direct" || ROLLBACK=1
healthcheck "http://127.0.0.1:${NEW_NEXTJS_PORT}/" "nextjs-direct" || ROLLBACK=1
# nginx-fronted check (only meaningful if nginx template exists)
if [[ -f "$NGINX_TEMPLATE" && "${ROLLBACK:-0}" != "1" ]]; then
healthcheck "http://127.0.0.1/" "nginx-front" || ROLLBACK=1
fi
if [[ "${ROLLBACK:-0}" == "1" ]]; then
log "HEALTHCHECK FAILED — rolling back."
log "Tearing down the new stack on :${NEW_POSTGREST_PORT} / :${NEW_NEXTJS_PORT}"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" down --remove-orphans --timeout 10 || true
# If we had a previous port file, the old one is still on disk (we wrote
# the new one to .new and only mv'd on success... but we DID mv already,
# so re-write the old value).
if [[ -n "${PREVIOUS_POSTGREST_PORT:-}" ]]; then
printf '%s\n' "$PREVIOUS_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
else
rm -f "$POSTGREST_PORT_FILE"
fi
if [[ -n "${PREVIOUS_NEXTJS_PORT:-}" ]]; then
printf '%s\n' "$PREVIOUS_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
else
rm -f "$NEXTJS_PORT_FILE"
fi
exit 3
fi
# -----------------------------------------------------------------------------
# 8. PERSIST — commit the chosen ports as the new single source of truth.
# (Done AFTER healthcheck so a failed deploy doesn't clobber the old one.)
# -----------------------------------------------------------------------------
section "PERSIST"
printf '%s\n' "$NEW_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
printf '%s\n' "$NEW_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
log ".postgrest-port = ${NEW_POSTGREST_PORT}"
log ".nextjs-port = ${NEW_NEXTJS_PORT}"
# -----------------------------------------------------------------------------
# 9. IMAGE_PRUNE — optional housekeeping.
# -----------------------------------------------------------------------------
if [[ "$PRUNE_IMAGES" == "1" ]]; then
section "IMAGE_PRUNE"
docker image prune -f
fi
section "DONE"
exit 0
-70
View File
@@ -1,70 +0,0 @@
# =============================================================================
# docker-compose.yml — production stack consumed by deploy.sh
# =============================================================================
#
# The host-side ports (POSTGREST_HOST_PORT, NEXTJS_HOST_PORT) are written by
# deploy.sh into .env.production. We interpolate from there with ${VAR:-3011}
# so a manual `docker compose up` without the deploy script still works.
#
# Note on networking: the Next.js container calls PostgREST on
# `host.docker.internal:POSTGREST_HOST_PORT` so the inlined
# NEXT_PUBLIC_API_URL (a localhost URL, per the deploy contract) resolves
# correctly. On Linux you may need to add
# extra_hosts:
# - "host.docker.internal:host-gateway"
# which is included below for that reason.
# =============================================================================
name: prod-app # default project name; deploy.sh overrides with -p
services:
postgrest:
image: postgrest/postgrest:latest
container_name: prod-app-postgrest
restart: unless-stopped
# The host port is dynamic. The container always listens on 3000.
ports:
- "${POSTGREST_HOST_PORT:-3011}:3000"
environment:
PGRST_DB_URI: ${PGRST_DB_URI}
PGRST_DB_ANON_ROLE: ${PGRST_DB_ANON_ROLE:-anon}
PGRST_DB_SCHEMA: ${PGRST_DB_SCHEMA:-public}
PGRST_SERVER_PORT: 3000
# Optional: tighten CORS for your real domain
PGRST_DB_TXN_END: "commit-allow-overwrite"
# Healthcheck lets `docker compose ps` show healthy state.
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
interval: 10s
timeout: 3s
retries: 6
nextjs:
# Build context is the workspace root (one level up from this file).
build:
context: ..
dockerfile: deploy/Dockerfile.nextjs
container_name: prod-app-nextjs
restart: unless-stopped
ports:
- "${NEXTJS_HOST_PORT:-3012}:3000"
environment:
# Runtime vars — these can change without rebuilding. NEXT_PUBLIC_*
# is also exported here for completeness, but the BROWSER's view of
# NEXT_PUBLIC_API_URL is baked in at build time (see Dockerfile).
NODE_ENV: production
PORT: 3000
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
env_file:
- ../.env.production # server-side secrets read at runtime
extra_hosts:
# Lets the container reach the host on the dynamically allocated port.
- "host.docker.internal:host-gateway"
depends_on:
postgrest:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
interval: 10s
timeout: 3s
retries: 6
-54
View File
@@ -1,54 +0,0 @@
#!/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
@@ -1,89 +0,0 @@
# =============================================================================
# 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;
}
}
+25
View File
@@ -0,0 +1,25 @@
services:
db:
image: postgres:16-alpine
container_name: route_commerce_db
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- db_data:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
volumes:
db_data:
driver: local
@@ -1,470 +0,0 @@
# 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
@@ -1,14 +0,0 @@
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
@@ -0,0 +1,64 @@
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",
],
};
+2 -7
View File
@@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000",
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint",
@@ -15,13 +15,10 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.96.0",
"@auth/pg-adapter": "^1.11.2",
"@clerk/nextjs": "^7.4.2",
"@google/generative-ai": "^0.24.1",
"@gsap/react": "^2.1.2",
"@sentry/nextjs": "^10.55.0",
"@stripe/react-stripe-js": "^3.10.0",
"@stripe/stripe-js": "^5.10.0",
"@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.105.3",
"@upstash/ratelimit": "^2.0.8",
@@ -31,7 +28,6 @@
"gsap": "^3.15.0",
"lucide-react": "^1.17.0",
"next": "^16.2.6",
"next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6",
"openai": "^6.37.0",
"papaparse": "^5.5.3",
@@ -52,7 +48,6 @@
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/papaparse": "^5.5.2",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -63,7 +58,7 @@
"pg": "^8.20.0",
"playwright": "^1.59.1",
"tailwindcss": "^4",
"typescript": "^5.9.3"
"typescript": "^5"
},
"overrides": "{}"
}
-99
View File
@@ -1,99 +0,0 @@
#!/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
@@ -1,346 +0,0 @@
#!/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()
+1 -4
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser";
import { importProductsBatch } from "@/actions/import-products";
import { importOrdersBatch } from "@/actions/import-orders";
@@ -42,9 +41,7 @@ export async function analyzeImport(
): Promise<AnalyzeImportResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
try {
assertBrandAccess(adminUser, brandId);
} catch {
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, error: "Not authorized for this brand" };
}
+3 -4
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -97,7 +96,7 @@ async function brandScopedRPC<T>(
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const brandId = adminUser.brand_id ?? null;
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
method: "POST",
@@ -233,7 +232,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const brandId = adminUser.brand_id ?? null;
const params = new URLSearchParams({
select: "id,customer_name,subtotal,status,created_at,fulfillment",
@@ -294,7 +293,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const brandId = adminUser.brand_id ?? null;
const params = new URLSearchParams({
select: "id,status",
-56
View File
@@ -1,56 +0,0 @@
"use server";
import { signIn, signOut } from "@/lib/auth";
import { AuthError } from "next-auth";
/**
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
* use from client components.
*
* Why server actions?
* • The Auth.js v5 `signIn` function has to run on the server (it
* needs to set the session cookie, talk to the database adapter,
* and redirect the user to the OAuth provider).
* • Calling it from a client component via a server action keeps the
* client bundle small and avoids exposing the OAuth client secret.
*
* Usage from a client component:
* <form action={signInWithGoogle}>
* <button type="submit">Sign in with Google</button>
* </form>
*
* Usage for the dev credentials provider (dev only):
* <form action={signInWithDev}>
* <input name="username" />
* <input name="password" type="password" />
* <button type="submit">Dev login</button>
* </form>
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
export async function signInWithDev(formData: FormData): Promise<void> {
const username = String(formData.get("username") ?? "admin");
const password = String(formData.get("password") ?? "dev");
try {
await signIn("dev-login", {
username,
password,
redirectTo: "/admin",
});
} catch (e) {
// signIn() throws a `NEXT_REDIRECT` to navigate — let that through
// so the redirect actually happens. Re-throw any other error so the
// caller can render a meaningful message.
if (e instanceof AuthError) {
throw new Error(`Dev sign-in failed: ${e.type}`);
}
throw e;
}
}
export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
}
@@ -1,123 +0,0 @@
"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 };
}
}
+17 -29
View File
@@ -271,37 +271,25 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
// Public version for storefront pages — uses slug, no auth required
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. 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 supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just falls back to its
// default brand name and revalidates from a real request later.
try {
const response = await fetch(
`${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 }),
}
);
const response = await fetch(
`${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 };
const data = await response.json();
return {
success: true,
settings: data,
wholesaleEnabled: data?.wholesale_enabled,
};
} catch {
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();
return {
success: true,
settings: data,
wholesaleEnabled: data?.wholesale_enabled,
};
}
export async function saveBrandSettings(params: {
-65
View File
@@ -1,65 +0,0 @@
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 [];
}
}
+3 -8
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type CampaignType = "marketing" | "operational" | "transactional";
@@ -58,11 +57,7 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
@@ -152,7 +147,7 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: adminUser.brand_id ?? null }),
}
);
@@ -172,7 +167,7 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser, brandId)) ?? null }),
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: brandId ?? adminUser.brand_id ?? null }),
}
);
+3 -8
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
import type { AudienceRules } from "./campaigns";
@@ -121,15 +120,11 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// Resolve brand from campaign or parameter (URL > cookie > legacy > first of brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
// Resolve brand from campaign or parameter
const effectiveBrandId = brandId ?? adminUser.brand_id;
// Brand scoping: brand_admin can only send their own brand's campaigns
if (adminUser.role === "brand_admin" && effectiveBrandId && !adminUser.brand_ids.includes(effectiveBrandId)) {
if (adminUser.role === "brand_admin" && effectiveBrandId && adminUser.brand_id !== effectiveBrandId) {
return { success: false, error: "Not authorized to send this brand's campaigns" };
}
+2 -7
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
import type { AudienceRules } from "./campaigns";
@@ -34,11 +33,7 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
// Brand scoping: brand_admin can only see their own brand's templates
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
@@ -117,7 +112,7 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? null }),
}
);
+2 -3
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -67,7 +66,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const brandId = adminUser.brand_id ?? null;
// Get today's date range
const today = new Date();
@@ -203,7 +202,7 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const brandId = adminUser.brand_id ?? null;
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
-304
View File
@@ -1,304 +0,0 @@
"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 };
}
+1 -6
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
import { randomUUID } from "crypto";
@@ -42,11 +41,7 @@ export async function createAdminOrder(
}
// Brand scoping
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
if (adminUser.role === "brand_admin" && adminUser.brand_id && effectiveBrandId !== adminUser.brand_id) {
return { success: false, error: "Not authorized for this brand" };
}
+4 -4
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type PaymentProvider = "stripe" | "square" | "manual";
@@ -66,9 +65,10 @@ export async function savePaymentSettings(params: {
return { success: false, error: "Not authorized" };
}
try {
assertBrandAccess(adminUser, params.brandId);
} catch {
if (
adminUser.role === "brand_admin" &&
adminUser.brand_id !== params.brandId
) {
return { success: false, error: "Not authorized for this brand" };
}
+1 -6
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export async function deleteProduct(
@@ -17,11 +16,7 @@ export async function deleteProduct(
return { success: false, error: "Not authorized to manage products" };
}
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
+1 -6
View File
@@ -38,12 +38,7 @@ export async function uploadProductImage(
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
{
method: "PUT",
headers: {
"apikey": supabaseKey,
"Authorization": `Bearer ${supabaseKey}`,
"Content-Type": `image/${ext}`,
"x-upsert": "true"
},
headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" },
body: buffer,
}
);
+8 -65
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -154,14 +153,7 @@ export interface FieldYieldSummary {
export async function getRouteTraceLots(brandId: string, status?: string) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
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;
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -243,14 +235,7 @@ export async function createHarvestLot(
) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
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;
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -313,14 +298,7 @@ export async function updateHarvestLotStatus(
export async function getRouteTraceStats(brandId: string) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
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;
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -340,14 +318,7 @@ export async function getRouteTraceStats(brandId: string) {
export async function searchHarvestLots(brandId: string, query: string) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
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;
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -376,14 +347,7 @@ export async function getTraceChain(lotId: string) {
export async function getHarvestLotsReadyToHaul(brandId: string) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
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;
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -400,14 +364,7 @@ export async function getHarvestLotsReadyToHaul(brandId: string) {
export async function getFieldYieldSummary(brandId: string) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
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;
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -448,14 +405,7 @@ export interface InventoryByCrop {
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
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;
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -501,14 +451,7 @@ export async function getRecentLotEvents(
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
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;
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
-47
View File
@@ -1,47 +0,0 @@
"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 };
}
+2 -3
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type UpdateShippingStatusResult =
@@ -29,7 +28,7 @@ export async function updateShippingStatus(
p_order_id: orderId,
p_shipping_status: status,
p_tracking_number: trackingNumber ?? null,
p_brand_id: await getActiveBrandId(adminUser),
p_brand_id: adminUser.brand_id ?? null,
}),
}
);
@@ -80,7 +79,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: await getActiveBrandId(adminUser),
p_brand_id: adminUser.brand_id ?? null,
}),
}
);
-5
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
import type { FedExServiceType } from "./fedex-rates";
@@ -157,10 +156,6 @@ export async function createFedExShipment(
const order = orders[0];
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;
// Get FedEx settings
-5
View File
@@ -11,7 +11,6 @@
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
// ── Types ────────────────────────────────────────────────────────────────────
@@ -178,10 +177,6 @@ export async function getFedExRates(
const order = orders[0];
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;
// Fetch shipping settings for this brand
+2 -7
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type SyncLogEntry = {
@@ -30,9 +29,7 @@ export async function syncSquareNow(
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
if (!adminUser.can_manage_orders) return { success: false, synced: 0, errors: ["Not authorized"] };
try {
assertBrandAccess(adminUser, brandId);
} catch {
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, synced: 0, errors: ["Not authorized"] };
}
@@ -64,9 +61,7 @@ export async function getSyncLog(brandId: string): Promise<{
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, logs: [] };
if (!adminUser.can_manage_orders) return { success: false, logs: [] };
try {
assertBrandAccess(adminUser, brandId);
} catch {
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, logs: [] };
}
+31 -56
View File
@@ -2,7 +2,6 @@
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 StopImportRow = {
@@ -26,11 +25,7 @@ export async function createStopsBatch(
return { success: false, created: 0, error: "Not authorized to manage stops" };
}
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, created: 0, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
const effectiveBrandId = brandId || adminUser.brand_id;
if (!effectiveBrandId) {
return { success: false, created: 0, error: "No brand selected" };
}
@@ -110,31 +105,22 @@ export type StopForSitemap = {
};
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the sitemap render with only the static URLs.
if (!supabaseUrl || !supabaseKey) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Get all active stops with their brand slug. Wrapped in try/catch so a
// build-time outage (ECONNREFUSED) doesn't crash the prerender — the
// sitemap just renders without stop URLs.
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
);
// Get all active stops with their brand slug
const response = await fetch(
`${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();
return Array.isArray(stops) ? stops : [];
} catch {
return [];
}
const stops = await response.json();
return Array.isArray(stops) ? stops : [];
}
/**
@@ -164,35 +150,24 @@ export async function getPublicStopsForBrand(
): Promise<PublicStop[]> {
if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the page render with zero stops; at runtime the
// fetch path is unchanged.
if (!supabaseUrl || !supabaseKey) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just renders with no stops
// and revalidates from a real request once the cache is warm.
try {
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: {
revalidate: 300,
tags: ["stops", `brand:${brandSlug}:stops`],
},
}
);
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: {
revalidate: 300,
tags: ["stops", `brand:${brandSlug}:stops`],
},
}
);
if (!response.ok) return [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
} catch {
return [];
}
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
}
-122
View File
@@ -1,122 +0,0 @@
"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,
};
}
+8 -9
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
type Irrigator = {
@@ -95,7 +94,7 @@ export async function updateWaterHeadgate(
p_name: name,
p_active: active,
p_unit: unit ?? null,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
p_brand_id: adminUser.brand_id ?? null,
p_high_threshold: highThreshold ?? null,
p_low_threshold: lowThreshold ?? null,
}),
@@ -187,7 +186,7 @@ export async function updateWaterIrrigator(
p_active: active,
p_lang: lang,
p_role: role,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
p_brand_id: adminUser.brand_id ?? null,
}),
}
);
@@ -216,7 +215,7 @@ export async function resetWaterIrrigatorPin(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_user_id: irrigatorId,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
p_brand_id: adminUser.brand_id ?? null,
}),
}
);
@@ -243,7 +242,7 @@ export async function deleteWaterUser(userId: string): Promise<{ success: boolea
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_user_id: userId,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
p_brand_id: adminUser.brand_id ?? null,
}),
}
);
@@ -270,7 +269,7 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_headgate_id: headgateId,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
p_brand_id: adminUser.brand_id ?? null,
}),
}
);
@@ -357,7 +356,7 @@ export async function regenerateHeadgateToken(headgateId: string): Promise<{ suc
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: adminUser.brand_id ?? null }),
}
);
@@ -393,7 +392,7 @@ export async function updateWaterEntry(
p_measurement: measurement,
p_notes: notes,
p_unit: unit ?? null,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
p_brand_id: adminUser.brand_id ?? null,
}),
}
);
@@ -422,7 +421,7 @@ export async function deleteWaterEntry(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_entry_id: entryId,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
p_brand_id: adminUser.brand_id ?? null,
}),
}
);
+1 -4
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export async function registerWholesaleCustomer(params: {
@@ -82,9 +81,7 @@ export async function approveWholesaleRegistration(
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
assertBrandAccess(adminUser, brandId);
} catch {
if (adminUser.role !== "platform_admin" && adminUser.brand_id !== brandId) {
return { success: false, error: "Not authorized to operate on this brand" };
}
+27 -33
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type WholesaleOrder = {
@@ -130,16 +129,15 @@ export type WholesaleDashboardStats = {
* platform_admin → null (means "all brands" — passes to RPC unchanged)
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
* 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)
*
* This prevents brand_admin from seeing or modifying another brand's data
* even if they manually pass a different brandId to the action.
*/
async function resolveBrandId(
function resolveBrandId(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): Promise<string | null> {
): string | null {
if (!adminUser) return null;
if (adminUser.role === "platform_admin") {
@@ -147,15 +145,15 @@ async function resolveBrandId(
return null;
}
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
// brand_admin and store_employee are scoped to their own brand
const userBrand = adminUser.brand_id ?? null;
if (requestedBrandId && activeBrandId !== requestedBrandId) {
if (requestedBrandId && requestedBrandId !== userBrand) {
// Brand admin trying to operate on another brand's data — block it
return null; // caller should check and return unauthorized
}
return activeBrandId;
return userBrand;
}
/**
@@ -163,24 +161,23 @@ async function resolveBrandId(
* if a brand_admin tries to operate outside their brand.
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
*/
async function enforceBrandScope(
function enforceBrandScope(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): Promise<{ brandId: string | null; error?: string }> {
): { brandId: string | null; error?: string } {
if (!adminUser) return { brandId: null, error: "Not authenticated" };
if (adminUser.role === "platform_admin") {
return { brandId: null }; // unrestricted
}
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
const userBrand = adminUser.brand_id ?? null;
if (requestedBrandId && activeBrandId !== requestedBrandId) {
if (requestedBrandId && requestedBrandId !== userBrand) {
return { brandId: null, error: "Not authorized to operate on this brand" };
}
return { brandId: activeBrandId };
return { brandId: userBrand };
}
// ── Orders ──────────────────────────────────────────────────────────────────
@@ -188,7 +185,7 @@ async function enforceBrandScope(
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
const bid = resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
@@ -212,7 +209,7 @@ export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrd
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
const bid = resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
@@ -259,7 +256,7 @@ export async function markWholesaleOrderFulfilled(orderId: string, brandId?: str
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
const { brandId: resolved, error } = enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -308,7 +305,7 @@ export async function updateWholesaleOrderStatus(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
const { error } = enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -335,7 +332,7 @@ export async function deleteWholesaleOrder(orderId: string, brandId?: string): P
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
const { error } = enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -362,7 +359,7 @@ export async function deleteWholesaleCustomer(customerId: string, brandId?: stri
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
const { error } = enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -391,7 +388,7 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
const { error } = enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -420,7 +417,7 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
const bid = resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
@@ -461,7 +458,7 @@ export async function saveWholesaleCustomer(params: {
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
const { error } = enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -504,7 +501,7 @@ export async function saveWholesaleCustomer(params: {
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
const bid = resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
@@ -548,7 +545,7 @@ export async function saveWholesaleProduct(params: {
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
const { error } = enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
@@ -594,10 +591,7 @@ export async function saveWholesaleProduct(params: {
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
const bid = await getActiveBrandId(adminUser, brandId);
if (!bid && adminUser.role !== "platform_admin") {
return null;
}
const bid = brandId ?? adminUser.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
@@ -706,7 +700,7 @@ export async function recordWholesaleDeposit(
p_method: method,
p_reference: reference ?? null,
p_recorded_by: adminUser.user_id,
p_brand_id: await getActiveBrandId(adminUser),
p_brand_id: adminUser.brand_id ?? null,
}),
}
);
@@ -716,7 +710,7 @@ export async function recordWholesaleDeposit(
if (!data?.success) return { success: false, error: data?.error ?? "Failed to record deposit" };
// Fire webhook — fire-and-forget
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, (await getActiveBrandId(adminUser)) ?? undefined).catch(() => {});
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, adminUser.brand_id ?? undefined).catch(() => {});
return { success: true };
}
@@ -759,7 +753,7 @@ export async function bulkFulfillWholesaleOrders(
body: JSON.stringify({
p_order_ids: orderIds,
p_by: adminUser.user_id,
p_brand_id: await getActiveBrandId(adminUser),
p_brand_id: adminUser.brand_id ?? null,
}),
}
);
@@ -797,7 +791,7 @@ export async function bulkRecordWholesaleDeposit(
p_method: method,
p_reference: reference ?? null,
p_recorded_by: adminUser.user_id,
p_brand_id: await getActiveBrandId(adminUser),
p_brand_id: adminUser.brand_id ?? null,
}),
}
);
+2 -22
View File
@@ -1,18 +1,11 @@
import AdminSidebar from "@/components/admin/AdminSidebar";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { listBrandsForAdmin } from "@/actions/brands";
import { redirect } from "next/navigation";
import "@/styles/admin-design-system.css";
import { ToastProvider } from "@/components/admin/Toast";
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
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
return (
@@ -64,25 +57,12 @@ export default async function AdminLayout({ children }: { children: React.ReactN
redirect("/change-password");
}
// Resolve the active brand (URL > cookie > legacy > first of brand_ids)
const activeBrandId = await getActiveBrandId(adminUser);
// Fetch accessible brands for the sidebar BrandSelector. We do this
// unconditionally — `listBrandsForAdmin` is cheap and the sidebar
// decides whether to show the dropdown.
const brands = await listBrandsForAdmin();
return (
<ToastProviderWrapper>
<AdminSidebar
userRole={adminUser.role}
brandIds={adminUser.brand_ids}
activeBrandId={activeBrandId}
brands={brands}
/>
<AdminSidebar userRole={adminUser.role} />
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
{children}
</div>
</ToastProviderWrapper>
);
}
}
+6 -13
View File
@@ -1,6 +1,5 @@
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { getAdminOrders } from "@/actions/orders";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
@@ -18,19 +17,13 @@ export default async function AdminOrdersPage() {
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 brandStops = activeBrandId
? stops.filter((s) => s.brand_id === activeBrandId)
const brandStops = adminUser?.brand_id
? stops.filter((s) => s.brand_id === adminUser.brand_id)
: stops;
const brandOrders = activeBrandId
const brandOrders = adminUser?.brand_id
? orders.filter(
(o) =>
o.stops && brandStops.some((s) => s.id === o.stop_id)
@@ -48,8 +41,8 @@ export default async function AdminOrdersPage() {
.order("name")
.limit(200);
if (activeBrandId) {
prodQuery = prodQuery.eq("brand_id", activeBrandId);
if (adminUser?.brand_id) {
prodQuery = prodQuery.eq("brand_id", adminUser.brand_id);
}
const { data: prods } = await prodQuery;
@@ -87,7 +80,7 @@ export default async function AdminOrdersPage() {
initialOrders={brandOrders}
initialStops={brandStops}
initialProducts={brandProducts}
brandId={activeBrandId}
brandId={adminUser?.brand_id ?? null}
/>
</div>
</div>
+6 -6
View File
@@ -1,7 +1,6 @@
import Link from "next/link";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { getBillingOverview } from "@/actions/billing/billing-overview";
import DashboardClient from "@/components/admin/DashboardClient";
@@ -24,11 +23,12 @@ export default async function AdminPage() {
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
let brandDisplayName = "Admin";
// Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
// > first of brand_ids). For platform_admin in dev mode this is null, so we
// fall back to the first brand in the brands table to keep the dashboard's
// "Active Products" stat in sync with the billing page.
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
// For platform_admin in dev mode, adminUser.brand_id is null. To keep
// the dashboard's "Active Products" stat in sync with the billing page,
// we need to pick a brand and use the same getBillingOverview action
// the billing page does. Otherwise the dashboard falls back to default
// (0/0/0) usage values and contradicts the billing page's "Products 1/25".
let dashboardBrandId: string | null = adminUser?.brand_id ?? null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
const { data: firstBrand } = await supabase
.from("brands")
+4 -20
View File
@@ -1,9 +1,7 @@
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import ProductsClient from "@/components/admin/ProductsClient";
import { getBrands } from "@/actions/admin/users";
// Icon for page header
const PackageIcon = () => (
@@ -31,16 +29,7 @@ export default async function AdminProductsPage() {
);
}
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 ?? [];
}
const brandId = adminUser.brand_id;
let query = supabase
.from("products")
@@ -59,8 +48,8 @@ export default async function AdminProductsPage() {
.is("deleted_at", null)
.order("name");
if (brandId) {
query = query.eq("brand_id", brandId);
if (adminUser.brand_id) {
query = query.eq("brand_id", adminUser.brand_id);
}
const { data: products, error } = await query;
@@ -80,12 +69,7 @@ export default async function AdminProductsPage() {
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<ProductsClient
products={products ?? []}
brandId={brandId ?? undefined}
brands={brands}
isPlatformAdmin={isPlatformAdmin}
/>
<ProductsClient products={products ?? []} brandId={brandId ?? undefined} />
</div>
);
}
+1 -2
View File
@@ -1,6 +1,5 @@
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import ReportsDashboard from "@/components/admin/ReportsDashboard";
import { PageHeader } from "@/components/admin/design-system";
@@ -20,7 +19,7 @@ export default async function ReportsPage() {
const initialBrandId = isPlatformAdmin
? null
: await getActiveBrandId(adminUser);
: adminUser.brand_id ?? null;
return (
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
+1 -6
View File
@@ -1,6 +1,5 @@
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { getBillingOverview } from "@/actions/billing/billing-overview";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import BillingClientPage from "./BillingClientPage";
@@ -14,11 +13,7 @@ export default async function BillingPage({ params }: Props) {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
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 effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
const isPlatformAdmin = adminUser.role === "platform_admin";
let resolvedBrandId = effectiveBrandId;
@@ -4,10 +4,6 @@ import { getPaymentSettings } from "@/actions/payments";
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
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() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
+4 -2
View File
@@ -1,4 +1,4 @@
import StopsDashboardClient from "@/components/admin/stops/StopsDashboardClient";
import StopTableClient from "@/components/admin/StopTableClient";
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
@@ -90,7 +90,9 @@ export default async function AdminStopsPage() {
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<StopsDashboardClient stops={stops ?? []} />
<div className="overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
<StopTableClient stops={stops ?? []} />
</div>
</div>
</main>
);
+1 -6
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { supabase } from "@/lib/supabase";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import TaxDashboard from "@/components/admin/TaxDashboard";
@@ -16,11 +15,7 @@ export default async function TaxesPage({ params }: Props) {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
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 effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
const isPlatformAdmin = adminUser.role === "platform_admin";
let resolvedBrandId = effectiveBrandId;
+2 -4
View File
@@ -1,7 +1,5 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import WholesaleClient from "./WholesaleClient";
export default async function WholesalePage() {
@@ -13,10 +11,10 @@ export default async function WholesalePage() {
devSession === "store_employee";
if (!isDevMode) {
const { getAdminUser } = await import("@/lib/admin-permissions");
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const activeBrandId = await getActiveBrandId(adminUser);
return <WholesaleClient brandId={activeBrandId ?? ""} />;
return <WholesaleClient brandId={adminUser.brand_id ?? ""} />;
}
// Dev mode: platform_admin sees all brands, use first brand as default
-16
View File
@@ -1,16 +0,0 @@
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;
+163 -231
View File
@@ -4,36 +4,25 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useCart } from "@/context/CartContext";
import type { StopInfo } from "@/context/CartContext";
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import StripeExpressCheckout, {
type CheckoutItem as ExpressItem,
} from "@/components/storefront/StripeExpressCheckout";
type Stop = {
id: string;
city: string;
state: string;
date: string;
location: string;
brand_id: string;
};
export default function CheckoutClient() {
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 { cart, subtotal, selectedStop, setSelectedStop, clearCart, cartBrandId } = useCart();
const router = useRouter();
const [stops, setStops] = useState<StopInfo[]>([]);
// 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);
const [stops, setStops] = useState<Stop[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Stable idempotency key per checkout session — survives retries
const idempotencyKeyRef = useRef<string | null>(null);
@@ -44,7 +33,7 @@ export default function CheckoutClient() {
useEffect(() => {
if (!cartBrandId) return;
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`,
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,location,brand_id&order=date`,
{
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
@@ -58,37 +47,45 @@ export default function CheckoutClient() {
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
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");
/**
* 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 () => {
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (cart.length === 0) return;
// Guard: if selected stop brand mismatches cart brand, block checkout
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
setSelectedStop(null);
router.replace("/cart");
return;
}
if (!customerEmail.trim()) {
setHostedError("Please enter your email before continuing to checkout.");
return;
}
if (hasStopPickupItems && !selectedStop) {
setHostedError("Please select a pickup location.");
return;
setLoading(true);
setError(null);
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 };
}
}
setHostedLoading(true);
setHostedError(null);
if (hasStopPickupItems && !stopId) {
setError("Please select a pickup location.");
setLoading(false);
return;
}
const items = cart.map((item) => ({
id: item.id,
@@ -98,61 +95,36 @@ export default function CheckoutClient() {
fulfillment: item.fulfillment ?? "pickup",
}));
const siteUrl =
typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
// Build return URLs for Stripe
const siteUrl = typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
const successUrl = `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${siteUrl}/checkout?status=cancelled`;
// Store customer info for order creation on success page
if (typeof sessionStorage !== "undefined") {
sessionStorage.setItem(
"pending_checkout",
JSON.stringify({
customerName,
customerEmail,
customerPhone,
stopId: selectedStop?.id ?? null,
items: items.map((item) => ({
...item,
fulfillment: item.fulfillment as "pickup" | "ship",
})),
cartBrandId,
shippingAddress: hasShipItems
? { state: shippingState, postal_code: shippingPostal, city: shippingCity }
: undefined,
idempotencyKey: idempotencyKeyRef.current,
})
);
sessionStorage.setItem("pending_checkout", JSON.stringify({
customerName,
customerEmail,
customerPhone,
stopId,
items: items.map((item) => ({ ...item, fulfillment: item.fulfillment as "pickup" | "ship" })),
cartBrandId,
shippingAddress,
idempotencyKey: idempotencyKeyRef.current,
}));
}
const stripeResult = await createRetailStripeCheckoutSession(
items,
"",
cartBrandId ?? "unknown",
successUrl,
cancelUrl
);
// Create Stripe Checkout session first
const stripeResult = await createRetailStripeCheckoutSession(items, "", cartBrandId ?? "unknown", successUrl, cancelUrl);
if (!stripeResult.success || !stripeResult.url) {
setHostedError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
setHostedLoading(false);
setError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
setLoading(false);
return;
}
// Redirect to Stripe Checkout
window.location.href = stripeResult.url;
}, [
cart,
customerName,
customerEmail,
customerPhone,
selectedStop,
shippingState,
shippingPostal,
shippingCity,
cartBrandId,
hasShipItems,
hasStopPickupItems,
router,
setSelectedStop,
]);
}, [cart, selectedStop, cartBrandId, hasShipItems, hasStopPickupItems, router, setSelectedStop]);
if (cart.length === 0) {
return (
@@ -160,7 +132,9 @@ export default function CheckoutClient() {
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
<main className="px-6 py-12">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-stone-900">Your cart is empty</h1>
<h1 className="text-3xl font-bold text-stone-900">
Your cart is empty
</h1>
<Link
href="/"
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
@@ -173,81 +147,71 @@ 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 (
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
<main className="px-6 py-12">
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
{/* LEFT — Form + Embedded Stripe Elements */}
<div>
<h1 className="text-4xl font-bold text-slate-900">Checkout</h1>
<h1 className="text-4xl font-bold text-slate-900">
Checkout
</h1>
<p className="mt-3 text-slate-600">
Tap to pay with Apple Pay, Google Pay, or card. Your details stay secure we never see them.
Complete your order details.
</p>
{/* Error alert (shared across both payment paths) */}
{(hostedError) && (
{/* Error alert */}
{error && (
<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">
<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" />
</svg>
<span>{hostedError}</span>
<span>{error}</span>
</div>
</div>
)}
<div className="mt-8 space-y-6">
{/* Customer Information — controlled inputs feed the embedded
Stripe Elements above. The wallet (Apple Pay) will overwrite
name/email at confirm time, but we still collect them here
so the order row has them in case the wallet omits them. */}
<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">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>
{/* Loading indicator */}
{loading && (
<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">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-300 border-t-emerald-600" aria-hidden="true" />
<span className="text-sm text-emerald-700">Placing your order...</span>
</div>
)}
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
<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">
<legend className="text-xl font-bold text-slate-900">Customer Information</legend>
<div className="mt-4 space-y-4">
<div>
<label htmlFor="customer_name" className="block text-sm font-medium text-slate-700">
Full Name
Full Name <span className="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="customer_name"
name="customer_name"
autoComplete="name"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
required
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"
aria-required="true"
/>
</div>
<div>
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
Email <span className="text-red-500" aria-hidden="true">*</span>
Email <span className="text-slate-400 text-xs">(optional)</span>
</label>
<input
id="customer_email"
name="customer_email"
type="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"
placeholder="jane@example.com"
aria-required="true"
/>
</div>
@@ -260,8 +224,6 @@ export default function CheckoutClient() {
name="customer_phone"
type="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"
placeholder="(555) 555-5555"
/>
@@ -269,7 +231,7 @@ export default function CheckoutClient() {
</div>
</fieldset>
{/* Shed Pickup notice */}
{/* Shed Pickup */}
{hasShedPickupItems && (
<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>
@@ -316,11 +278,6 @@ export default function CheckoutClient() {
id="stop_id"
name="stop_id"
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"
aria-required="true"
>
@@ -344,8 +301,8 @@ export default function CheckoutClient() {
Enter your shipping details for delivery.
</p>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-3">
<div className="mt-4 space-y-4">
<div>
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
Street Address
</label>
@@ -357,97 +314,80 @@ export default function CheckoutClient() {
placeholder="123 Main St"
/>
</div>
<div>
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
City
</label>
<input
id="shipping_city"
name="shipping_city"
autoComplete="address-level2"
value={shippingCity}
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>
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
State
</label>
<input
id="shipping_state"
name="shipping_state"
autoComplete="address-level1"
maxLength={2}
value={shippingState}
onChange={(e) => setShippingState(e.target.value.toUpperCase())}
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"
placeholder="NC"
/>
</div>
<div>
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
ZIP Code
</label>
<input
id="shipping_postal_code"
name="shipping_postal_code"
autoComplete="postal-code"
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 className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
City
</label>
<input
id="shipping_city"
name="shipping_city"
autoComplete="address-level2"
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>
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
State
</label>
<input
id="shipping_state"
name="shipping_state"
autoComplete="address-level1"
maxLength={2}
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"
placeholder="NC"
/>
</div>
<div>
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
ZIP Code
</label>
<input
id="shipping_postal_code"
name="shipping_postal_code"
autoComplete="postal-code"
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>
</fieldset>
)}
{/* Embedded Stripe Elements (Express + Card) — real checkout */}
<div data-testid="stripe-express-checkout">
<StripeExpressCheckout
items={expressItems}
brandId={cartBrandId}
customerName={customerName}
customerEmail={customerEmail}
customerPhone={customerPhone}
selectedStop={selectedStop as StopInfo | null}
shippingState={shippingState}
shippingPostal={shippingPostal}
shippingCity={shippingCity}
checkoutSessionKey={idempotencyKeyRef.current ?? undefined}
onHostedCheckout={() => void handleHostedCheckout()}
/>
</div>
{/* Hosted-checkout fallback (legacy Stripe Checkout) */}
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-4">
<p className="text-xs text-slate-500 mb-2 text-center">
Prefer Stripes hosted page? Tap below.
</p>
<button
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>
{/* Submit button */}
<button
type="submit"
disabled={loading}
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"
>
{loading ? (
<>
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Redirecting to payment...
</>
) : (
<>
<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" />
</svg>
Pay Now ${subtotal.toFixed(2)}
</>
)}
</button>
</form>
</div>
{/* 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">
<h2 className="text-xl font-bold text-slate-900">Order Summary</h2>
<h2 className="text-xl font-bold text-slate-900">
Order Summary
</h2>
<div className="mt-4 space-y-3">
{cart.map((item) => (
@@ -480,20 +420,12 @@ export default function CheckoutClient() {
</div>
</div>
{/* Trust badges */}
<div className="mt-4 space-y-1.5">
<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="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>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>
{/* Secure payment badge */}
<div className="mt-4 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="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>Secured by Stripe</span>
</div>
</div>
</aside>
@@ -503,4 +435,4 @@ export default function CheckoutClient() {
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" />
</div>
);
}
}
+7 -18
View File
@@ -47,16 +47,12 @@ function formatStopDate(dateStr: string): string {
function SuccessContent() {
const searchParams = useSearchParams();
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");
// Direct access with order_id — load from sessionStorage in lazy initializer.
// searchParams values are stable, and sessionStorage is client-only, so this
// is safe in a client component.
const [order, setOrder] = useState<StoredOrder | null>(() => {
if (!orderIdParam || sessionId || paymentIntentId) return null;
if (!orderIdParam || sessionId) return null;
if (typeof window === "undefined") return null;
try {
const stored = sessionStorage.getItem(`order_${orderIdParam}`);
@@ -66,20 +62,14 @@ function SuccessContent() {
return null;
}
});
const [creating, setCreating] = useState(Boolean(sessionId || (paymentIntentId && redirectStatus === "succeeded")));
const [creating, setCreating] = useState(!!sessionId);
const [error, setError] = useState<string | null>(null);
// Create the order from the pending-checkout payload, regardless of
// whether the user paid via hosted Stripe Checkout (?session_id=...) or
// embedded Stripe Elements (?payment_intent=...). Both paths store the
// same payload in `pending_checkout` before payment is initiated.
// Stripe redirected back — create order from pending checkout data.
// Wrapped in an async IIFE so all setState calls happen inside a callback,
// not in the synchronous effect body (satisfies set-state-in-effect rule).
useEffect(() => {
if (!sessionId && !paymentIntentId) return;
if (paymentIntentId && redirectStatus && redirectStatus !== "succeeded") {
setError("Payment was not completed. Please try again.");
setCreating(false);
return;
}
if (!sessionId) return;
(async () => {
const pendingStr = sessionStorage.getItem("pending_checkout");
@@ -98,7 +88,6 @@ function SuccessContent() {
cartBrandId?: string;
shippingAddress?: { state: string; postal_code: string; city?: string };
idempotencyKey: string;
paymentIntentId?: string;
};
try {
pending = JSON.parse(pendingStr);
@@ -134,7 +123,7 @@ function SuccessContent() {
setCreating(false);
}
})();
}, [sessionId, paymentIntentId, redirectStatus]);
}, [sessionId]);
if (!order || !orderIdParam) {
return (
+2 -590
View File
@@ -1,11 +1,6 @@
@import "tailwindcss";
@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 */
--color-surface-50: #f5f5f7;
--color-surface-100: #e8e8ed;
@@ -90,7 +85,7 @@ html {
}
body {
font-family: var(--font-sans);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
background: #ffffff;
background-attachment: fixed;
color: #1a1a1a;
@@ -446,587 +441,4 @@ select:-webkit-autofill:focus {
.transition-glass {
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; }
}
+2 -23
View File
@@ -1,5 +1,4 @@
import type { Metadata, Viewport } from "next";
import { Fraunces, Manrope, Fragment_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/Providers";
import ToastNotificationContainer from "@/components/notifications/ToastNotification";
@@ -7,26 +6,6 @@ import CookieConsentBanner from "@/components/legal/CookieConsentBanner";
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 = {
width: "device-width",
initialScale: 1,
@@ -71,8 +50,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning className={`${fraunces.variable} ${manrope.variable} ${fragmentMono.variable}`}>
<body className="font-sans">
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
<ToastNotificationContainer />
<CookieConsentBanner />
-77
View File
@@ -3,7 +3,6 @@
import { useState, useEffect, useCallback, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin";
function LoginForm() {
const [email, setEmail] = useState("");
@@ -125,82 +124,6 @@ function LoginForm() {
</p>
</div>
{/* Auth.js v5 — primary sign-in: Google OAuth */}
<form action={signInWithGoogle} className="space-y-3">
<button
type="submit"
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]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
aria-label="Sign in with Google"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0 0 12 23z"
/>
<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>
</form>
{/* Dev login (only visible in development) */}
{process.env.NODE_ENV !== "production" && (
<form action={signInWithDev} className="space-y-3">
<div className="rounded-xl bg-amber-50/70 border border-amber-200/60 px-3 py-2 text-xs text-amber-900">
<strong>Dev login</strong> only available in development.
Set <code>ALLOW_DEV_LOGIN=false</code> in <code>.env.local</code> to hide.
</div>
<div className="grid grid-cols-2 gap-2">
<input
name="username"
type="text"
defaultValue="admin"
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
placeholder="Username"
aria-label="Dev username"
/>
<input
name="password"
type="password"
defaultValue="dev"
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
placeholder="Password"
aria-label="Dev password"
/>
</div>
<button
type="submit"
className="w-full rounded-xl bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
>
Dev sign in (no Google required)
</button>
</form>
)}
<div className="relative my-2">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-stone-200/70" />
</div>
<div className="relative flex justify-center text-xs uppercase tracking-wider">
<span className="bg-white/0 px-2 text-stone-400" style={{ background: "linear-gradient(to right, transparent, white 30%, white 70%, transparent)" }}>
or sign in with email
</span>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
{globalError && (
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
-124
View File
@@ -1,124 +0,0 @@
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>
);
}
@@ -1,116 +0,0 @@
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>
);
}
-105
View File
@@ -1,105 +0,0 @@
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: {
/**
* Gate /admin routes. Anything not on the public list and not signed in
* gets redirected to /login. This mirrors what the page-level checks do,
* but runs first at the edge so unauthorized requests never hit the
* server component tree.
*/
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
const isOnProtectedExample = nextUrl.pathname.startsWith(
"/protected-example"
);
if (isOnAdmin) {
if (isLoggedIn) return true;
return false; // Redirect to /login
}
if (isOnProtectedExample) {
if (isLoggedIn) return true;
return false;
}
return true;
},
/**
* 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
@@ -1,288 +0,0 @@
"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>
);
}
+183 -249
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect } from "react";
import { createStop } from "@/actions/stops/create-stop";
import GlassModal from "@/components/admin/GlassModal";
@@ -21,20 +21,12 @@ type Props = {
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) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [city, setCity] = useState("");
const [stateField, setStateField] = useState("");
const [state, setState] = useState("");
const [location, setLocation] = useState("");
const [date, setDate] = useState("");
const [time, setTime] = useState("");
@@ -44,13 +36,11 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
const [cutoffTime, setCutoffTime] = useState("");
const [status, setStatus] = useState<"draft" | "active">("draft");
const cityRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
// Reset form when modal opens with optional duplicate source
setCity(duplicateFrom?.city ?? "");
setStateField(duplicateFrom?.state ?? "");
setState(duplicateFrom?.state ?? "");
setLocation(duplicateFrom?.location ?? "");
setDate(duplicateFrom?.date ?? "");
setTime(duplicateFrom?.time ?? "");
@@ -59,328 +49,272 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
setStatus("draft");
setError(null);
// Auto-focus city field for fast data entry
requestAnimationFrame(() => cityRef.current?.focus());
}
/* eslint-enable react-hooks/set-state-in-effect */
}, [isOpen, duplicateFrom]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
setError("City, state, location, and date are required.");
return;
if (!city.trim() || !state.trim() || !location.trim() || !date) {
setError("City, state, location, and date are required.");
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");
}
setLoading(true);
try {
const result = await createStop(brandId, {
city: city.trim(),
state: stateField.trim(),
location: location.trim(),
date,
time: time || "08:00",
address: address || undefined,
zip: zip || undefined,
cutoff_time: cutoffTime || undefined,
active: status === "active",
});
if (result.success) {
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,
]
);
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
}, [brandId, city, state, location, date, time, address, zip, cutoffTime, status, onSuccess, onClose]);
const isDuplicate = Boolean(duplicateFrom);
const title = isDuplicate ? "Duplicate Stop" : "Add Stop";
const eyebrow = isDuplicate && duplicateFrom
const title = isDuplicate ? "Duplicate Stop" : "Add New Stop";
const subtitle = isDuplicate && duplicateFrom
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
: "New stop on the route";
const submitLabel = loading
? isDuplicate ? "Duplicating…" : "Creating…"
: isDuplicate
? "Duplicate Stop"
: status === "active"
? "Create & Publish"
: "Save as Draft";
: "Create a new tour stop for your route.";
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 | 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;
return (
<GlassModal
title={title}
eyebrow={eyebrow}
onClose={onClose}
maxWidth="max-w-xl"
compact
>
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
<GlassModal title={title} subtitle={subtitle} onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div
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>{error}</span>
<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>
)}
{/* Row 1 — Where: City + State */}
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
<div className="ha-field">
<label htmlFor="stop-city" className="ha-field-label">
<PinIcon />
<span>City</span>
<span className="ha-field-label-required">*</span>
</label>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
<input
ref={cityRef}
id="stop-city"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Denver"
className="ha-field-input"
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
autoComplete="address-level2"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-state" className="ha-field-label">
<span>State</span>
<span className="ha-field-label-required">*</span>
</label>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<input
id="stop-state"
type="text"
value={stateField}
onChange={(e) => setStateField(e.target.value.toUpperCase())}
value={state}
onChange={(e) => setState(e.target.value.toUpperCase())}
placeholder="CO"
maxLength={2}
className="ha-field-input ha-field-input-mono text-center"
style={{ textTransform: "uppercase" }}
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
autoComplete="address-level1"
/>
</div>
</div>
{/* Row 2 — Where at: Location (full) */}
<div className="ha-field">
<label htmlFor="stop-location" className="ha-field-label">
<span>Location / Venue</span>
<span className="ha-field-label-required">*</span>
</label>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Location</label>
<input
id="stop-location"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Whole Foods Market — Highlands"
className="ha-field-input"
placeholder="Whole Foods Market"
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>
{/* Row 3 — When: Date + Time (with small clock icon) */}
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div className="ha-field">
<label htmlFor="stop-date" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
</svg>
<span>Pickup Date</span>
<span className="ha-field-label-required">*</span>
</label>
<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">Date</label>
<input
id="stop-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="ha-field-input ha-field-input-mono"
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="ha-field">
<label htmlFor="stop-time" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" strokeLinecap="round" />
</svg>
<span>Pickup Time</span>
</label>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Pickup Time</label>
<input
id="stop-time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="ha-field-input ha-field-input-mono"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
{/* Row 4 — Address + ZIP + Cutoff in 3 columns */}
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
<div className="ha-field">
<label htmlFor="stop-address" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
<path d="M9 22V12h6v10" strokeLinejoin="round" />
</svg>
<span>Street Address</span>
</label>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Address</label>
<input
id="stop-address"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
className="ha-field-input"
autoComplete="street-address"
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="ha-field">
<label htmlFor="stop-zip" className="ha-field-label">
<span>ZIP</span>
</label>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<input
id="stop-zip"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80202"
maxLength={10}
className="ha-field-input ha-field-input-mono"
autoComplete="postal-code"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-cutoff" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" strokeLinecap="round" />
</svg>
<span>Cutoff</span>
</label>
<input
id="stop-cutoff"
type="time"
value={cutoffTime}
onChange={(e) => setCutoffTime(e.target.value)}
className="ha-field-input ha-field-input-mono"
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>
{/* Row 5 — Status: segmented control (compact, replaces two giant buttons) */}
<div className="ha-field pt-0.5">
<div className="flex items-center justify-between">
<label className="ha-field-label">
<span>Visibility</span>
</label>
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
Draft is hidden from customers
</span>
</div>
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Cutoff Time</label>
<input
type="time"
value={cutoffTime}
onChange={(e) => setCutoffTime(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}
/>
<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
type="button"
role="radio"
aria-checked={status === "draft"}
onClick={() => setStatus("draft")}
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
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="ha-segment-dot" />
<span>Save as Draft</span>
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
background: status === "draft" ? 'rgba(245, 158, 11, 0.2)' : 'rgba(0, 0, 0, 0.05)',
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
type="button"
role="radio"
aria-checked={status === "active"}
onClick={() => setStatus("active")}
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
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="ha-segment-dot" />
<span>Publish Now</span>
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
background: status === "active" ? 'rgba(16, 185, 129, 0.2)' : 'rgba(0, 0, 0, 0.05)',
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>
</div>
</div>
{/* Footer */}
<div className="ha-modal-footer">
<span className="ha-modal-footer-hint">
<kbd>Esc</kbd>
to close
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="ha-btn-ghost"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="ha-btn-primary"
>
{loading ? (
<>
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
<span>{submitLabel}</span>
</>
) : (
<>
{status === "active" ? (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
<span>{submitLabel}</span>
</>
)}
</button>
</div>
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}>
<button
type="button"
onClick={onClose}
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"
>
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>
) : (
isDuplicate ? "Duplicate Stop" : "Create Stop"
)}
</button>
</div>
</form>
</GlassModal>
@@ -404,4 +338,4 @@ export function useAddStopModal(brandId: string, duplicateFrom?: Props["duplicat
), [isOpen, close, brandId, duplicateFrom]);
return { open, close, Modal };
}
}
+1 -29
View File
@@ -5,7 +5,6 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
import BrandSelector from "./BrandSelector";
// Elegant warm sidebar design
// Colors: parchment 100 bg, soft linen text, powder petal accent
@@ -218,12 +217,9 @@ const ICON_MAP: Record<string, React.ReactNode> = {
type SidebarProps = {
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, brandIds, activeBrandId, brands }: SidebarProps) {
export default function AdminSidebar({ userRole }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
@@ -234,15 +230,9 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin"
: userRole === "multi_brand_admin" ? "Multi-Brand Manager"
: userRole === "store_employee" ? "Store Employee"
: 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) => {
if (href === "/admin") return pathname === "/admin";
return pathname.startsWith(href);
@@ -404,24 +394,6 @@ export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands
</Link>
</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 className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
<ul className="space-y-1" role="list">
-218
View File
@@ -1,218 +0,0 @@
"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
@@ -1,298 +0,0 @@
"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
@@ -1,84 +0,0 @@
"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>
);
}
+20 -66
View File
@@ -9,22 +9,9 @@ type Props = {
onClose: () => void;
children: React.ReactNode;
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",
compact = false,
eyebrow,
}: Props) {
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg" }: Props) {
// Lock body scroll
useEffect(() => {
document.body.style.overflow = "hidden";
@@ -46,36 +33,25 @@ export default function GlassModal({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 md:p-6"
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.45)",
backdropFilter: "blur(2px)",
WebkitBackdropFilter: "blur(2px)",
}}
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
{/* Modal card - solid white with shadow for high contrast */}
<div
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`}
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`}
>
{/* Accent bar — only for non-compact (compact forms have their own internal accent) */}
{!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" />
)}
{/* Subtle top border 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" />
{/* Header */}
<div
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"
}`}
<div
className="flex items-center justify-between px-4 sm:px-6 md:px-8 py-4 sm:py-6 shrink-0"
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
>
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
{titleIcon && !compact && (
<div
{titleIcon && (
<div
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)" }}
>
@@ -83,39 +59,21 @@ export default function GlassModal({
</div>
)}
<div className="min-w-0">
{compact && eyebrow && (
<p className="ha-eyebrow mb-0.5 truncate">{eyebrow}</p>
)}
<h2
className={`${
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" }}
<h2
className="text-lg sm:text-xl font-semibold text-[var(--admin-text-primary)] truncate"
style={{ letterSpacing: "-0.02em" }}
>
{title}
</h2>
{subtitle && !compact && (
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">
{subtitle}
</p>
{subtitle && (
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">{subtitle}</p>
)}
</div>
</div>
<button
onClick={onClose}
className={`flex shrink-0 items-center justify-center rounded-full transition-all duration-150 ml-2 ${
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"
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"
style={{ backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }}
>
<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" />
@@ -123,12 +81,8 @@ export default function GlassModal({
</button>
</div>
{/* Content */}
<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"
}`}
>
{/* Content - scrollable */}
<div className="relative px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8 overflow-y-auto flex-1">
{children}
</div>
</div>
-387
View File
@@ -1,387 +0,0 @@
"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
@@ -1,718 +0,0 @@
"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
);
}
+382 -209
View File
@@ -1,14 +1,13 @@
"use client";
import { useState, useCallback, useTransition, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useState, useCallback, useRef, useTransition } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { createProduct } from "@/actions/products/create-product";
import { updateProduct } from "@/actions/products/update-product";
import { deleteProduct } from "@/actions/products";
import { uploadProductImage } from "@/actions/products/upload-image";
import ProductFormModal, { type ProductFormValues } from "@/components/admin/ProductFormModal";
import GlassModal from "@/components/admin/GlassModal";
import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, useToast, Skeleton } from "@/components/admin/design-system";
type Product = {
@@ -21,8 +20,16 @@ type Product = {
image_url?: string | null;
brand_id: string;
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";
@@ -67,17 +74,7 @@ const PackageIconHeader = () => (
</svg>
);
export default function ProductsClient({
products,
brandId,
brands = [],
isPlatformAdmin = false,
}: {
products: Product[];
brandId?: string;
brands?: { id: string; name: string }[];
isPlatformAdmin?: boolean;
}) {
export default function ProductsClient({ products, brandId }: { products: Product[]; brandId?: string }) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
@@ -86,10 +83,28 @@ export default function ProductsClient({
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [showModal, setShowModal] = useState(false);
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 [deletingId, setDeletingId] = useState<string | null>(null);
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 matchesSearch =
!search ||
@@ -132,104 +147,155 @@ export default function ProductsClient({
});
}
const handleUploadImage = useCallback(
async (file: File): Promise<{ success: boolean; imageUrl?: string; error?: string }> => {
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
return { success: false, error: "Only PNG, JPEG, and WebP images are allowed." };
}
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: "Image must be under 5MB." };
}
async function handleFileSelect(file: File) {
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
return;
}
if (file.size > 5 * 1024 * 1024) {
setUploadError("Image must be under 5MB.");
return;
}
const resizedBuffer = await resizeImage(file, 1200);
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
setUploadError(null);
setUploading(true);
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
const result = await uploadProductImage(productIdForUpload, resizedFile);
if (result.success) {
return { success: true, imageUrl: result.imageUrl };
}
return { success: false, error: result.error };
},
[editingProduct]
);
const resizedBuffer = await resizeImage(file, 1200);
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
const result = await uploadProductImage(productIdForUpload, resizedFile);
setUploading(false);
if (result.success) {
setPendingImageUrl(result.imageUrl);
setImagePreview(result.imageUrl);
setFormData({ ...formData, image_url: result.imageUrl });
} else {
setUploadError(result.error ?? "Upload failed.");
}
}
const openAddModal = useCallback(() => {
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);
}, []);
const openEditModal = useCallback((product: 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);
}, []);
const closeModal = useCallback(() => {
setShowModal(false);
setEditingProduct(null);
setImagePreview(null);
setPendingImageUrl(null);
setUploadError(null);
setError(null);
}, []);
const handleModalSubmit = useCallback(
async (values: ProductFormValues): Promise<{ success: boolean; error?: string }> => {
setIsLoading(true);
try {
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 handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSaving(true);
setIsLoading(true);
// Platform admins pick a brand in the modal; everyone else is locked.
const effectiveBrandId = isPlatformAdmin ? values.brand_id : brandId;
if (!effectiveBrandId) {
return { success: false, error: "Please choose a brand for this product." };
}
const price = parseFloat(formData.price);
if (isNaN(price) || price < 0) {
setError("Please enter a valid price");
setSaving(false);
setIsLoading(false);
return;
}
let result;
if (editingProduct) {
result = await updateProduct(editingProduct.id, 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,
});
} 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,
});
}
if (!formData.name.trim()) {
setError("Product name is required");
setSaving(false);
setIsLoading(false);
return;
}
if (!result.success) {
showError("Failed to save product", result.error ?? "Please try again");
return { success: false, error: result.error };
}
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 {
try {
if (!brandId) {
setError("Brand ID is required");
setSaving(false);
setIsLoading(false);
return;
}
},
[editingProduct, brandId, isPlatformAdmin, router, showError, showSuccess]
);
let result;
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) => {
setDeletingId(productId);
@@ -333,38 +399,203 @@ export default function ProductsClient({
/>
)}
{/* Modal — Atelier des Récoltes (editorial product form) */}
<ProductFormModal
open={showModal}
mode={editingProduct ? "edit" : "add"}
onClose={closeModal}
onSubmit={handleModalSubmit}
onUploadImage={handleUploadImage}
brands={brands}
lockBrand={!isPlatformAdmin}
lockedBrandId={brandId ?? ""}
initial={
editingProduct
? {
name: editingProduct.name,
description: editingProduct.description || "",
price: String(editingProduct.price),
type: (editingProduct.type as "pickup" | "wholesale" | "subscription") ?? "pickup",
brand_id: editingProduct.brand_id,
active: editingProduct.active,
is_taxable: editingProduct.is_taxable,
available_from: editingProduct.available_from ?? null,
available_until: editingProduct.available_until ?? null,
}
: undefined
}
initialImageUrl={editingProduct?.image_url ?? null}
/>
{/* Modal */}
{showModal && (
<GlassModal
title={editingProduct ? "Edit Product" : "Add Product"}
subtitle={editingProduct ? `Editing ${editingProduct.name}` : "Create a new product"}
onClose={closeModal}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<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">
<svg className="h-5 w-5 shrink-0 text-red-500" 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>
{error}
</div>
)}
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Product name"
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>
);
}
// ── Table View ─────────────────────────────────────────────────────────────────
function TableView({
@@ -384,51 +615,8 @@ function TableView({
onDeleteCancel: () => void;
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 (
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-white">
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-white">
<table className="w-full text-sm">
<thead className="bg-stone-50">
<tr>
@@ -512,15 +700,44 @@ function TableView({
Edit
</AdminButton>
<button
ref={(el) => { buttonRefs.current[product.id] = el; }}
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"
aria-label="Product actions"
>
<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" />
</svg>
</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>
</td>
</tr>
@@ -528,50 +745,6 @@ function TableView({
)}
</tbody>
</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>
);
}
-31
View File
@@ -1,31 +0,0 @@
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
@@ -1,310 +0,0 @@
"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>
);
}
+1 -4
View File
@@ -27,11 +27,9 @@ type StopEditFormProps = {
cutoff_time?: string | null;
};
brands: Brand[];
/** Optional callback fired after a successful save. */
onSaved?: () => void;
};
export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProps) {
export default function StopEditForm({ stop, brands }: StopEditFormProps) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [saving, setSaving] = useState(false);
@@ -101,7 +99,6 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
showSuccess("Stop updated", `${city}, ${state} has been saved`);
setSaved(true);
setSaving(false);
onSaved?.();
router.refresh();
}
+170 -375
View File
@@ -1,7 +1,6 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import Image from "next/image";
import { useState } from "react";
import { logAuditEvent } from "@/actions/audit";
type Product = {
@@ -9,13 +8,12 @@ type Product = {
name: string;
type: string;
price: number;
image_url?: string | null;
};
type AssignedProduct = {
id: string;
product_id: string;
products: { name: string; type: string; price: number; image_url?: string | null } | null;
products: { name: string; type: string; price: number } | null;
};
type StopProductAssignmentProps = {
@@ -25,20 +23,6 @@ type StopProductAssignmentProps = {
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({
stopId,
allProducts,
@@ -46,398 +30,209 @@ export default function StopProductAssignment({
callerUid,
}: StopProductAssignmentProps) {
const [products, setProducts] = useState(assignedProducts);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [pendingId, setPendingId] = useState<string | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
const [selected, setSelected] = useState("");
const [assigning, setAssigning] = useState(false);
const [removing, setRemoving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const assignedIds = useMemo(
() => new Set(products.map((p) => p.product_id)),
[products]
);
const assignedIds = new Set(products.map((p) => p.product_id));
const availableProducts = allProducts.filter((p) => !assignedIds.has(p.id));
// Counters for the filter chips
const counts = useMemo(() => {
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]);
async function handleAssign(e: React.FormEvent) {
e.preventDefault();
if (!selected) return;
// 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);
setAssigning(true);
setError(null);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
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 assign: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
setPendingId(null);
return;
// First run diagnostic to understand the actual state
const diagRes = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/debug_stop_product_assignment`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
p_product_id: selected,
p_caller_uid: callerUid,
}),
}
);
const diag = await diagRes.json();
// Optimistic insert: build the entry from the product we already have
// locally, keyed by the row id returned from the RPC. Use functional
// setState so concurrent calls compose correctly.
setProducts((prev) => {
if (prev.some((p) => p.product_id === productId)) return prev;
return [
...prev,
{
id: data.id,
product_id: productId,
products: {
name: product.name,
type: product.type,
price: product.price,
image_url: product.image_url ?? null,
},
},
];
});
setPendingId(null);
// Now attempt the actual assignment
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
p_product_id: selected,
p_caller_uid: callerUid,
}),
}
);
logAuditEvent({
table_name: "product_stops",
record_id: data.id,
action: "INSERT",
old_data: null,
new_data: { stop_id: stopId, product_id: productId },
brand_id: null,
});
} catch {
setError("Network error while assigning product.");
setPendingId(null);
const data = await res.json();
if (!res.ok || data.success === false) {
const diagInfo = diag && typeof diag === 'object' && 'reason' in diag
? `[${(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})`
: '';
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
const fullError = diagInfo
? `Failed to assign: ${errMsg} ${diagInfo}${errDetail ? ' | ' + errDetail : ''}`
: `Failed to assign: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`;
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 remove(productId: string) {
if (removingId) return;
async function handleRemove(productId: string) {
const entry = products.find((p) => p.product_id === productId);
if (!entry) return;
setRemovingId(productId);
setRemoving(productId);
setError(null);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
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 res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
p_product_id: productId,
p_caller_uid: callerUid,
}),
}
);
setProducts((prev) => prev.filter((p) => p.product_id !== productId));
setRemovingId(null);
const data = await res.json();
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,
});
} catch {
setError("Network error while removing product.");
setRemovingId(null);
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 : ''}`);
setRemoving(null);
return;
}
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 (
<div className="ha-picker">
<div className="space-y-6">
{error && (
<div
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 className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{/* Header: editorial title + search */}
<div className="ha-picker-header">
<div className="ha-picker-title">
<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."}
{products.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium text-zinc-300">
Assigned Products ({products.length})
</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 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>
<p className="text-sm text-zinc-500">No products assigned yet.</p>
)}
{/* Summary footer when assigned items exist */}
{hasAssigned && (
<div className="ha-picker-summary">
<span className="ha-picker-summary-left">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>
<span className="ha-picker-summary-count">{products.length}</span>{" "}
{products.length === 1 ? "product is" : "products are"} live at this stop
</span>
</span>
<button
type="button"
onClick={clearAll}
disabled={!!removingId}
className="ha-picker-summary-clear"
{availableProducts.length > 0 && (
<form onSubmit={handleAssign} className="flex gap-3">
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
>
Remove all
<option value="">Select a product...</option>
{availableProducts.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.type})
</option>
))}
</select>
<button
type="submit"
disabled={!selected || assigning}
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
>
{assigning ? "..." : "Assign"}
</button>
</div>
</form>
)}
</div>
);
}
}
+159 -330
View File
@@ -1,21 +1,11 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import React, { useState, useTransition, useEffect, useMemo } from "react";
import React, { useState, useTransition, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop } from "@/actions/stops";
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";
import { AdminSearchInput, AdminFilterTabs, AdminButton, AdminIconButton, useToast, Skeleton } from "@/components/admin/design-system";
type Stop = {
id: string;
@@ -28,27 +18,14 @@ type Stop = {
deleted_at?: string | null;
brand_id: string;
status?: string;
address?: string | null;
brands: { name: string } | { name: string }[] | null;
brands: { name: string } | { name: string }[];
};
type Props = {
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;
};
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) {
export default function StopTableClient({ stops }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
@@ -59,66 +36,57 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
const [isLoading, setIsLoading] = useState(false);
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
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;
// Simulate loading when filters change
useEffect(() => {
setIsLoading(true);
const timer = setTimeout(() => setIsLoading(false), 220);
const timer = setTimeout(() => setIsLoading(false), 300);
return () => clearTimeout(timer);
}, [page, statusFilter, search]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return stops.filter((s) => {
const matchesSearch =
!q ||
s.city.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q);
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && s.status !== "draft") ||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
(statusFilter === "draft" && s.status === "draft");
return matchesSearch && matchesStatus;
});
}, [stops, search, statusFilter]);
const filtered = stops.filter((s) => {
const matchesSearch =
!search ||
s.city.toLowerCase().includes(search.toLowerCase()) ||
s.location.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && s.status !== "draft") ||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
(statusFilter === "draft" && s.status === "draft");
return matchesSearch && matchesStatus;
});
const statusCounts = useMemo(
() => ({
all: stops.length,
active: stops.filter((s) => s.active && s.status !== "draft").length,
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
draft: stops.filter((s) => s.status === "draft").length,
}),
[stops]
);
const statusCounts = {
all: stops.length,
active: stops.filter(s => s.active && s.status !== "draft").length,
inactive: stops.filter(s => !s.active && s.status !== "draft").length,
draft: stops.filter(s => s.status === "draft").length,
};
const tabs = useMemo(
() => [
{ value: "all", label: "All", count: statusCounts.all },
{ value: "active", label: "Active", count: statusCounts.active },
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
{ value: "draft", label: "Draft", count: statusCounts.draft },
],
[statusCounts]
);
const tabs = [
{ value: "all", label: "All", count: statusCounts.all },
{ value: "active", label: "Active", count: statusCounts.active },
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
{ value: "draft", label: "Draft", count: statusCounts.draft },
];
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const totalPages = Math.ceil(filtered.length / 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 = () => {
if (selectedStops.size === paginatedStops.length) {
setSelectedStops(new Set());
} else {
setSelectedStops(new Set(paginatedStops.map((s) => s.id)));
setSelectedStops(new Set(paginatedStops.map(s => s.id)));
}
};
const toggleStopSelection = (stopId: string) => {
setSelectedStops((prev) => {
setSelectedStops(prev => {
const next = new Set(prev);
if (next.has(stopId)) {
next.delete(stopId);
@@ -131,11 +99,13 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
async function handleBulkPublish() {
if (selectedStops.size === 0) return;
setBulkPublishing(true);
let successCount = 0;
let failCount = 0;
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") {
const result = await publishStop(stopId, stop.brand_id);
if (result.success) {
@@ -145,125 +115,83 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
}
}
}
setBulkPublishing(false);
setSelectedStops(new Set());
if (failCount === 0) {
showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
showSuccess(`${successCount} stop${successCount !== 1 ? 's' : ''} published`);
} else {
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
}
startTransition(() => router.refresh());
}
function handleDeleted() {
setDeleteError(null);
startTransition(() => router.refresh());
}
function refresh() {
startTransition(() => router.refresh());
startTransition(() => {
router.refresh();
});
}
return (
<>
{/* Filter bar */}
{!hideInternalFilterBar && (
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)]">
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
<AdminSearchInput
placeholder="Search by city or venue…"
placeholder="Search stops..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
onClear={() => {
setSearch("");
setPage(0);
}}
showClear
className="flex-1 min-w-[180px] max-w-[280px]"
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
onClear={() => { setSearch(""); setPage(0); }}
showClear={true}
className="flex-1 min-w-48 max-w-64"
/>
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => {
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
setPage(0);
}}
onTabChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(0); }}
tabs={tabs}
size="sm"
showCounts
showCounts={true}
/>
<span className="text-xs text-[var(--admin-text-muted)] ml-auto tabular-nums">
{filtered.length} stop{filtered.length !== 1 ? "s" : ""}
</span>
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">{filtered.length} stops</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))}
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>
<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 tabular-nums">
{page + 1}/{totalPages}
</span>
<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))}
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>
<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="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>
)}
{/* Bulk actions bar */}
{selectedStops.size > 0 && (
<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="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="flex items-center gap-3">
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
<span className="text-sm font-semibold text-[var(--admin-accent-text)]">
{selectedStops.size} stop{selectedStops.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={() => setSelectedStops(new Set())}
className="text-xs text-stone-500 hover:text-stone-700"
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]"
>
Clear
</button>
@@ -281,7 +209,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
{/* Delete error */}
{deleteError && (
<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">
<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
@@ -291,59 +219,45 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="bg-[var(--admin-bg-subtle)]/40 text-[var(--admin-text-muted)] text-xs uppercase tracking-wide">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="w-10 px-4 py-3">
<th className="w-10 px-5 py-4">
<input
type="checkbox"
checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0}
onChange={toggleSelectAll}
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
aria-label="Select all"
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
/>
</th>
<th className="px-4 py-3 font-semibold">When</th>
<th className="px-4 py-3 font-semibold">Where</th>
<th className="px-4 py-3 font-semibold">Venue</th>
<th className="px-4 py-3 font-semibold">Status</th>
<th className="w-12 px-4 py-3" />
<th className="px-5 py-4 font-semibold">City</th>
<th className="px-5 py-4 font-semibold">Location</th>
<th className="px-5 py-4 font-semibold">Date</th>
<th className="px-5 py-4 font-semibold">Time</th>
<th className="px-5 py-4 font-semibold">Brand</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: 8 }).map((_, i) => (
<tr key={i}>
<td className="px-4 py-3.5"><Skeleton variant="rect" className="h-4 w-4" /></td>
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-24 h-4" /></td>
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-40 h-4" /></td>
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-16 h-5 rounded-full" /></td>
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-6 h-6" /></td>
<tr key={i} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4"><Skeleton variant="rect" className="h-5 w-5" /></td>
<td className="px-5 py-4"><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-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-20 h-4" /></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-4 py-14 text-center">
<div className="flex flex-col items-center gap-3 text-[var(--admin-text-muted)]">
<div className="h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
<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 colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
{search || statusFilter !== "all"
? "No stops match your search."
: "No stops found. Create one to get started."}
</td>
</tr>
) : (
@@ -354,101 +268,52 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
isSelected={selectedStops.has(stop.id)}
onToggleSelect={(e) => {
e.stopPropagation();
toggleStopSelection(stop.id);
}}
onOpen={() => setOpenStopId(stop.id)}
onToggleSelect={() => toggleStopSelection(stop.id)}
/>
))
)}
</tbody>
</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({
stop,
onDeleted,
onDeleteError,
isSelected,
onToggleSelect,
onOpen,
}: {
stop: Stop;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
isSelected: boolean;
onToggleSelect: (e: React.MouseEvent) => void;
onOpen: () => void;
onToggleSelect: () => void;
}) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
const { date, weekday } = formatDate(stop.date);
const time = formatTime(stop.time);
async function handlePublish() {
setPublishingId(stop.id);
const result = await publishStop(stop.id, stop.brand_id);
async function handlePublish(stopId: string) {
setPublishingId(stopId);
setOpenMenu(null);
const result = await publishStop(stopId, stop.brand_id);
setPublishingId(null);
if (result.success) {
showSuccess("Stop published", "The stop is now visible on the storefront");
onDeleted();
startTransition(() => router.refresh());
} else {
showError("Failed to publish", result.error ?? "Please try again");
}
}
async function handleDelete() {
setDeletingId(stop.id);
async function handleDelete(stopId: string) {
setDeletingId(stopId);
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
{
@@ -457,7 +322,7 @@ function StopRowBase({
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stop.id, p_brand_id: stop.brand_id }),
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: stop.brand_id }),
}
);
const data = await res.json();
@@ -473,88 +338,69 @@ function StopRowBase({
}
return (
<tr
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()}>
<tr className={`hover:bg-[var(--admin-bg-subtle)] transition-colors relative ${isSelected ? 'bg-[var(--admin-accent-light)]/30' : ''}`}>
<td className="px-5 py-4">
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
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}`}
onChange={onToggleSelect}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
/>
</td>
<td className="px-4 py-3.5">
<div className="flex flex-col leading-tight">
<span className="font-semibold text-[var(--admin-text-primary)] tabular-nums">{date}</span>
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">
{weekday} {time && `· ${time}`}
</span>
</div>
<td className="px-5 py-4">
<Link
href={`/admin/stops/${stop.id}`}
className="font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] transition-colors"
>
{stop.city}, {stop.state}
</Link>
</td>
<td className="px-4 py-3.5">
<div className="flex flex-col leading-tight">
<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>
</div>
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">{stop.location}</td>
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.date}</td>
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.time}</td>
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">
{Array.isArray(stop.brands)
? stop.brands[0]?.name
: stop.brands?.name}
</td>
<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">
<td className="px-5 py-4">
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ${
className={`rounded-full px-3 py-1 text-xs font-medium ${
stop.status === "draft"
? "bg-amber-50 text-amber-700 border border-amber-200"
? "bg-amber-100 text-amber-700 border border-amber-200"
: stop.active
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-500 border border-stone-200"
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
}`}
>
<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"}
</span>
</td>
<td className="px-4 py-3.5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="relative inline-flex items-center justify-end gap-1">
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<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
variant="ghost"
size="sm"
label="More options"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
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">
<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>
</AdminIconButton>
@@ -562,42 +408,36 @@ function StopRowBase({
<>
<div
className="fixed inset-0 z-10"
onClick={(e) => {
e.stopPropagation();
setOpenMenu(null);
setConfirmDelete(null);
}}
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
{stop.status === "draft" && (
<button
onClick={(e) => {
e.stopPropagation();
handlePublish();
}}
<AdminButton
variant="ghost"
size="sm"
fullWidth
onClick={() => handlePublish(stop.id)}
disabled={publishingId === stop.id}
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"
className="!justify-start !text-[var(--admin-accent)] hover:!bg-[var(--admin-accent-light)]"
>
{publishingId === stop.id ? "Publishing" : "Publish"}
</button>
{publishingId === stop.id ? "Publishing..." : "Publish"}
</AdminButton>
)}
<Link
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
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"
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"
>
Duplicate
</Link>
<button
onClick={(e) => {
e.stopPropagation();
setOpenMenu(null);
setConfirmDelete(stop.id);
}}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
</a>
<AdminButton
variant="ghost"
size="sm"
fullWidth
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
className="!justify-start !text-[var(--admin-danger)] hover:!bg-[var(--admin-danger)]/10"
>
Delete
</button>
</AdminButton>
</div>
</>
)}
@@ -606,11 +446,7 @@ function StopRowBase({
<>
<div
className="fixed inset-0 z-30"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(null);
setOpenMenu(null);
}}
onClick={() => { 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">
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
@@ -619,29 +455,22 @@ function StopRowBase({
<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.
</p>
<div className="mt-4 flex gap-2 justify-end">
<div className="mt-4 flex gap-2">
<AdminButton
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(null);
setOpenMenu(null);
}}
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
>
Cancel
</AdminButton>
<AdminButton
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
onClick={() => handleDelete(stop.id)}
disabled={deletingId === stop.id}
isLoading={deletingId === stop.id}
>
{deletingId === stop.id ? "Deleting…" : "Delete"}
{deletingId === stop.id ? "..." : "Delete"}
</AdminButton>
</div>
</div>
@@ -653,4 +482,4 @@ function StopRowBase({
);
}
const StopRow = React.memo(StopRowBase);
const StopRow = React.memo(StopRowBase);
@@ -1,754 +0,0 @@
"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>
</>
);
}
+2 -6
View File
@@ -8,17 +8,13 @@ import { AdminButton } from "@/components/admin/design-system";
type Props = {
brandId: string;
/** Hide on the Locations tab — actions belong to the Stops tab only. */
tab?: "stops" | "locations";
};
export default function StopsHeaderActions({ brandId, tab = "stops" }: Props) {
export default function StopsHeaderActions({ brandId }: Props) {
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const router = useRouter();
if (tab !== "stops") return null;
function handleImportComplete(count: number) {
router.refresh();
}
@@ -70,4 +66,4 @@ export default function StopsHeaderActions({ brandId, tab = "stops" }: Props) {
/>
</>
);
}
}
-107
View File
@@ -1,107 +0,0 @@
"use client";
import Link from "next/link";
type Props = {
active: "stops" | "locations";
stopCount: number;
locationCount: number;
stopsSublabel?: string; // e.g. "269 · 7 cities"
locationsSublabel?: string; // e.g. "41 · 8 cities"
};
const StopIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
const PinIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path 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 d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export default function StopsLocationsTabs({
active,
stopCount,
locationCount,
stopsSublabel,
locationsSublabel,
}: Props) {
const tabs = [
{
value: "stops" as const,
label: "Stops",
count: stopCount,
sublabel: stopsSublabel,
icon: <StopIcon />,
href: "/admin/stops",
},
{
value: "locations" as const,
label: "Locations",
count: locationCount,
sublabel: locationsSublabel,
icon: <PinIcon />,
href: "/admin/stops?tab=locations",
},
];
return (
<div
className="flex flex-wrap items-stretch gap-1.5 px-3 py-2.5 border-b border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]"
role="tablist"
aria-label="Stops and Locations tabs"
>
{tabs.map((t) => {
const isActive = t.value === active;
return (
<Link
key={t.value}
href={t.href}
role="tab"
aria-selected={isActive}
className={`
group relative flex items-center gap-2.5 rounded-xl px-3.5 py-2
text-sm font-semibold transition-all duration-200
${isActive
? "bg-white text-emerald-700 border border-emerald-200 shadow-sm"
: "text-stone-600 border border-transparent hover:text-emerald-700 hover:bg-emerald-50/40"
}
`}
>
<span
className={`flex-shrink-0 transition-colors ${isActive ? "text-emerald-600" : "text-stone-400 group-hover:text-emerald-500"}`}
aria-hidden
>
{t.icon}
</span>
<span>{t.label}</span>
<span
className={`
inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5
text-[11px] font-bold tabular-nums
${isActive
? "bg-emerald-600 text-white"
: "bg-stone-200/70 text-stone-600 group-hover:bg-emerald-100 group-hover:text-emerald-700"
}
`}
>
{t.count}
</span>
{t.sublabel && (
<span
className={`hidden sm:inline text-[11px] font-medium tabular-nums ${isActive ? "text-stone-500" : "text-stone-400"}`}
>
· {t.sublabel}
</span>
)}
</Link>
);
})}
</div>
);
}
-133
View File
@@ -1,133 +0,0 @@
"use client";
import { useState, useMemo } from "react";
import StopTableClient from "@/components/admin/StopTableClient";
import StopsCalendarClient from "@/components/admin/StopsCalendarClient";
import { AdminSearchInput, AdminFilterTabs } from "@/components/admin/design-system";
export type StopStatusFilter = "all" | "active" | "inactive" | "draft";
export type StopsViewMode = "calendar" | "table";
export type StopForView = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
deleted_at?: string | null;
brand_id: string;
status?: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
brands: { name: string } | { name: string }[];
};
type Props = {
stops: StopForView[];
};
export default function StopsViewClient({ stops }: Props) {
const [view, setView] = useState<StopsViewMode>("calendar");
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StopStatusFilter>("all");
// Apply shared filter so both views agree on what's visible
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return stops.filter((s) => {
const matchesSearch =
!q ||
s.city.toLowerCase().includes(q) ||
s.state.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q);
const isDraft = s.status === "draft";
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && !isDraft) ||
(statusFilter === "inactive" && !s.active && !isDraft) ||
(statusFilter === "draft" && isDraft);
return matchesSearch && matchesStatus;
});
}, [stops, search, statusFilter]);
const counts = useMemo(
() => ({
all: stops.length,
active: stops.filter((s) => s.active && 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 = [
{ value: "all", label: "All", count: counts.all },
{ value: "active", label: "Active", count: counts.active },
{ value: "inactive", label: "Inactive", count: counts.inactive },
{ value: "draft", label: "Draft", count: counts.draft },
];
return (
<>
{/* Filter / search / view-toggle bar — shared across both views */}
<div className="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center">
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(v) => setStatusFilter(v as StopStatusFilter)}
tabs={tabs}
size="sm"
showCounts
/>
<AdminSearchInput
placeholder="Search city, state, or venue…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch("")}
showClear
className="flex-1 min-w-48 max-w-72"
/>
<span className="text-xs text-[var(--admin-text-muted)]">
{filtered.length} {filtered.length === 1 ? "stop" : "stops"}
</span>
<div className="sm:ml-auto">
<div className="ha-viewtoggle" role="tablist" aria-label="Stops view mode">
<button
type="button"
role="tab"
aria-selected={view === "calendar"}
onClick={() => setView("calendar")}
className={`ha-viewtoggle-btn ${view === "calendar" ? "ha-viewtoggle-btn--active" : ""}`}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
</svg>
Calendar
</button>
<button
type="button"
role="tab"
aria-selected={view === "table"}
onClick={() => setView("table")}
className={`ha-viewtoggle-btn ${view === "table" ? "ha-viewtoggle-btn--active" : ""}`}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
</svg>
Table
</button>
</div>
</div>
</div>
{view === "calendar" ? (
<StopsCalendarClient stops={filtered} />
) : (
<StopTableClient stops={filtered} hideInternalFilterBar />
)}
</>
);
}
-30
View File
@@ -1,30 +0,0 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
type Props = {
tabKey: string;
children: React.ReactNode;
};
/**
* Fades the content of a tabbed panel when the active tab changes.
* The tab key drives the animation, so switching tabs triggers a quick
* fade-in of the new content. No motion when the tab key is stable
* (i.e. on first render of a given tab).
*/
export function TabSwitcher({ tabKey, children }: Props) {
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={tabKey}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
>
{children}
</motion.div>
</AnimatePresence>
);
}
@@ -1,432 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
type Stop,
type StopStatus,
formatMonthYear,
formatTime12,
getStopDate,
getStopStatus,
isSameDay,
} from "./types";
type Props = {
stops: Stop[];
};
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const STATUS_STYLES: Record<StopStatus, { bg: string; text: string; dot: string; border: string }> = {
active: {
bg: "bg-[var(--admin-accent-light)]",
text: "text-[var(--admin-accent-text)]",
dot: "bg-[var(--admin-accent-dot)]",
border: "border-[var(--admin-accent)]/40",
},
draft: {
bg: "bg-amber-50",
text: "text-amber-800",
dot: "bg-amber-500",
border: "border-amber-300",
},
inactive: {
bg: "bg-stone-100",
text: "text-stone-500",
dot: "bg-stone-400",
border: "border-stone-300",
},
};
function buildMonthGrid(viewYear: number, viewMonth: number) {
// Sunday-first 6-row grid
const first = new Date(viewYear, viewMonth, 1);
const startWeekday = first.getDay();
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const cells: { date: Date; inMonth: boolean }[] = [];
// Leading days from previous month
for (let i = startWeekday - 1; i >= 0; i--) {
const d = new Date(viewYear, viewMonth, -i);
cells.push({ date: d, inMonth: false });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ date: new Date(viewYear, viewMonth, d), inMonth: true });
}
// Trailing days to fill 6 rows = 42 cells
while (cells.length < 42) {
const last = cells[cells.length - 1].date;
const next = new Date(last);
next.setDate(last.getDate() + 1);
cells.push({ date: next, inMonth: false });
}
return cells;
}
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}`;
}
export default function StopsCalendar({ stops }: Props) {
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
const [selectedDate, setSelectedDate] = useState<string | null>(ymd(today));
const cells = useMemo(() => buildMonthGrid(view.year, view.month), [view]);
const stopsByDate = useMemo(() => {
const map = new Map<string, Stop[]>();
for (const s of stops) {
const d = getStopDate(s);
if (!d) continue;
const k = ymd(d);
const arr = map.get(k) ?? [];
arr.push(s);
map.set(k, arr);
}
// sort each bucket by time
for (const arr of map.values()) {
arr.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
}
return map;
}, [stops]);
const visibleStopsByDate = useMemo(() => {
const map = new Map<string, Stop[]>();
for (const [k, arr] of stopsByDate.entries()) {
map.set(
k,
arr.filter((s) => getStopStatus(s) !== "inactive" || arr.length <= 6)
);
}
return map;
}, [stopsByDate]);
const monthStopsCount = useMemo(() => {
let count = 0;
for (const cell of cells) {
if (!cell.inMonth) continue;
count += stopsByDate.get(ymd(cell.date))?.length ?? 0;
}
return count;
}, [cells, stopsByDate]);
const upcomingWeek = useMemo(() => {
const end = new Date(today);
end.setDate(end.getDate() + 7);
let count = 0;
for (const s of stops) {
const d = getStopDate(s);
if (!d) continue;
if (d >= today && d < end && getStopStatus(s) !== "inactive") count++;
}
return count;
}, [stops, today]);
const goPrev = () => {
setView((v) => {
const m = v.month - 1;
if (m < 0) return { year: v.year - 1, month: 11 };
return { ...v, month: m };
});
setSelectedDate(null);
};
const goNext = () => {
setView((v) => {
const m = v.month + 1;
if (m > 11) return { year: v.year + 1, month: 0 };
return { ...v, month: m };
});
setSelectedDate(null);
};
const goToday = () => {
setView({ year: today.getFullYear(), month: today.getMonth() });
setSelectedDate(ymd(today));
};
const monthLabel = formatMonthYear(new Date(view.year, view.month, 1));
const isCurrentMonth = view.year === today.getFullYear() && view.month === today.getMonth();
const selectedDayStops = selectedDate ? stopsByDate.get(selectedDate) ?? [] : [];
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-6">
{/* Calendar card */}
<section
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm"
aria-label="Stops calendar"
>
{/* Decorative paper texture */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.4]"
style={{
backgroundImage:
"radial-gradient(circle at 20% 10%, rgba(34,197,94,0.04) 0%, transparent 40%), radial-gradient(circle at 80% 90%, rgba(217,119,6,0.04) 0%, transparent 40%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.035]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.5 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
}}
/>
{/* Header */}
<header className="relative flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-5">
<div className="flex items-baseline gap-4">
<h2 className="font-display text-3xl font-medium text-[var(--admin-text-primary)] tracking-tight">
{monthLabel.split(" ")[0]}
<span className="text-[var(--admin-accent)] italic font-light">
{" "}
{monthLabel.split(" ")[1]}
</span>
</h2>
<span className="hidden sm:inline-block font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
{monthStopsCount} stop{monthStopsCount === 1 ? "" : "s"} scheduled
</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={goPrev}
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
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
onClick={goToday}
disabled={isCurrentMonth}
className="h-8 px-3 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Today
</button>
<button
onClick={goNext}
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
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>
</header>
{/* Weekday header */}
<div className="relative grid grid-cols-7 border-b border-[var(--admin-border)]">
{WEEKDAY_LABELS.map((d, i) => (
<div
key={d}
className={`px-2 py-2.5 text-center font-mono text-[10px] uppercase tracking-[0.2em] ${
i === 0 || i === 6 ? "text-[var(--admin-accent)]" : "text-[var(--admin-text-muted)]"
}`}
>
{d}
</div>
))}
</div>
{/* Grid */}
<div className="relative grid grid-cols-7 grid-rows-6">
{cells.map((cell, idx) => {
const k = ymd(cell.date);
const dayStops = stopsByDate.get(k) ?? [];
const visibleStops = visibleStopsByDate.get(k) ?? [];
const isToday = isSameDay(cell.date, today);
const isSelected = k === selectedDate;
const isWeekend = cell.date.getDay() === 0 || cell.date.getDay() === 6;
const overflow = dayStops.length - visibleStops.length;
return (
<button
key={idx}
type="button"
onClick={() => setSelectedDate(k)}
className={`
group relative flex min-h-[110px] flex-col items-stretch gap-1.5 border-b border-r border-[var(--admin-border-light)] p-2 text-left transition-colors
${!cell.inMonth ? "bg-[var(--admin-bg-subtle)]/40" : "bg-white"}
${isWeekend && cell.inMonth ? "bg-[var(--admin-bg-subtle)]/30" : ""}
${isSelected ? "!bg-[var(--admin-accent-light)] ring-2 ring-inset ring-[var(--admin-accent)]" : "hover:bg-[var(--admin-accent-light)]/40"}
`}
>
{/* Date number row */}
<div className="flex items-center justify-between">
<span
className={`
inline-flex h-6 min-w-[1.5rem] items-center justify-center rounded-md px-1.5 font-mono text-[11px] font-semibold
${isToday
? "bg-gradient-to-br from-amber-300 to-orange-400 text-white shadow-sm"
: !cell.inMonth
? "text-[var(--admin-text-muted)]/50"
: isSelected
? "text-[var(--admin-accent-text)]"
: "text-[var(--admin-text-primary)]"
}
`}
>
{cell.date.getDate()}
</span>
{dayStops.length > 0 && cell.inMonth && (
<span className="font-mono text-[9px] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
{dayStops.length}
</span>
)}
</div>
{/* Stop chips */}
<div className="flex flex-col gap-0.5 overflow-hidden">
{visibleStops.slice(0, 3).map((s) => {
const status = getStopStatus(s);
const st = STATUS_STYLES[status];
return (
<div
key={s.id}
className={`flex items-center gap-1 rounded ${st.bg} ${st.border} border px-1.5 py-0.5 text-[10px] font-medium leading-tight ${st.text}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${st.dot}`} aria-hidden />
<span className="truncate">
{s.time && (
<span className="font-mono opacity-70 mr-0.5">{formatTime12(s.time).replace(" ", "").toLowerCase()}</span>
)}
{s.city}
</span>
</div>
);
})}
{overflow > 0 && (
<div className="px-1.5 text-[10px] font-mono font-semibold text-[var(--admin-accent-text)]">
+{overflow} more
</div>
)}
</div>
</button>
);
})}
</div>
{/* Legend */}
<footer className="relative flex flex-wrap items-center gap-4 border-t border-[var(--admin-border)] px-6 py-3">
{(["active", "draft", "inactive"] as StopStatus[]).map((s) => {
const st = STATUS_STYLES[s];
return (
<div key={s} className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
<span className={`h-2 w-2 rounded-full ${st.dot}`} aria-hidden />
<span>{s}</span>
</div>
);
})}
<div className="ml-auto flex items-center gap-3 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
<span>
<span className="font-display text-base font-semibold text-[var(--admin-accent)]">
{upcomingWeek}
</span>{" "}
upcoming in 7d
</span>
</div>
</footer>
</section>
{/* Day detail panel */}
<aside className="rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
<div className="border-b border-[var(--admin-border)] px-5 py-4">
<div className="flex items-baseline gap-2">
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
{selectedDate
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", { weekday: "long" })
: "Pick a day"}
</h3>
</div>
<p className="mt-0.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{selectedDate
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
: "Select a date on the calendar"}
</p>
</div>
<div className="max-h-[640px] overflow-y-auto px-2 py-2">
{!selectedDate ? (
<div className="px-4 py-10 text-center font-mono text-[11px] uppercase tracking-wider text-[var(--admin-text-muted)]">
No day selected
</div>
) : selectedDayStops.length === 0 ? (
<div className="px-4 py-10 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops</p>
<p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
Free day on the route
</p>
</div>
) : (
<ul className="space-y-1">
{selectedDayStops.map((s) => {
const status = getStopStatus(s);
const st = STATUS_STYLES[status];
return (
<li key={s.id}>
<Link
href={`/admin/stops/${s.id}`}
className="group block rounded-xl border border-transparent px-3 py-3 transition-all hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
>
<div className="flex items-start gap-3">
<div
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${st.bg} ${st.border} border`}
>
<span className="font-mono text-[10px] font-bold text-[var(--admin-text-primary)]">
{formatTime12(s.time).split(" ")[0]}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate font-display text-base font-medium text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors">
{s.city}, {s.state}
</p>
<span className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{formatTime12(s.time).split(" ")[1]}
</span>
</div>
<p className="truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</p>
<div className="mt-1.5 flex items-center gap-2">
<span
className={`inline-flex items-center gap-1 rounded-full ${st.bg} ${st.text} px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider ${st.border} border`}
>
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} aria-hidden />
{status}
</span>
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name ? (
<span className="truncate font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name}
</span>
) : null}
</div>
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</aside>
</div>
);
}
@@ -1,184 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { type Stop, type StopView, getStopStatus } from "./types";
import StopsCalendar from "./StopsCalendar";
import StopsLocations from "./StopsLocations";
import StopsList from "./StopsList";
type Props = {
stops: Stop[];
};
const TABS: { value: StopView; label: string; hint: string }[] = [
{ value: "calendar", label: "Calendar", hint: "Month at a glance" },
{ value: "locations", label: "Locations", hint: "Stops grouped by city" },
{ value: "list", label: "List", hint: "All stops in order" },
];
export default function StopsDashboardClient({ stops }: Props) {
const [view, setView] = useState<StopView>("calendar");
const stats = useMemo(() => {
const total = stops.length;
const active = stops.filter((s) => getStopStatus(s) === "active").length;
const draft = stops.filter((s) => getStopStatus(s) === "draft").length;
const cities = new Set(stops.map((s) => s.city.trim().toLowerCase())).size;
const venues = new Set(
stops.map((s) => `${s.city}|${s.state}|${s.location}`.toLowerCase())
).size;
const today = new Date();
today.setHours(0, 0, 0, 0);
const in7 = new Date(today);
in7.setDate(today.getDate() + 7);
const upcoming = stops.filter((s) => {
if (!s.date) return false;
if (getStopStatus(s) === "inactive") return false;
const d = new Date(s.date + "T00:00:00");
return d >= today && d < in7;
}).length;
return { total, active, draft, cities, venues, upcoming };
}, [stops]);
return (
<div className="space-y-6">
{/* Top stats strip — almanac style */}
<section
aria-label="Stops overview"
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-5 shadow-sm"
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.6 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
}}
/>
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
Harvest Dispatch · Almanac
</p>
<h2 className="mt-2 font-display text-3xl sm:text-4xl font-medium tracking-tight text-[var(--admin-text-primary)]">
{stats.total === 0
? "No stops scheduled"
: `${stats.total} stop${stats.total === 1 ? "" : "s"} on the route`}
<span className="ml-2 text-[var(--admin-accent)] italic font-light">
{stats.cities} cit{stats.cities === 1 ? "y" : "ies"}
</span>
</h2>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
{stats.active} active · {stats.draft} drafts · {stats.venues} distinct venues
</p>
</div>
{/* Tab nav — binder-style with notched corners */}
<div className="flex items-end gap-1">
{TABS.map((t) => {
const isActive = t.value === view;
return (
<button
key={t.value}
type="button"
onClick={() => setView(t.value)}
aria-pressed={isActive}
className={`
group relative -mb-px inline-flex flex-col items-start gap-0.5 rounded-t-xl border border-b-0 px-4 py-2.5 transition-all
${isActive
? "z-10 border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] shadow-[0_-4px_8px_-4px_rgba(60,56,37,0.08)]"
: "border-transparent bg-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-white/40"
}
`}
>
<span className={`font-display text-base font-medium ${isActive ? "text-[var(--admin-accent-text)]" : ""}`}>
{t.label}
</span>
<span className="font-mono text-[9px] uppercase tracking-[0.18em] opacity-80">
{t.hint}
</span>
{isActive && (
<span
aria-hidden
className="absolute left-3 right-3 -bottom-px h-0.5 bg-[var(--admin-accent)]"
/>
)}
</button>
);
})}
</div>
</div>
{/* Stat cells */}
<div className="relative mt-5 grid grid-cols-2 gap-3 border-t border-[var(--admin-border-light)] pt-5 sm:grid-cols-4">
<AlmanacStat
numeral="I"
label="Active"
value={stats.active}
accent
/>
<AlmanacStat
numeral="II"
label="Upcoming 7d"
value={stats.upcoming}
accent={stats.upcoming > 0}
/>
<AlmanacStat
numeral="III"
label="Cities"
value={stats.cities}
/>
<AlmanacStat
numeral="IV"
label="Venues"
value={stats.venues}
/>
</div>
</section>
{/* Active view */}
<section>
{view === "calendar" && <StopsCalendar stops={stops} />}
{view === "locations" && <StopsLocations stops={stops} />}
{view === "list" && <StopsList stops={stops} />}
</section>
</div>
);
}
function AlmanacStat({
numeral,
label,
value,
accent,
}: {
numeral: string;
label: string;
value: number;
accent?: boolean;
}) {
return (
<div className="flex items-center gap-3">
<span
className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md font-display text-base font-medium ${
accent ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
}`}
aria-hidden
>
{numeral}
</span>
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{label}
</p>
<p
className={`font-display text-2xl font-medium leading-none tabular-nums ${
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
}`}
>
{value}
</p>
</div>
</div>
);
}
-72
View File
@@ -1,72 +0,0 @@
"use client";
import { useMemo } from "react";
import { type Stop, getStopStatus } from "./types";
type Props = {
stops: Stop[];
};
// Minimal list view used inside the StopsDashboard tab nav.
// It is a calmer, read-only list; for bulk publish/edit use the
// dedicated /admin/stops page.
export default function StopsList({ stops }: Props) {
const sorted = useMemo(() => {
return [...stops].sort((a, b) => (a.date || "").localeCompare(b.date || ""));
}, [stops]);
if (sorted.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops yet</p>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
Switch to a different tab to add stops.
</p>
</div>
);
}
return (
<ul className="divide-y divide-[var(--admin-border-light)] overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
{sorted.map((s) => {
const status = getStopStatus(s);
const dot =
status === "active"
? "bg-[var(--admin-accent-dot)]"
: status === "draft"
? "bg-amber-500"
: "bg-stone-400";
const brand = Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name;
return (
<li key={s.id} className="group flex items-center gap-4 px-5 py-3 transition-colors hover:bg-[var(--admin-bg-subtle)]">
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)] w-20">
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" }) : "—"}
</span>
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
{s.time ? s.time.slice(0, 5) : "—"}
</span>
<span className="flex-1 min-w-0">
<span className="truncate font-display text-sm font-medium text-[var(--admin-text-primary)]">
{s.city}, {s.state}
</span>
<span className="ml-2 truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</span>
</span>
{brand && (
<span className="hidden md:inline-block font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{brand}
</span>
)}
<span
className={`font-mono text-[9px] uppercase tracking-wider ${
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
}`}
>
{status}
</span>
</li>
);
})}
</ul>
);
}
@@ -1,403 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
type LocationGroup,
type Stop,
formatTime12,
getStopStatus,
} from "./types";
type Props = {
stops: Stop[];
};
function groupStopsByLocation(stops: Stop[]): LocationGroup[] {
const map = new Map<string, LocationGroup>();
for (const s of stops) {
const key = `${(s.city || "Unknown").trim().toUpperCase()}|${(s.state || "").trim().toUpperCase()}`;
let group = map.get(key);
if (!group) {
group = {
key,
city: s.city || "Unknown",
state: s.state || "",
venueCount: 0,
total: 0,
active: 0,
draft: 0,
inactive: 0,
upcoming: 0,
nextDate: null,
firstDate: null,
lastDate: null,
sampleVenue: s.location,
stops: [],
};
map.set(key, group);
}
group.stops.push(s);
group.total += 1;
const status = getStopStatus(s);
if (status === "active") group.active += 1;
else if (status === "draft") group.draft += 1;
else group.inactive += 1;
if (s.date) {
if (!group.firstDate || s.date < group.firstDate) group.firstDate = s.date;
if (!group.lastDate || s.date > group.lastDate) group.lastDate = s.date;
}
}
// post-process: count distinct venues, upcoming stops
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
for (const g of map.values()) {
const venues = new Set(g.stops.map((s) => (s.location || "").trim().toLowerCase()).filter(Boolean));
g.venueCount = Math.max(1, venues.size);
g.upcoming = g.stops.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive").length;
const future = g.stops
.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive")
.map((s) => s.date)
.sort();
g.nextDate = future[0] ?? null;
g.stops.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
}
return Array.from(map.values()).sort((a, b) => {
// active locations with upcoming stops first, sorted by next date; then by city
if (a.nextDate && !b.nextDate) return -1;
if (!a.nextDate && b.nextDate) return 1;
if (a.nextDate && b.nextDate) return a.nextDate.localeCompare(b.nextDate);
return a.city.localeCompare(b.city);
});
}
function formatDateLabel(ymd: string | null): string {
if (!ymd) return "—";
const d = new Date(ymd + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function formatRange(a: string | null, b: string | null): string {
if (!a) return "—";
if (!b || a === b) return formatDateLabel(a);
return `${formatDateLabel(a)}${formatDateLabel(b)}`;
}
export default function StopsLocations({ stops }: Props) {
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [showOnlyActive, setShowOnlyActive] = useState(false);
const groups = useMemo(() => groupStopsByLocation(stops), [stops]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return groups.filter((g) => {
if (q) {
const hay = `${g.city} ${g.state} ${g.sampleVenue}`.toLowerCase();
if (!hay.includes(q)) return false;
}
if (showOnlyActive && g.active === 0) return false;
return true;
});
}, [groups, query, showOnlyActive]);
// Aggregated stats
const stats = useMemo(() => {
const totalLocations = groups.length;
const totalStops = stops.length;
const totalActive = groups.reduce((sum, g) => sum + g.active, 0);
const totalUpcoming = groups.reduce((sum, g) => sum + g.upcoming, 0);
const totalDrafts = groups.reduce((sum, g) => sum + g.draft, 0);
const totalCities = new Set(groups.map((g) => g.city.toLowerCase())).size;
const totalVenues = groups.reduce((sum, g) => sum + g.venueCount, 0);
return { totalLocations, totalStops, totalActive, totalUpcoming, totalDrafts, totalCities, totalVenues };
}, [groups, stops]);
if (stops.length === 0) {
return (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-12 text-center shadow-sm">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
<svg className="h-7 w-7 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
No locations yet
</h3>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
Create your first stop to see pickup locations here.
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Stats strip — almanac style */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{[
{ numeral: "I", label: "Locations", value: stats.totalLocations, hint: `${stats.totalVenues} venues` },
{ numeral: "II", label: "Cities", value: stats.totalCities, hint: "covered" },
{ numeral: "III", label: "Stops", value: stats.totalStops, hint: "all-time" },
{ numeral: "IV", label: "Active", value: stats.totalActive, hint: "live" },
{ numeral: "V", label: "Upcoming", value: stats.totalUpcoming, hint: "in queue" },
{ numeral: "VI", label: "Drafts", value: stats.totalDrafts, hint: "unpublished" },
].map((s) => (
<div
key={s.numeral}
className="group relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-sm transition-all hover:shadow-md hover:border-[var(--admin-accent)]/30"
>
<div className="flex items-start justify-between">
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{s.numeral} · {s.label}
</p>
<p className="mt-2 font-display text-3xl font-medium leading-none text-[var(--admin-text-primary)] tabular-nums">
{s.value}
</p>
<p className="mt-1.5 font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{s.hint}
</p>
</div>
<div
aria-hidden
className="h-8 w-8 -rotate-3 font-display text-2xl text-[var(--admin-accent)]/15 group-hover:text-[var(--admin-accent)]/30 transition-colors"
>
{s.numeral}
</div>
</div>
<div className="mt-3 h-px w-full bg-gradient-to-r from-[var(--admin-accent)]/20 via-[var(--admin-accent)]/40 to-[var(--admin-accent)]/20" />
</div>
))}
</section>
{/* Filter bar */}
<section className="flex flex-wrap items-center gap-3 rounded-2xl border border-[var(--admin-border)] bg-white px-4 py-3 shadow-sm">
<div className="relative flex-1 min-w-[200px] max-w-md">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search city, state, venue..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white pl-9 pr-3 py-2 text-sm text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:border-[var(--admin-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/15"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={showOnlyActive}
onClick={() => setShowOnlyActive((v) => !v)}
className={`relative h-5 w-9 rounded-full transition-colors ${showOnlyActive ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${showOnlyActive ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-secondary)]">
Active only
</span>
</label>
<span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{filtered.length} of {groups.length} locations
</span>
</section>
{/* Location grid */}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
<p className="font-display text-lg text-[var(--admin-text-primary)]">No locations match.</p>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">Try a different search.</p>
</div>
) : (
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((g, idx) => {
const isOpen = expandedKey === g.key;
return (
<article
key={g.key}
className={`
group relative overflow-hidden rounded-2xl border bg-white shadow-sm transition-all
${isOpen ? "border-[var(--admin-accent)] shadow-md ring-1 ring-[var(--admin-accent)]/20" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)]/40 hover:shadow-md"}
`}
>
{/* Decorative route number */}
<div
aria-hidden
className="pointer-events-none absolute -right-2 -top-3 select-none font-display text-[80px] leading-none font-medium text-[var(--admin-accent)]/[0.06] group-hover:text-[var(--admin-accent)]/[0.1] transition-colors"
>
{String(idx + 1).padStart(2, "0")}
</div>
<button
type="button"
onClick={() => setExpandedKey(isOpen ? null : g.key)}
className="block w-full text-left p-5"
aria-expanded={isOpen}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{/* Pin marker */}
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)]/30">
<svg className="h-3.5 w-3.5 text-[var(--admin-accent-text)]" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z" />
</svg>
</span>
<div className="min-w-0">
<h3 className="truncate font-display text-xl font-medium text-[var(--admin-text-primary)]">
{g.city}
{g.state && (
<span className="ml-1.5 font-mono text-xs font-normal text-[var(--admin-text-muted)]">
{g.state}
</span>
)}
</h3>
<p className="truncate text-xs text-[var(--admin-text-secondary)]">
{g.sampleVenue}
</p>
</div>
</div>
</div>
<div
className={`mt-1 h-7 w-7 shrink-0 inline-flex items-center justify-center rounded-full border transition-transform ${
isOpen ? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] rotate-45" : "border-[var(--admin-border)] bg-white text-[var(--admin-text-muted)]"
}`}
aria-hidden
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
</svg>
</div>
</div>
{/* Stat grid */}
<div className="mt-5 grid grid-cols-3 gap-2">
<Stat label="Stops" value={g.total} />
<Stat label="Active" value={g.active} accent={g.active > 0} />
<Stat label="Upcoming" value={g.upcoming} accent={g.upcoming > 0} />
</div>
{/* Date range + drafts */}
<div className="mt-4 flex items-center justify-between border-t border-[var(--admin-border-light)] pt-3 font-mono text-[10px] uppercase tracking-wider">
<div className="text-[var(--admin-text-muted)]">
{g.firstDate ? (
<>
<span className="text-[var(--admin-text-secondary)]">{formatRange(g.firstDate, g.lastDate)}</span>
{" · "}
{g.venueCount > 1 ? `${g.venueCount} venues` : `${g.total} stop${g.total === 1 ? "" : "s"}`}
</>
) : (
"No dates"
)}
</div>
{g.draft > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden />
{g.draft} draft
</span>
)}
</div>
{/* Next up ribbon */}
{g.nextDate && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-[var(--admin-accent)]/20 bg-gradient-to-r from-[var(--admin-accent-light)] to-transparent px-3 py-2">
<span className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-accent-text)]">
Next
</span>
<span className="font-display text-sm font-medium text-[var(--admin-text-primary)]">
{formatDateLabel(g.nextDate)}
</span>
</div>
)}
</button>
{/* Expanded stop list */}
{isOpen && (
<div className="border-t border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/40 px-5 py-4">
<div className="mb-2 flex items-center justify-between">
<h4 className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
All stops · {g.stops.length}
</h4>
<Link
href={`/admin/stops/new?city=${encodeURIComponent(g.city)}&state=${encodeURIComponent(g.state)}`}
className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-accent-text)] hover:underline"
>
+ Add at this city
</Link>
</div>
<ul className="space-y-1">
{g.stops.map((s) => {
const status = getStopStatus(s);
const dot =
status === "active"
? "bg-[var(--admin-accent-dot)]"
: status === "draft"
? "bg-amber-500"
: "bg-stone-400";
return (
<li key={s.id}>
<Link
href={`/admin/stops/${s.id}`}
className="flex items-center gap-3 rounded-lg border border-transparent bg-white px-3 py-2 transition-colors hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-16">
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }) : "—"}
</span>
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
{formatTime12(s.time)}
</span>
<span className="flex-1 truncate text-xs text-[var(--admin-text-primary)]">
{s.location}
</span>
<span
className={`font-mono text-[9px] uppercase tracking-wider ${
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
}`}
>
{status}
</span>
</Link>
</li>
);
})}
</ul>
</div>
)}
</article>
);
})}
</section>
)}
</div>
);
}
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
return (
<div
className={`rounded-lg border px-2.5 py-2 ${
accent ? "border-[var(--admin-accent)]/30 bg-[var(--admin-accent-light)]" : "border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/50"
}`}
>
<p className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">{label}</p>
<p
className={`mt-0.5 font-display text-xl font-medium tabular-nums leading-none ${
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
}`}
>
{value}
</p>
</div>
);
}
-70
View File
@@ -1,70 +0,0 @@
export type StopStatus = "draft" | "active" | "inactive";
export type Stop = {
id: string;
city: string;
state: string;
date: string; // YYYY-MM-DD
time: string; // HH:MM
location: string;
active: boolean;
deleted_at?: string | null;
brand_id: string;
status?: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
brands: { name: string } | { name: string }[];
};
export type StopView = "calendar" | "locations" | "list";
export type LocationGroup = {
key: string;
city: string;
state: string;
venueCount: number; // distinct location strings at this city
total: number;
active: number;
draft: number;
inactive: number;
upcoming: number;
nextDate: string | null; // YYYY-MM-DD
firstDate: string | null;
lastDate: string | null;
sampleVenue: string;
stops: Stop[];
};
export function getStopStatus(s: Stop): StopStatus {
if (s.status === "draft") return "draft";
return s.active ? "active" : "inactive";
}
export function getStopDate(s: Stop): Date | null {
if (!s.date) return null;
const d = new Date(s.date + "T00:00:00");
return isNaN(d.getTime()) ? null : d;
}
export function formatTime12(time: string): string {
if (!time) return "—";
const [h, m] = time.split(":");
const hour = parseInt(h, 10);
if (isNaN(hour)) return time;
const ampm = hour >= 12 ? "PM" : "AM";
const hour12 = hour % 12 || 12;
return `${hour12}:${m} ${ampm}`;
}
export function formatMonthYear(date: Date): string {
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
}
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
@@ -1,494 +0,0 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import { useCart } from "@/context/CartContext";
import { formatDate } from "@/lib/format-date";
type QuickCartSheetProps = {
open: boolean;
onClose: () => void;
/** Optional override for the product just added (used when re-opening). */
productName?: string;
/** Optional message override (e.g. "Added ✓" vs default). */
justAddedLabel?: string;
};
const EASE_OUT = [0.22, 0.61, 0.36, 1] as const;
/**
* Slide-up cart drawer that appears immediately after the buyer adds an item.
*
* Mobile: bottom sheet that slides up from the bottom of the screen,
* full width, ~85vh tall. Drag handle at top.
* Desktop: right-side drawer (420px wide) that slides in from the right.
*
* Contains:
* - "✓ Added" confirmation
* - The added item card (name, price, qty, fulfillment)
* - Express checkout row (Apple Pay / Google Pay / Shop Pay)
* - Primary "Checkout" CTA
* - Secondary "View full cart" link
* - "Continue shopping" dismiss link
*/
export default function QuickCartSheet({
open,
onClose,
productName,
justAddedLabel,
}: QuickCartSheetProps) {
const { cart, subtotal, selectedStop, justAdded, dismissToast } = useCart();
// Lock body scroll while open (mobile only — desktop keeps scroll)
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
const isDesktop = window.matchMedia("(min-width: 1024px)").matches;
if (!isDesktop) {
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = prev;
};
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
const displayName = productName ?? justAdded?.name ?? cart[0]?.name ?? "Your box";
const addedAt = new Date();
const headerLabel = justAddedLabel ?? "Just added";
function handleViewCart() {
dismissToast();
onClose();
}
function handleCheckout() {
dismissToast();
onClose();
}
return (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.button
type="button"
aria-label="Close cart"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className="fixed inset-0 z-[60] bg-stone-950/55 backdrop-blur-[3px] cursor-default"
/>
{/* Mobile: bottom sheet */}
<motion.div
role="dialog"
aria-modal="true"
aria-label="Quick cart"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", stiffness: 380, damping: 38, mass: 0.9 }}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.5 }}
onDragEnd={(_, info) => {
if (info.offset.y > 90 || info.velocity.y > 480) onClose();
}}
className="lg:hidden fixed inset-x-0 bottom-0 z-[61] max-h-[88vh] rounded-t-[28px] bg-[#FAF6EA] shadow-[0_-24px_60px_-12px_rgba(26,77,46,0.35)] flex flex-col"
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-1 cursor-grab active:cursor-grabbing">
<div className="h-1.5 w-12 rounded-full bg-stone-300/70" />
</div>
<SheetContent
displayName={displayName}
addedAt={addedAt}
headerLabel={headerLabel}
cart={cart}
subtotal={subtotal}
selectedStop={selectedStop}
onViewCart={handleViewCart}
onCheckout={handleCheckout}
onContinue={onClose}
/>
</motion.div>
{/* Desktop: right-side drawer */}
<motion.aside
role="dialog"
aria-modal="true"
aria-label="Quick cart"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", stiffness: 320, damping: 36, mass: 0.9 }}
className="hidden lg:flex fixed inset-y-0 right-0 z-[61] w-full sm:w-[420px] xl:w-[460px] bg-[#FAF6EA] shadow-[-24px_0_60px_-12px_rgba(26,77,46,0.35)] flex-col"
>
<SheetContent
displayName={displayName}
addedAt={addedAt}
headerLabel={headerLabel}
cart={cart}
subtotal={subtotal}
selectedStop={selectedStop}
onViewCart={handleViewCart}
onCheckout={handleCheckout}
onContinue={onClose}
showCloseButton
/>
</motion.aside>
</>
)}
</AnimatePresence>
);
}
type CartRow = {
id: string;
name: string;
price: string;
quantity: number;
fulfillment?: "pickup" | "ship";
};
type StopInfo = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
type SheetContentProps = {
displayName: string;
addedAt: Date;
headerLabel: string;
cart: CartRow[];
subtotal: number;
selectedStop: StopInfo | null;
onViewCart: () => void;
onCheckout: () => void;
onContinue: () => void;
showCloseButton?: boolean;
};
function SheetContent({
displayName,
addedAt,
headerLabel,
cart,
subtotal,
selectedStop,
onViewCart,
onCheckout,
onContinue,
showCloseButton,
}: SheetContentProps) {
return (
<>
{/* Header */}
<div className="relative px-6 sm:px-8 pt-2 pb-5 border-b border-stone-900/10">
<div className="flex items-center gap-3">
<motion.div
initial={{ scale: 0, rotate: -45 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 18, delay: 0.05 }}
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#1A4D2E] text-white shadow-[0_4px_14px_rgba(26,77,46,0.4)]"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</motion.div>
<div className="flex-1 min-w-0">
<p className="text-[10px] font-bold uppercase tracking-[0.22em] text-[#1A4D2E]/70">
{headerLabel}
</p>
<h2 className="font-display text-[22px] sm:text-[24px] font-bold leading-[1.1] text-stone-950 truncate">
{displayName}
</h2>
</div>
{showCloseButton && (
<button
onClick={onContinue}
aria-label="Close"
className="flex h-9 w-9 items-center justify-center rounded-full text-stone-500 hover:bg-stone-900/5 hover:text-stone-900 transition-colors"
>
<svg className="h-5 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>
<p className="mt-3 font-mono text-[10px] uppercase tracking-widest text-stone-500">
{addedAt.toLocaleString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})} · Today
</p>
</div>
{/* Body — scrollable */}
<div className="flex-1 overflow-y-auto px-6 sm:px-8 py-5">
<ul className="space-y-3">
{cart.map((item) => (
<li
key={item.id}
className="flex items-start gap-4 rounded-2xl bg-white/70 ring-1 ring-stone-900/8 p-4"
>
{/* Tiny product swatch */}
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-gradient-to-br from-[#FFD86A] via-[#F0B81C] to-[#A47A0B] ring-1 ring-stone-900/10">
<CornGlyph className="absolute inset-0 m-auto h-9 w-9 text-[#1A4D2E]/85" />
<span className="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-[#1A4D2E] text-[10px] font-bold text-white shadow-sm">
{item.quantity}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-stone-950 text-[15px] leading-tight truncate">
{item.name}
</p>
<p className="mt-0.5 text-[11px] font-mono uppercase tracking-widest text-stone-500">
{item.fulfillment === "ship" ? "Ships to door" : "Pickup at stop"}
</p>
<p className="mt-1.5 text-[15px] font-bold text-stone-950">
{item.price}
</p>
</div>
</li>
))}
</ul>
{/* Selected stop banner (if any) */}
{selectedStop && (
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-dashed border-[#1A4D2E]/40 bg-[#1A4D2E]/5 p-3.5">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#1A4D2E] text-white">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a2 2 0 01-2.828 0l-4.243-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 className="min-w-0 flex-1">
<p className="text-[10px] font-mono uppercase tracking-widest text-[#1A4D2E]/70">Pickup at</p>
<p className="text-sm font-semibold text-stone-950 truncate">
{selectedStop.city}, {selectedStop.state}
</p>
<p className="text-[11px] text-stone-600">
{formatDate(selectedStop.date)} · {selectedStop.time}
</p>
</div>
</div>
)}
{/* Subtotal */}
<div className="mt-5 flex items-baseline justify-between border-t border-stone-900/10 pt-4">
<span className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
Subtotal
</span>
<span className="font-display text-2xl font-bold text-stone-950">
${subtotal.toFixed(2)}
</span>
</div>
<p className="mt-1.5 text-[11px] text-stone-500 text-right">
+ tax & shipping at checkout
</p>
</div>
{/* Footer — sticky CTA + express */}
<div className="border-t border-stone-900/10 bg-[#F5EFD9] px-6 sm:px-8 pt-5 pb-7 space-y-3">
{/* Express checkout row the buttons are visual shortcuts.
/checkout auto-renders Stripe's <ExpressCheckoutElement> for
Apple Pay / Google Pay / Link + <PaymentElement> for card via
StripeExpressCheckout. The `?express=` query is decorative. */}
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.22em] text-stone-500 mb-2 text-center">
Express checkout
</p>
<div className="grid grid-cols-3 gap-2">
<ExpressButton
href="/checkout?express=apple_pay"
onClick={onCheckout}
label="Pay"
icon={<ApplePayMark />}
className="bg-black text-white hover:bg-stone-900"
/>
<ExpressButton
href="/checkout?express=google_pay"
onClick={onCheckout}
label="Pay"
icon={<GooglePayMark />}
className="bg-white text-stone-900 ring-1 ring-stone-900/10 hover:bg-stone-50"
/>
<ExpressButton
href="/checkout?express=shop_pay"
onClick={onCheckout}
label="Shop"
icon={<ShopPayMark />}
className="bg-[#5A31F4] text-white hover:bg-[#4A22E0]"
/>
</div>
</div>
{/* Primary checkout */}
<Link
href="/checkout"
onClick={onCheckout}
className="group flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-[#1A4D2E] text-white font-bold tracking-wide shadow-[0_10px_24px_-8px_rgba(26,77,46,0.5)] hover:bg-[#143C24] active:scale-[0.99] transition-all"
>
Checkout · ${subtotal.toFixed(2)}
<svg className="h-5 w-5 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</Link>
{/* Secondary actions */}
<div className="flex items-center justify-between pt-1 text-[13px]">
<Link
href="/cart"
onClick={onViewCart}
className="font-semibold text-stone-600 hover:text-stone-950 underline-offset-4 hover:underline transition-colors"
>
View full cart
</Link>
<button
onClick={onContinue}
className="font-semibold text-stone-600 hover:text-stone-950 underline-offset-4 hover:underline transition-colors"
>
Continue shopping
</button>
</div>
</div>
</>
);
}
function ExpressButton({
href,
onClick,
label,
icon,
className,
}: {
href: string;
onClick: () => void;
label: string;
icon: React.ReactNode;
className: string;
}) {
return (
<Link
href={href}
onClick={onClick}
className={`group flex h-12 items-center justify-center gap-1.5 rounded-xl font-bold text-[13px] tracking-tight transition-all active:scale-[0.97] ${className}`}
>
{icon}
<span>{label}</span>
</Link>
);
}
/* ── Express brand marks (stylized, not official) ─────────────────── */
function ApplePayMark() {
return (
<svg viewBox="0 0 28 14" className="h-4 w-auto" aria-hidden="true">
<text x="0" y="11" fontFamily="system-ui, -apple-system, sans-serif" fontWeight="700" fontSize="12" fill="currentColor" letterSpacing="-0.5">
Pay
</text>
<path
d="M5.5 3.2c.5-.6.8-1.4.7-2.2-.7 0-1.5.5-2 1.1-.4.5-.8 1.3-.7 2.1.8 0 1.6-.4 2-1zm.7.9c-1.1 0-2 .6-2.6.6-.6 0-1.4-.6-2.3-.6-1.2 0-2.3.7-2.9 1.8C-2.9 8.1-2 11 1 11c.8 0 1.4-.5 2.3-.5s1.4.5 2.3.5c1 0 1.6-.8 2.2-1.6.7-1 .9-1.9.9-2-.1 0-1.7-.6-1.7-2.4 0-1.5 1.2-2.2 1.3-2.2-.7-1.1-1.9-1.2-2.3-1.2z"
transform="translate(0, -3) scale(0.5)"
fill="currentColor"
/>
</svg>
);
}
function GooglePayMark() {
return (
<svg viewBox="0 0 36 16" className="h-3.5 w-auto" aria-hidden="true">
<text
x="0"
y="12"
fontFamily="system-ui, -apple-system, sans-serif"
fontWeight="600"
fontSize="11"
fill="currentColor"
letterSpacing="-0.3"
>
G
<tspan dx="0.5" fontWeight="400" fontStyle="italic">
Pay
</tspan>
</text>
</svg>
);
}
function ShopPayMark() {
return (
<svg viewBox="0 0 38 14" className="h-3.5 w-auto" aria-hidden="true">
<text
x="0"
y="11"
fontFamily="system-ui, -apple-system, sans-serif"
fontWeight="700"
fontSize="11"
fill="currentColor"
letterSpacing="-0.4"
>
shop
<tspan fontWeight="400">Pay</tspan>
</text>
</svg>
);
}
function CornGlyph({ className }: { className?: string }) {
return (
<svg viewBox="0 0 32 32" className={className} aria-hidden="true">
{/* Husk leaves */}
<path
d="M9 16c-2-4-2-9 0-12 1 3 3 5 5 6"
fill="currentColor"
opacity="0.4"
/>
<path
d="M23 16c2-4 2-9 0-12-1 3-3 5-5 6"
fill="currentColor"
opacity="0.4"
/>
{/* Cob */}
<ellipse cx="16" cy="17" rx="6" ry="9" fill="currentColor" />
{/* Kernels (dots) */}
<g fill="#FAF6EA" opacity="0.7">
<circle cx="14" cy="12" r="0.8" />
<circle cx="18" cy="12" r="0.8" />
<circle cx="13" cy="15" r="0.8" />
<circle cx="16" cy="14" r="0.8" />
<circle cx="19" cy="15" r="0.8" />
<circle cx="14" cy="18" r="0.8" />
<circle cx="18" cy="18" r="0.8" />
<circle cx="16" cy="20" r="0.8" />
<circle cx="14" cy="22" r="0.8" />
<circle cx="18" cy="22" r="0.8" />
</g>
</svg>
);
}
@@ -1,504 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Elements, ExpressCheckoutElement, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import type { StripeExpressCheckoutElementConfirmEvent } from "@stripe/stripe-js";
import { getStripe, hasStripePublishableKey } from "@/lib/stripe-client";
import { createRetailPaymentIntent } from "@/actions/billing/retail-payment-intent";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time?: string;
location: string;
brand_id: string;
};
export type CheckoutItem = {
id: string;
name: string;
price: string; // dollars, with or without $ sign
quantity: number;
fulfillment?: "pickup" | "ship";
};
type Props = {
items: CheckoutItem[];
brandId: string | null;
customerName: string;
customerEmail: string;
customerPhone: string;
selectedStop: Stop | null;
/** Required shipping address fields (only used when items include ship fulfillment). */
shippingState?: string;
shippingPostal?: string;
shippingCity?: string;
/** Called after the user submits via the form submit button (hosted checkout fallback). */
onHostedCheckout?: () => void;
/** Stable id used to group intents for the same logical checkout (UUID per page load). */
checkoutSessionKey?: string;
};
/**
* Renders the embedded Stripe Elements (Express Checkout + Payment Element)
* on top of the existing hosted Stripe Checkout fallback.
*
* Express Checkout Element is the primary CTA it surfaces Apple Pay /
* Google Pay / Link / PayPal buttons automatically based on the user's
* browser and wallet availability. Tapping one opens the wallet's
* payment sheet, confirms the PaymentIntent in-page, and we navigate
* to /checkout/success to create the order.
*
* If Stripe.js can't load (no publishable key, network error, etc.),
* the component renders a graceful error and the parent falls back to
* the hosted Stripe Checkout button.
*/
export default function StripeExpressCheckout(props: Props) {
const router = useRouter();
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [paymentIntentId, setPaymentIntentId] = useState<string | null>(null);
const [intentAmount, setIntentAmount] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [available, setAvailable] = useState(false);
const hasShip = props.items.some((i) => i.fulfillment === "ship");
const hasPickup = props.items.some(
(i) => (i.fulfillment ?? "pickup") === "pickup" && (i as { pickup_type?: string }).pickup_type !== "shed"
);
// Block express checkout if pickup items exist but no stop is selected
const stopBlocked = hasPickup && !props.selectedStop;
const emailBlocked = !props.customerEmail.trim();
// Stable key for grouping intents by checkout session (in case of retry)
const sessionKey = useMemo(
() => props.checkoutSessionKey ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
[props.checkoutSessionKey]
);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
if (!hasStripePublishableKey()) {
setAvailable(false);
setError("Stripe publishable key not configured. Use the secure checkout button below.");
setLoading(false);
return;
}
if (props.items.length === 0) {
setLoading(false);
return;
}
if (stopBlocked) {
setLoading(false);
return;
}
(async () => {
const result = await createRetailPaymentIntent(
props.items.map((i) => ({
id: i.id,
name: i.name,
price: Number(String(i.price).replace(/[$,]/g, "")) || 0,
quantity: i.quantity,
})),
{
name: props.customerName,
email: props.customerEmail,
},
props.brandId,
props.selectedStop?.id ?? null,
hasShip
? {
state: props.shippingState,
postal_code: props.shippingPostal,
city: props.shippingCity,
}
: null
);
if (cancelled) return;
if (result.success) {
setClientSecret(result.clientSecret);
setPaymentIntentId(result.paymentIntentId);
setIntentAmount(result.amount);
setAvailable(true);
setError(null);
} else {
setAvailable(false);
setError(result.error);
}
setLoading(false);
})();
return () => {
cancelled = true;
};
// Re-fetch when the inputs that affect the PaymentIntent change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
sessionKey,
props.brandId,
props.selectedStop?.id,
props.customerName,
props.customerEmail,
hasShip ? `${props.shippingState}|${props.shippingPostal}|${props.shippingCity}` : "",
stopBlocked,
]);
// Persist the pending-checkout payload to sessionStorage so the
// success page can create the order. The wallet may overwrite the
// name/email (Apple Pay provides them), so we read from the PaymentIntent
// metadata on the success page where appropriate.
function persistPendingCheckout(intentId: string) {
if (typeof sessionStorage === "undefined") return;
sessionStorage.setItem(
"pending_checkout",
JSON.stringify({
customerName: props.customerName,
customerEmail: props.customerEmail,
customerPhone: props.customerPhone,
stopId: props.selectedStop?.id ?? null,
items: props.items.map((i) => ({
id: i.id,
name: i.name,
price: Number(String(i.price).replace(/[$,]/g, "")) || 0,
quantity: i.quantity,
fulfillment: (i.fulfillment ?? "pickup") as "pickup" | "ship",
})),
cartBrandId: props.brandId,
shippingAddress: hasShip
? {
state: props.shippingState,
postal_code: props.shippingPostal,
city: props.shippingCity,
}
: undefined,
idempotencyKey: sessionKey,
paymentIntentId: intentId,
paymentIntentAmount: intentAmount,
})
);
}
// Stripe.js loader promise
const stripePromise = useMemo(() => getStripe(), []);
if (loading) {
return (
<ExpressShell>
<div className="flex items-center gap-2 text-stone-500 text-sm py-3">
<span className="h-4 w-4 rounded-full border-2 border-stone-300 border-t-stone-700 animate-spin" />
Preparing express checkout
</div>
</ExpressShell>
);
}
if (!available || !clientSecret || !stripePromise) {
return (
<ExpressShell>
{error && (
<p className="text-xs text-stone-500 mb-2">
{error}
</p>
)}
<button
type="button"
onClick={props.onHostedCheckout}
className="w-full rounded-xl bg-gradient-to-r from-stone-900 to-stone-700 px-6 py-3.5 text-sm font-semibold text-white hover:from-stone-800 hover:to-stone-600 transition-all shadow-lg shadow-stone-900/20"
>
Continue to secure checkout
</button>
</ExpressShell>
);
}
return (
<ExpressShell>
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: "flat",
variables: {
colorPrimary: "#1A4D2E",
colorBackground: "#ffffff",
colorText: "#0a0a0a",
colorDanger: "#dc2626",
fontFamily: "system-ui, -apple-system, sans-serif",
spacingUnit: "4px",
borderRadius: "12px",
},
rules: {
".Input": { boxShadow: "none", border: "1px solid #e7e5e4" },
".Input:focus": { border: "1px solid #1A4D2E" },
".Label": { fontWeight: "600", color: "#0a0a0a" },
},
},
}}
>
<CheckoutInner
{...props}
emailBlocked={emailBlocked}
stopBlocked={stopBlocked}
paymentIntentId={paymentIntentId}
intentAmount={intentAmount}
persistPendingCheckout={persistPendingCheckout}
onError={setError}
onSuccess={() => {
// Express / Card confirmed in-page. Navigate to success.
router.push("/checkout/success?payment_intent=" + (paymentIntentId ?? ""));
}}
onHostedCheckout={props.onHostedCheckout}
/>
</Elements>
</ExpressShell>
);
}
function ExpressShell({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<svg className="h-4 w-4 text-stone-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-700">
Express checkout
</p>
</div>
{children}
</div>
);
}
type InnerProps = Props & {
emailBlocked: boolean;
stopBlocked: boolean;
paymentIntentId: string | null;
intentAmount: number | null;
persistPendingCheckout: (intentId: string) => void;
onError: (msg: string) => void;
onSuccess: () => void;
};
function CheckoutInner({
items,
brandId,
customerName,
customerEmail,
customerPhone,
selectedStop,
shippingState,
shippingPostal,
shippingCity,
emailBlocked,
stopBlocked,
paymentIntentId,
persistPendingCheckout,
onError,
onSuccess,
onHostedCheckout,
}: InnerProps) {
const stripe = useStripe();
const elements = useElements();
const [submitting, setSubmitting] = useState(false);
const [expressReady, setExpressReady] = useState(false);
const blocked = emailBlocked || stopBlocked;
const siteUrl =
typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
async function handleExpressConfirm(event: StripeExpressCheckoutElementConfirmEvent) {
if (blocked) {
event.paymentFailed({ reason: 'fail' });
onError(
emailBlocked
? "Enter your email above before paying."
: "Pick a pickup stop before paying."
);
return;
}
if (!stripe || !elements) {
event.paymentFailed({ reason: 'fail' });
onError("Stripe is not ready yet. Please try again.");
return;
}
// Persist the pending checkout payload so the success page can
// create the order. We do this BEFORE confirmPayment because the
// wallet may navigate away briefly during the payment sheet.
if (paymentIntentId) persistPendingCheckout(paymentIntentId);
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${siteUrl}/checkout/success?source=express`,
payment_method_data: {
billing_details: {
name: customerName || undefined,
email: customerEmail || undefined,
phone: customerPhone || undefined,
address: shippingState
? {
state: shippingState,
postal_code: shippingPostal,
city: shippingCity,
country: "US",
}
: undefined,
},
},
},
});
if (confirmError) {
event.paymentFailed({ reason: 'fail' });
onError(confirmError.message ?? "Payment failed. Please try again.");
} else {
// payment confirmed in-page — wallet closes its sheet automatically.
onSuccess();
}
}
async function handleCardSubmit(e: React.FormEvent) {
e.preventDefault();
if (!stripe || !elements) {
onError("Stripe is not ready yet. Please try again.");
return;
}
if (blocked) {
onError(
emailBlocked
? "Enter your email above before paying."
: "Pick a pickup stop before paying."
);
return;
}
setSubmitting(true);
if (paymentIntentId) persistPendingCheckout(paymentIntentId);
const { error: submitError } = await elements.submit();
if (submitError) {
onError(submitError.message ?? "Form is incomplete.");
setSubmitting(false);
return;
}
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${siteUrl}/checkout/success?source=card`,
payment_method_data: {
billing_details: {
name: customerName || undefined,
email: customerEmail || undefined,
phone: customerPhone || undefined,
address: shippingState
? {
state: shippingState,
postal_code: shippingPostal,
city: shippingCity,
country: "US",
}
: undefined,
},
},
},
});
if (confirmError) {
onError(confirmError.message ?? "Payment failed.");
setSubmitting(false);
return;
}
onSuccess();
}
return (
<div className="space-y-4">
{/* Express (Apple Pay, Google Pay, Link, PayPal) */}
<div>
<ExpressCheckoutElement
onConfirm={handleExpressConfirm}
onReady={(ev) => {
const m = ev.availablePaymentMethods;
const any = !!m && (m.applePay || m.googlePay || m.link || m.paypal || m.amazonPay);
setExpressReady(any);
}}
options={{
buttonType: { applePay: "buy", googlePay: "buy" },
buttonTheme: { applePay: "black", googlePay: "black" },
paymentMethodOrder: ["apple_pay", "google_pay", "link", "paypal"],
layout: { maxColumns: 3, maxRows: 1 },
}}
/>
{!expressReady && !blocked && (
<p className="mt-2 text-[11px] text-stone-500 text-center">
No express wallets detected on this device you can still pay by card below.
</p>
)}
{blocked && (
<p className="mt-2 text-[11px] text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-center">
{emailBlocked
? "Enter your email above to enable express checkout."
: "Pick a pickup stop above to enable express checkout."}
</p>
)}
</div>
{/* Divider */}
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-stone-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-3 text-[10px] font-bold uppercase tracking-widest text-stone-400">
or pay by card
</span>
</div>
</div>
{/* Card form */}
<form onSubmit={handleCardSubmit}>
<PaymentElement
options={{
layout: "tabs",
fields: { billingDetails: "auto" },
wallets: { applePay: "never", googlePay: "never" },
}}
/>
<button
type="submit"
disabled={!stripe || !elements || submitting || blocked}
className="mt-4 w-full rounded-xl bg-stone-900 hover:bg-stone-800 disabled:bg-stone-400 disabled:cursor-not-allowed px-6 py-3.5 text-sm font-semibold text-white transition-all shadow-lg shadow-stone-900/15"
>
{submitting ? "Processing…" : "Pay by card"}
</button>
</form>
{/* Fallback to hosted checkout (existing flow) */}
{onHostedCheckout && (
<button
type="button"
onClick={onHostedCheckout}
className="w-full text-xs text-stone-500 hover:text-stone-800 underline underline-offset-4 transition-colors"
>
Having trouble? Use the secure hosted checkout instead.
</button>
)}
</div>
);
}
@@ -1,873 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence, useScroll, useTransform } from "framer-motion";
import { useCart } from "@/context/CartContext";
import QuickCartSheet from "./QuickCartSheet";
import StorefrontHeader from "./StorefrontHeader";
import StorefrontFooter from "./StorefrontFooter";
type Props = {
brandId: string;
brandName: string;
logoUrl?: string | null;
logoUrlDark?: string | null;
};
const PRODUCT = {
id: "sweet-corn-box-12",
slug: "sweet-corn-box",
name: "Fresh Sweet Corn Box 12 Ears",
tagline: "Straight-from-the-farm sweetness in every bite.",
price: "$29.99",
priceNumeric: 29.99,
// Used by the existing /cart flow to filter pickup vs ship
type: "Pickup & Shipping",
imageUrl:
"https://images.unsplash.com/photo-1551754655-cd27e38d2076?auto=format&fit=crop&w=1600&q=80",
fallbackGradient:
"radial-gradient(ellipse at 50% 50%, #FFE08A 0%, #E5A80F 35%, #8A5A06 80%, #1A4D2E 100%)",
};
const EASE_OUT = [0.22, 0.61, 0.36, 1] as const;
export default function SweetCornProductPage({ brandId, brandName, logoUrl, logoUrlDark }: Props) {
const router = useRouter();
const { addToCart, buyNow } = useCart();
const [sheetOpen, setSheetOpen] = useState(false);
const [justAdded, setJustAdded] = useState(false);
const [buyNowPulse, setBuyNowPulse] = useState(false);
const [imgError, setImgError] = useState(false);
const [imgLoaded, setImgLoaded] = useState(false);
// Parallax for the hero corn ears floating decoration
const { scrollY } = useScroll();
const floatY1 = useTransform(scrollY, [0, 800], [0, -120]);
const floatY2 = useTransform(scrollY, [0, 800], [0, 80]);
// Auto-dismiss the "Added" pulse after a short delay
useEffect(() => {
if (!justAdded) return;
const t = setTimeout(() => setJustAdded(false), 1800);
return () => clearTimeout(t);
}, [justAdded]);
function buildBaseItem() {
return {
id: PRODUCT.id,
name: PRODUCT.name,
price: PRODUCT.price,
brand_id: brandId,
brand_slug: "tuxedo",
is_taxable: true,
pickup_type: "scheduled_stop" as const,
description: PRODUCT.tagline,
};
}
function handleAddToCart() {
addToCart(buildBaseItem(), "pickup");
setJustAdded(true);
// Tiny delay so the user sees the "Added" state before the sheet slides up
setTimeout(() => setSheetOpen(true), 280);
}
function handleBuyNow() {
buyNow(buildBaseItem(), "pickup");
setBuyNowPulse(true);
setTimeout(() => router.push("/checkout"), 140);
}
return (
<div className="min-h-screen bg-[#FAF6EA] text-stone-950">
<StorefrontHeader
brandName={brandName}
brandSlug="tuxedo"
logoUrl={logoUrl}
logoUrlDark={logoUrlDark}
showWholesaleLink
brandAccent="green"
/>
<main>
{/* ── HERO ─────────────────────────────────────────────────────── */}
<section className="relative overflow-hidden bg-[#FAF6EA]">
{/* Subtle paper grain */}
<div
aria-hidden
className="absolute inset-0 pointer-events-none opacity-[0.035] mix-blend-multiply"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.7'/></svg>\")",
}}
/>
{/* Decorative giant "12" in the background */}
<div
aria-hidden
className="absolute -top-20 -right-10 lg:-right-24 font-display text-[260px] lg:text-[420px] font-black leading-none text-[#E5A80F]/8 select-none pointer-events-none"
style={{ color: "rgba(229, 168, 15, 0.08)" }}
>
12
</div>
{/* Floating decorative corn ears */}
<motion.div
aria-hidden
style={{ y: floatY1 }}
className="absolute top-32 right-[6%] hidden lg:block"
>
<FloatingCorn className="w-16 text-[#1A4D2E]/15 rotate-12" />
</motion.div>
<motion.div
aria-hidden
style={{ y: floatY2 }}
className="absolute bottom-20 left-[4%] hidden lg:block"
>
<FloatingCorn className="w-12 text-[#E5A80F]/25 -rotate-12" />
</motion.div>
<div className="relative mx-auto max-w-6xl px-5 sm:px-6 lg:px-8 pt-10 sm:pt-14 lg:pt-20 pb-8 lg:pb-12">
{/* Breadcrumb */}
<nav className="flex items-center gap-1.5 text-[11px] font-mono uppercase tracking-widest text-stone-500">
<Link href="/tuxedo" className="hover:text-stone-900 transition-colors">
Tuxedo Corn
</Link>
<Chevron />
<Link href="/tuxedo#products" className="hover:text-stone-900 transition-colors">
Shop
</Link>
<Chevron />
<span className="text-stone-900">Sweet Corn Box</span>
</nav>
<div className="mt-8 lg:mt-12 grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-14 items-start">
{/* LEFT — Visual */}
<div className="lg:col-span-7 relative">
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: EASE_OUT }}
className="relative aspect-[4/3] sm:aspect-[5/4] lg:aspect-[6/5] w-full overflow-hidden rounded-[28px] ring-1 ring-stone-900/10 shadow-[0_30px_60px_-20px_rgba(26,77,46,0.35)]"
>
{/* Gradient fallback (always behind, visible until image loads or on error) */}
<div
className="absolute inset-0"
style={{ background: PRODUCT.fallbackGradient }}
/>
{/* Decorative corn cobs inside the image frame */}
<div className="absolute inset-0 flex items-center justify-center">
<CornIllustration className="w-3/5 max-w-[420px] text-[#1A4D2E]/55" />
</div>
{!imgError && (
<Image
src={PRODUCT.imageUrl}
alt="Freshly harvested Olathe Sweet sweet corn"
fill
priority
sizes="(min-width: 1024px) 58vw, 100vw"
className={`object-cover transition-opacity duration-700 ${
imgLoaded ? "opacity-100" : "opacity-0"
}`}
onLoad={() => setImgLoaded(true)}
onError={() => setImgError(true)}
/>
)}
{/* Bottom scrim for badge legibility */}
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-black/35 to-transparent pointer-events-none" />
{/* Tilted corner stamp */}
<motion.div
initial={{ opacity: 0, rotate: 0, scale: 0.6 }}
animate={{ opacity: 1, rotate: -8, scale: 1 }}
transition={{ delay: 0.55, type: "spring", stiffness: 200, damping: 14 }}
className="absolute top-5 left-5 lg:top-7 lg:left-7 origin-top-left"
>
<div className="flex h-[88px] w-[88px] lg:h-[100px] lg:w-[100px] flex-col items-center justify-center rounded-full border-2 border-dashed border-white/85 bg-[#1A4D2E]/90 text-white text-center shadow-lg backdrop-blur-sm">
<span className="font-mono text-[8px] uppercase tracking-[0.18em] text-white/70">
Peak
</span>
<span className="font-display text-[20px] lg:text-[22px] font-black leading-none">
SEASON
</span>
<span className="font-mono text-[8px] uppercase tracking-[0.18em] text-white/70">
2026
</span>
</div>
</motion.div>
{/* Right corner "12 ears" badge */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4, duration: 0.5 }}
className="absolute bottom-5 right-5 lg:bottom-7 lg:right-7"
>
<div className="rounded-2xl bg-white/95 px-3.5 py-2 ring-1 ring-stone-900/10 shadow-lg backdrop-blur-sm">
<p className="font-mono text-[9px] uppercase tracking-widest text-stone-500 leading-none">
Box contains
</p>
<p className="font-display text-[22px] font-black text-stone-950 leading-none mt-0.5">
12 ears
</p>
</div>
</motion.div>
</motion.div>
{/* Thumbnails row (decorative — same image) */}
<div className="mt-4 flex gap-3">
{[0, 1, 2].map((i) => (
<button
key={i}
aria-label={`View image ${i + 1}`}
className={`relative aspect-square w-16 sm:w-20 overflow-hidden rounded-xl ring-2 transition-all ${
i === 0
? "ring-[#1A4D2E]"
: "ring-stone-900/10 hover:ring-stone-900/30"
}`}
>
<div
className="absolute inset-0"
style={{ background: PRODUCT.fallbackGradient }}
/>
<CornIllustration className="absolute inset-0 m-auto w-3/5 text-[#1A4D2E]/55" />
</button>
))}
<div className="ml-auto hidden sm:flex items-center gap-1.5 rounded-xl bg-stone-900/5 px-3 py-2 text-[10px] font-mono uppercase tracking-widest text-stone-600">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
Lot 04-26 · In season
</div>
</div>
</div>
{/* RIGHT — Copy + Buy Box */}
<div className="lg:col-span-5">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.1, ease: EASE_OUT }}
>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full bg-[#1A4D2E] px-3 py-1 text-[10px] font-bold uppercase tracking-widest text-white">
<span className="h-1.5 w-1.5 rounded-full bg-[#FCD34D] animate-pulse" />
Ships fresh
</span>
<span className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
Same-day harvest when possible
</span>
</div>
<h1 className="mt-5 font-display text-[44px] sm:text-[52px] lg:text-[60px] font-black leading-[0.95] tracking-[-0.02em] text-stone-950">
<span className="block">Sweet Corn</span>
<span className="block italic font-normal text-[#1A4D2E]">Box</span>
</h1>
<p className="mt-4 font-display text-[19px] sm:text-[21px] leading-[1.35] text-stone-700 italic">
{PRODUCT.tagline}
</p>
<p className="mt-5 text-[15px] leading-relaxed text-stone-700 max-w-prose">
Our 12-Ear Corn Box is packed with peak-season, super-sweet corn picked
that morning and rushed to your door. Perfect for BBQs, family dinners,
or freezing for winter.
</p>
{/* Why our corn (inline) */}
<ul className="mt-6 space-y-2.5">
{WHY_CORN.map((item) => (
<li key={item.title} className="flex items-start gap-3">
<span className="mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[#1A4D2E] text-white">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</span>
<div>
<span className="font-bold text-stone-950 text-[14px]">
{item.title}
</span>
<span className="text-stone-600 text-[14px]">
{item.body && <> {item.body}</>}
</span>
</div>
</li>
))}
</ul>
{/* Price + buy box (desktop sticky) */}
<div
id="buy-box"
className="mt-8 lg:mt-10 rounded-3xl bg-white ring-1 ring-stone-900/10 p-6 sm:p-7 shadow-[0_20px_40px_-12px_rgba(26,77,46,0.18)]"
>
<div className="flex items-end justify-between gap-4">
<div>
<p className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
One box · 12 ears
</p>
<p className="mt-1 font-display text-[44px] font-black leading-none tracking-tight text-stone-950">
{PRODUCT.price}
</p>
<p className="mt-1 text-[11px] text-stone-500">
$2.50 / ear · tax included
</p>
</div>
<div className="text-right">
<p className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
In stock
</p>
<p className="mt-1 font-display text-[20px] font-bold text-[#1A4D2E]">
240 boxes
</p>
<p className="text-[10px] text-stone-500">ready this week</p>
</div>
</div>
{/* Quantity */}
<div className="mt-5 flex items-center gap-3">
<span className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
Qty
</span>
<div className="inline-flex items-center rounded-full ring-1 ring-stone-900/15 bg-stone-50">
<button
aria-label="Decrease quantity"
className="h-10 w-10 flex items-center justify-center text-stone-700 hover:text-stone-950 hover:bg-stone-100 rounded-l-full transition-colors"
onClick={() => {
/* single-product page — qty is always 1 for now */
}}
>
</button>
<span className="w-10 text-center font-bold text-stone-950">1</span>
<button
aria-label="Increase quantity"
className="h-10 w-10 flex items-center justify-center text-stone-700 hover:text-stone-950 hover:bg-stone-100 rounded-r-full transition-colors"
>
+
</button>
</div>
<span className="ml-auto text-[12px] text-stone-500 hidden sm:inline">
Ships in a compostable cooler
</span>
</div>
{/* Add to Cart (primary) */}
<motion.button
onClick={handleAddToCart}
whileTap={{ scale: 0.98 }}
className={`mt-5 relative w-full overflow-hidden rounded-2xl h-14 sm:h-16 font-bold text-[15px] sm:text-[16px] tracking-wide transition-all ${
justAdded
? "bg-[#1A4D2E] text-white"
: "bg-stone-950 text-white hover:bg-stone-800"
}`}
>
<AnimatePresence mode="wait">
{justAdded ? (
<motion.span
key="added"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.18 }}
className="flex items-center justify-center gap-2"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Added to cart
</motion.span>
) : (
<motion.span
key="add"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.18 }}
className="flex items-center justify-center gap-2"
>
Add Box to Cart · {PRODUCT.price}
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</motion.span>
)}
</AnimatePresence>
</motion.button>
{/* Buy Now (secondary, urgent orange) */}
<motion.button
onClick={handleBuyNow}
whileTap={{ scale: 0.98 }}
className={`mt-3 group relative w-full overflow-hidden rounded-2xl h-12 sm:h-[52px] font-bold text-[14px] tracking-wide transition-all ${
buyNowPulse
? "bg-[#D97706] text-white"
: "bg-[#FFF1D6] text-[#9A4A06] ring-1 ring-[#E5A80F]/40 hover:bg-[#FFE7B0]"
}`}
>
<span className="flex items-center justify-center gap-2">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" />
</svg>
Quick Buy Ships Today
<span className="ml-1 font-mono text-[10px] font-medium uppercase tracking-widest opacity-70 hidden sm:inline">
1-click
</span>
</span>
</motion.button>
{/* Express row (visual only) */}
<div className="mt-4 grid grid-cols-3 gap-2">
<ExpressChip label="Apple Pay" />
<ExpressChip label="Google Pay" />
<ExpressChip label="Shop Pay" />
</div>
<p className="mt-4 text-center text-[11px] text-stone-500">
One click. One box. Farm-fresh delivered.
</p>
</div>
</motion.div>
</div>
</div>
</div>
</section>
{/* ── GUARANTEE / TRUST STRIP ─────────────────────────────────────── */}
<section className="relative bg-[#1A4D2E] text-white overflow-hidden">
<div
aria-hidden
className="absolute inset-0 opacity-[0.08]"
style={{
background:
"radial-gradient(ellipse at 20% 50%, rgba(252, 211, 77, 0.4) 0%, transparent 50%), radial-gradient(ellipse at 80% 50%, rgba(252, 211, 77, 0.3) 0%, transparent 50%)",
}}
/>
<div className="relative mx-auto max-w-6xl px-5 sm:px-6 lg:px-8 py-12 sm:py-16">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10">
<TrustColumn
icon={<TruckIcon />}
eyebrow="Ships Fresh"
title="Same-day harvest when possible"
body="We pick before the heat steals a single calorie of sweetness. By the time your order arrives, the corn was still in the field that morning."
/>
<TrustColumn
icon={<ShieldIcon />}
eyebrow="Satisfaction"
title="100% Sweetness Guarantee"
body="If its not the sweetest corn youve ever had, well replace it. No questions, no forms, no fuss."
accent
/>
<TrustColumn
icon={<SparkleIcon />}
eyebrow="Versatile"
title="Great for every summer table"
body="Grilling, boiling, freezing, or corn-on-the-cob parties — twelve ears is exactly the right amount for a family of four to share."
/>
</div>
</div>
</section>
{/* ── FARM STORY ─────────────────────────────────────────────────── */}
<section className="relative bg-[#F3EDD8]">
<div className="mx-auto max-w-6xl px-5 sm:px-6 lg:px-8 py-20 sm:py-28">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center">
<div className="lg:col-span-5">
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[#9A4A06]">
Field notes · Vol. IV
</p>
<h2 className="mt-4 font-display text-[36px] sm:text-[44px] lg:text-[52px] font-black leading-[1.02] tracking-tight text-stone-950">
Picked by hand, <span className="italic font-normal text-[#1A4D2E]">stayed</span> sweet for days.
</h2>
<p className="mt-5 text-stone-700 text-[16px] leading-relaxed max-w-prose">
We hand-select every ear for size and quality. No machine knows when a
cob is at peak sugar content. Thats why your box is a little heavier
than you expect and a whole lot sweeter than any corn youve bought
from a grocery store shelf.
</p>
<div className="mt-8 flex flex-wrap items-center gap-6">
<Stat number="40+" label="Years growing" />
<span className="h-10 w-px bg-stone-900/15" />
<Stat number="3" label="Generations" />
<span className="h-10 w-px bg-stone-900/15" />
<Stat number="100%" label="Hand-picked" />
</div>
</div>
<div className="lg:col-span-7 relative">
<div className="relative aspect-[5/4] overflow-hidden rounded-[28px] ring-1 ring-stone-900/10 shadow-[0_30px_60px_-20px_rgba(26,77,46,0.35)]">
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, #FCD34D 0%, #E5A80F 45%, #8A5A06 100%)",
}}
/>
{/* Abstract corn field lines */}
<svg
viewBox="0 0 400 320"
className="absolute inset-0 w-full h-full"
aria-hidden
>
<defs>
<pattern id="rows" width="50" height="20" patternUnits="userSpaceOnUse" patternTransform="rotate(-8)">
<line x1="0" y1="10" x2="50" y2="10" stroke="#1A4D2E" strokeWidth="2" strokeLinecap="round" />
</pattern>
</defs>
<rect width="400" height="320" fill="url(#rows)" opacity="0.45" />
</svg>
{/* Floating cob clusters */}
<CornIllustration className="absolute -left-6 top-1/2 -translate-y-1/2 w-32 text-[#1A4D2E]/85 rotate-[-8deg]" />
<CornIllustration className="absolute right-6 top-8 w-24 text-[#1A4D2E]/75 rotate-[12deg]" />
<CornIllustration className="absolute right-16 bottom-8 w-20 text-[#1A4D2E]/70 -rotate-6" />
{/* Sun stamp */}
<div className="absolute top-5 right-5 h-20 w-20 rounded-full bg-[#FFE08A] ring-4 ring-[#1A4D2E]/15 flex items-center justify-center">
<span className="font-display text-[10px] font-bold text-[#1A4D2E] text-center leading-tight">
HAND
<br />
PICKED
</span>
</div>
{/* Bottom data strip */}
<div className="absolute inset-x-4 bottom-4 rounded-2xl bg-stone-950/90 backdrop-blur-sm px-4 py-3 flex items-center justify-between text-white text-[11px] font-mono">
<div>
<span className="text-white/50">FIELD</span> · 04-N
</div>
<div>
<span className="text-white/50">BRIX</span> · 24.2°
</div>
<div>
<span className="text-white/50">PICK</span> · 06:14
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* ── FAQ MICRO ──────────────────────────────────────────────────── */}
<section className="relative bg-[#FAF6EA]">
<div className="mx-auto max-w-4xl px-5 sm:px-6 lg:px-8 py-20 sm:py-24">
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[#9A4A06] text-center">
Buyers notes
</p>
<h2 className="mt-4 font-display text-[36px] sm:text-[44px] font-black leading-tight text-center text-stone-950">
Questions, answered.
</h2>
<div className="mt-12 divide-y divide-stone-900/10 border-t border-b border-stone-900/10">
{FAQS.map((faq) => (
<FAQItem key={faq.q} q={faq.q} a={faq.a} />
))}
</div>
</div>
</section>
{/* ── FINAL CTA STRIP (desktop) ─────────────────────────────────── */}
<section className="hidden lg:block relative bg-stone-950 text-white overflow-hidden">
<div
aria-hidden
className="absolute inset-0 opacity-30"
style={{
background:
"radial-gradient(ellipse at 30% 50%, rgba(229, 168, 15, 0.5) 0%, transparent 60%), radial-gradient(ellipse at 80% 50%, rgba(26, 77, 46, 0.6) 0%, transparent 60%)",
}}
/>
<div className="relative mx-auto max-w-6xl px-8 py-16 flex items-center justify-between gap-10">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[#FCD34D]">
Ready when you are
</p>
<h3 className="mt-3 font-display text-[40px] font-black leading-[1.05]">
Twelve ears. <span className="italic font-normal">One click.</span>
</h3>
<p className="mt-2 text-stone-400 max-w-md">
Free shipping on orders over $40. Cancel anytime before harvest.
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<button
onClick={handleAddToCart}
className="rounded-2xl h-14 px-7 bg-white text-stone-950 font-bold hover:bg-stone-100 active:scale-[0.98] transition-all"
>
Add to cart {PRODUCT.price}
</button>
<button
onClick={handleBuyNow}
className="rounded-2xl h-14 px-7 bg-[#FCD34D] text-stone-950 font-bold hover:bg-[#FBBF24] active:scale-[0.98] transition-all"
>
Quick Buy
</button>
</div>
</div>
</section>
</main>
{/* ── STICKY MOBILE BOTTOM CTA BAR ──────────────────────────────────── */}
<div className="lg:hidden fixed inset-x-0 bottom-0 z-40 bg-[#FAF6EA]/95 backdrop-blur-xl border-t border-stone-900/10 shadow-[0_-12px_30px_-12px_rgba(26,77,46,0.3)]">
<div className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<p className="font-mono text-[9px] uppercase tracking-widest text-stone-500 leading-none">
12-Ear Box
</p>
<p className="mt-1 font-display text-[26px] font-black leading-none text-stone-950">
{PRODUCT.price}
</p>
</div>
<button
onClick={handleBuyNow}
className="h-14 min-w-[120px] rounded-2xl bg-[#FFF1D6] text-[#9A4A06] font-bold text-[14px] ring-1 ring-[#E5A80F]/40 active:scale-[0.98] transition-all"
>
Quick Buy
</button>
<button
onClick={handleAddToCart}
className="h-14 min-w-[160px] rounded-2xl bg-stone-950 text-white font-bold text-[15px] active:scale-[0.98] transition-all"
>
Add · {PRODUCT.price}
</button>
</div>
{/* Safe-area spacer for iOS */}
<div className="h-[env(safe-area-inset-bottom)]" />
</div>
{/* Bottom padding so sticky bar doesn't cover last content on mobile */}
<div className="lg:hidden h-28" aria-hidden />
<StorefrontFooter
brandName={brandName}
brandSlug="tuxedo"
logoUrl={logoUrl}
logoUrlDark={logoUrlDark}
brandAccent="green"
/>
<QuickCartSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
productName={PRODUCT.name}
/>
</div>
);
}
/* ── Sub-components ─────────────────────────────────────────────── */
function TrustColumn({
icon,
eyebrow,
title,
body,
accent,
}: {
icon: React.ReactNode;
eyebrow: string;
title: string;
body: string;
accent?: boolean;
}) {
return (
<div className="relative">
<div
className={`flex h-12 w-12 items-center justify-center rounded-2xl ${
accent ? "bg-[#FCD34D] text-stone-950" : "bg-white/10 text-[#FCD34D]"
} ring-1 ring-white/15`}
>
{icon}
</div>
<p
className={`mt-5 font-mono text-[10px] uppercase tracking-[0.25em] ${
accent ? "text-[#FCD34D]" : "text-white/60"
}`}
>
{eyebrow}
</p>
<h3 className="mt-2 font-display text-[24px] font-bold leading-[1.15] text-white">
{title}
</h3>
<p className="mt-3 text-[14px] leading-relaxed text-white/75 max-w-sm">{body}</p>
</div>
);
}
function Stat({ number, label }: { number: string; label: string }) {
return (
<div>
<p className="font-display text-[40px] font-black leading-none text-stone-950">
{number}
</p>
<p className="mt-1 font-mono text-[10px] uppercase tracking-widest text-stone-500">
{label}
</p>
</div>
);
}
function FAQItem({ q, a }: { q: string; a: string }) {
const [open, setOpen] = useState(false);
return (
<div className="py-5">
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between gap-4 text-left group"
>
<span className="font-display text-[18px] sm:text-[20px] font-bold text-stone-950 group-hover:text-[#1A4D2E] transition-colors">
{q}
</span>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ring-1 ring-stone-900/15 transition-transform duration-300 ${
open ? "rotate-45 bg-stone-950 text-white" : "text-stone-700"
}`}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className="overflow-hidden"
>
<p className="pt-3 text-[15px] leading-relaxed text-stone-700 max-w-prose">{a}</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function Chevron() {
return (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
);
}
function ExpressChip({ label }: { label: string }) {
return (
<div className="flex h-9 items-center justify-center rounded-lg bg-stone-100 ring-1 ring-stone-900/5 text-[11px] font-semibold text-stone-700">
{label}
</div>
);
}
function TruckIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7h11v10H3z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M14 10h4l3 3v4h-7z" />
<circle cx="7" cy="18" r="2" />
<circle cx="17" cy="18" r="2" />
</svg>
);
}
function ShieldIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 2l8 4v6c0 5-3.5 9-8 10-4.5-1-8-5-8-10V6l8-4z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4" />
</svg>
);
}
function SparkleIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3l2 5 5 2-5 2-2 5-2-5-5-2 5-2 2-5z" />
</svg>
);
}
function CornIllustration({ className }: { className?: string }) {
return (
<svg viewBox="0 0 100 140" className={className} aria-hidden="true">
{/* Husk leaves */}
<path
d="M30 70c-12-15-15-40-5-60 5 18 15 30 25 38"
fill="currentColor"
opacity="0.55"
/>
<path
d="M70 70c12-15 15-40 5-60-5 18-15 30-25 38"
fill="currentColor"
opacity="0.55"
/>
<path
d="M50 75c-3-20 0-50 5-65 1 20-1 45-3 60"
fill="currentColor"
opacity="0.45"
/>
{/* Cob */}
<ellipse cx="50" cy="78" rx="20" ry="34" fill="currentColor" />
{/* Kernels */}
<g fill="#FAF6EA" opacity="0.75">
{Array.from({ length: 8 }).map((_, row) =>
Array.from({ length: 5 }).map((_, col) => {
const x = 50 - 14 + col * 7 + (row % 2 ? 3.5 : 0);
const y = 55 + row * 6;
return <circle key={`${row}-${col}`} cx={x} cy={y} r="1.6" />;
})
)}
</g>
{/* Silk at top */}
<g stroke="currentColor" strokeWidth="1.4" fill="none" opacity="0.7" strokeLinecap="round">
<path d="M50 45c-6-8-12-12-18-12" />
<path d="M50 45c0-10 4-15 8-20" />
<path d="M50 45c6-6 12-8 18-10" />
</g>
</svg>
);
}
function FloatingCorn({ className }: { className?: string }) {
return (
<svg viewBox="0 0 60 80" className={className} aria-hidden="true">
<ellipse cx="30" cy="40" rx="14" ry="24" fill="currentColor" />
<g fill="white" opacity="0.3">
<circle cx="26" cy="28" r="1.2" />
<circle cx="32" cy="32" r="1.2" />
<circle cx="28" cy="38" r="1.2" />
<circle cx="34" cy="42" r="1.2" />
<circle cx="26" cy="48" r="1.2" />
<circle cx="32" cy="52" r="1.2" />
<circle cx="28" cy="58" r="1.2" />
</g>
</svg>
);
}
/* ── Static data ─────────────────────────────────────────────── */
const WHY_CORN = [
{ title: "Naturally grown & non-GMO", body: "no lab has touched this seed" },
{ title: "Harvested at peak sugar content", body: "before the heat can steal a calorie" },
{ title: "Hand-selected for size & quality", body: "the only machine we trust is a cooler" },
{ title: "Stays sweet for days in the fridge", body: "and freezes beautifully for winter" },
];
const FAQS = [
{
q: "How fast will my box ship?",
a: "Same-day harvest when the weather cooperates. Youll get a tracking link the moment the cooler leaves the farm — typically within 24 hours of your order.",
},
{
q: "Can I pick up at a local stop instead?",
a: "Yes — at checkout, choose pickup and select a stop near you. We deliver to scheduled stops in Colorado, Utah, New Mexico, and Arizona through the summer.",
},
{
q: "What if my corn isnt the sweetest Ive ever had?",
a: "We replace it. Send a photo to hello@tuxedocorn.com and well send a fresh box the next morning. No forms, no questions.",
},
{
q: "How long does it stay fresh?",
a: "Seven days in the fridge with the husks on. Three months in the freezer if you blanch it for three minutes first.",
},
];
+1 -31
View File
@@ -20,8 +20,6 @@ type StopInfo = {
brand_id: string;
};
export type { StopInfo };
type CartContextType = {
cart: CartItem[];
subtotal: number;
@@ -32,12 +30,6 @@ type CartContextType = {
cartRestored: boolean; // true when server cart was loaded (for toast)
setSelectedStop: (stop: StopInfo | null) => void;
addToCart: (item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship") => void;
/**
* Replaces the cart with a single item (clears other items + selected stop)
* and returns the resulting item so the caller can navigate to /checkout
* for a true 1-tap "Buy Now" flow.
*/
buyNow: (item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship") => CartItem;
increaseQuantity: (id: string) => void;
decreaseQuantity: (id: string) => void;
removeFromCart: (id: string) => void;
@@ -209,27 +201,6 @@ export function CartProvider({ children }: { children: ReactNode }) {
});
}
/**
* Replaces the entire cart with this single item. Use for "Buy Now" /
* "Quick Buy" flows where the buyer wants to skip the cart step and go
* straight to checkout. The returned CartItem reflects the final state
* (existing items of the same product are merged by quantity).
*/
function buyNow(item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship"): CartItem {
// Always clear selected stop on a buy-now — the buyer is bypassing the
// standard stop-picker flow and will pick a stop at checkout.
setSelectedStop(null);
const finalItem: CartItem = {
...item,
quantity: 1,
fulfillment: fulfillment ?? "pickup",
};
setCart([finalItem]);
setJustAdded(finalItem);
return finalItem;
}
function increaseQuantity(id: string) {
setCart((prev) =>
prev.map((item) => (item.id === id ? { ...item, quantity: item.quantity + 1 } : item))
@@ -320,7 +291,6 @@ export function CartProvider({ children }: { children: ReactNode }) {
cartRestored: showRestoredToast,
setSelectedStop: setSelectedStop,
addToCart,
buyNow,
increaseQuantity,
decreaseQuantity,
removeFromCart,
@@ -336,7 +306,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
);
}
export function useCart(): CartContextType {
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used inside CartProvider");
return context;
+2 -12
View File
@@ -1,19 +1,9 @@
// Shared AdminUser type — safe to import from both server and client components
//
// `brand_id` is the active brand (one of `brand_ids`, or null for platform_admin).
// `brand_ids` is the full list of brands the admin can act in.
// - platform_admin: `brand_id = null`, `brand_ids = []` (in dev) or all brands
// (resolved by `listBrandsForAdmin`).
// - multi_brand_admin: `brand_id` = selected/cookie brand, `brand_ids` = 2+.
// - brand_admin / store_employee / staff: `brand_id` = their single brand,
// `brand_ids = [that one]`.
export type AdminUser = {
id?: string;
user_id: string;
brand_id: string | null;
brand_ids: string[];
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
active: boolean;
role: "platform_admin" | "brand_admin" | "store_employee" | "staff";
can_manage_products: boolean;
can_manage_stops: boolean;
can_manage_orders: boolean;
@@ -25,4 +15,4 @@ export type AdminUser = {
can_manage_reports: boolean;
can_manage_settings: boolean;
must_change_password?: boolean;
};
};
+27 -87
View File
@@ -1,21 +1,24 @@
import { cookies } from "next/headers";
import type { AdminUser } from "./admin-permissions-types";
export type { AdminUser } from "./admin-permissions-types";
/**
* Returns the current admin user, or `null` if not authenticated.
*
* Resolution order:
* 1. Mock data mode (NEXT_PUBLIC_USE_MOCK_DATA=true) platform_admin dev.
* 2. `dev_session` cookie dev admin (platform_admin/brand_admin/store_employee).
* 3. Real auth (rc_auth_uid or rc_uid cookie) load admin_users + brand_ids.
*
* `brand_id` is the active brand; `brand_ids` is the full membership list.
* For dev sessions without a real DB, `brand_ids` is populated by:
* - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table)
* - store_employee: `[<first real brand>]` if a brand exists, else `[]`
* - brand_admin: `[]` (legacy dev; we don't have a way to scope brand in dev)
*/
export type AdminUser = {
id: string;
user_id: string;
brand_id: string | null;
role: string;
active: boolean;
can_manage_products: boolean;
can_manage_stops: boolean;
can_manage_orders: boolean;
can_manage_pickup: boolean;
can_manage_messages: boolean;
can_manage_refunds: boolean;
can_manage_users: boolean;
can_manage_water_log: boolean;
can_manage_reports: boolean;
can_manage_settings: boolean;
must_change_password: boolean;
};
export async function getAdminUser(): Promise<AdminUser | null> {
const cookieStore = await cookies();
@@ -73,7 +76,7 @@ export async function getAdminUser(): Promise<AdminUser | null> {
if (res.ok) {
const inserted = await res.json().catch(() => null);
if (inserted && inserted.length > 0) {
return buildAdminUser(inserted[0] as Record<string, unknown>, []);
return buildAdminUser(inserted[0] as Record<string, unknown>);
}
}
} catch (e) {
@@ -85,56 +88,11 @@ export async function getAdminUser(): Promise<AdminUser | null> {
const admin = adminUsers[0] as Record<string, unknown>;
if (!admin.active) return null;
// Load brand_ids from the admin_user_brands junction
const brandIds = await fetchAdminUserBrandIds(supabaseUrl, serviceKey, admin.id as string);
return buildAdminUser(admin, brandIds);
}
/**
* Load `brand_ids` from the admin_user_brands junction for the given admin row.
* Returns an empty array on any failure (e.g. before migration 207 is applied).
*/
async function fetchAdminUserBrandIds(
supabaseUrl: string,
serviceKey: string,
adminRowId: string
): Promise<string[]> {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_user_brands?admin_user_id=eq.${adminRowId}&select=brand_id`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
if (!res.ok) return [];
const data = await res.json().catch(() => []);
if (!Array.isArray(data)) return [];
return data
.map((row: Record<string, unknown>) => row.brand_id as string)
.filter((id): id is string => typeof id === "string");
} catch {
return [];
}
return buildAdminUser(admin);
}
function buildDevAdmin(role: string): AdminUser {
// For dev sessions we don't have an admin_user_brands junction row to load.
// - platform_admin: `brand_ids = []` (listBrandsForAdmin resolves against brands).
// - store_employee: `brand_ids = []` (dev AdminAccessDenied is acceptable;
// this is the documented limitation — re-read spec section on getAdminUser
// step 1. We skip the spec's "fetch first real brand" complexity here in
// favour of keeping dev session cheap and DB-independent).
// - brand_admin: `brand_ids = []` (same rationale).
// `role` is narrowed to the strict union — we know the dev callers pass
// only valid values.
const base = {
id: "dev",
user_id: "dev",
brand_id: null,
brand_ids: [] as string[],
role: role as AdminUser["role"],
active: true,
must_change_password: false,
};
const base = { id: "dev", user_id: "dev", brand_id: null, role, active: true, must_change_password: false };
if (role === "store_employee") {
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
@@ -145,28 +103,10 @@ function buildDevAdmin(role: string): AdminUser {
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true };
}
function buildAdminUser(r: Record<string, unknown>, brandIds: string[]): AdminUser {
// The DB column is TEXT (per CLAUDE.md) so the runtime value is a string.
// We narrow it to the known union here. If the DB has an unknown role
// (e.g. a future role), the migration's CHECK constraint will reject it
// before it ever reaches this function.
const role = r.role as AdminUser["role"];
// `brand_id` is the *legacy* single-brand column — preserved here as-is.
// The canonical "active brand" is resolved by `getActiveBrandId` on each
// page/action, which considers URL params, the active_brand_id cookie,
// and this legacy fallback. Setting `brand_id` here to a sensible default
// (legacy → first of brand_ids) keeps the AdminUser shape useful even
// for callers that haven't migrated to `getActiveBrandId` yet.
const legacyBrandId = (r.brand_id as string | null) ?? null;
const base = {
id: r.id as string,
user_id: r.user_id as string,
brand_id: legacyBrandId ?? brandIds[0] ?? null,
brand_ids: brandIds,
role,
active: r.active as boolean,
must_change_password: Boolean(r.must_change_password),
};
function buildAdminUser(r: Record<string, unknown>): AdminUser {
const role = r.role as string;
const base = { id: r.id as string, user_id: r.user_id as string, brand_id: r.brand_id as string | null,
role, active: r.active as boolean, must_change_password: Boolean(r.must_change_password) };
if (role === "platform_admin") {
return { ...base, can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
@@ -182,4 +122,4 @@ function buildAdminUser(r: Record<string, unknown>, brandIds: string[]): AdminUs
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
}
}
-136
View File
@@ -1,136 +0,0 @@
import NextAuth from "next-auth";
import PostgresAdapter from "@auth/pg-adapter";
import { Pool } from "pg";
import Credentials from "next-auth/providers/credentials";
import {
authConfig,
isDevLoginEnabled,
} from "@/auth.config";
/**
* Build the dev Credentials provider. Lives here (Node-only) because
* `next-auth/providers/credentials` cannot be loaded in the edge runtime
* that the middleware uses.
*/
function buildDevCredentialsProvider() {
return Credentials({
id: "dev-login",
name: "Dev login",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(creds) {
if (!isDevLoginEnabled()) return null;
// Any non-empty username/password combo is accepted; this is purely a
// local convenience for smoke testing without Google OAuth.
const username = String(creds?.username ?? "").trim();
const password = String(creds?.password ?? "");
if (!username || !password) return null;
return {
id: `dev-${username.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
name: username,
email: `${username}@dev.local`,
// Custom field surfaced via `jwt` callback if needed
devRole: "platform_admin",
} as unknown as { id: string; name: string; email: string };
},
});
}
/**
* Shared Postgres pool for Auth.js. Reuses the same database the rest of
* the app talks to (via `pg`). Lives behind a module-level singleton so
* Next.js hot reload doesn't open a new pool on every request.
*
* Note: in production, `DATABASE_URL` should be the only DB env var. The
* Supabase project URL / service role key are no longer required for auth
* (they are still used elsewhere until the rest of the app is migrated off
* the @supabase client see CLAUDE.md).
*/
const globalForPool = globalThis as unknown as { __pgPool?: Pool };
function getPool(): Pool {
if (globalForPool.__pgPool) return globalForPool.__pgPool;
const connectionString =
process.env.DATABASE_URL ??
process.env.SUPABASE_DB_URL ??
process.env.POSTGRES_URL;
if (!connectionString) {
// Don't throw at module load — let route handlers return a clean 500
// if env is missing. The smoke test instructions tell the user to
// set DATABASE_URL.
// eslint-disable-next-line no-console
console.warn(
"[auth] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — Auth.js database adapter will not be wired up."
);
}
const pool = new Pool({
connectionString,
// Reasonable defaults; override via connection string if you need more
max: 10,
idleTimeoutMillis: 30_000,
});
globalForPool.__pgPool = pool;
return pool;
}
/**
* Final server-side Auth.js config.
*
* Builds on `authConfig` (edge-safe) and layers on:
* 1. The Postgres database adapter
* 2. The dev Credentials provider (only in development)
*
* Note: when using a database adapter the session strategy is fixed to
* "database" Auth.js will persist sessions in the `sessions` table.
*/
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
// Use JWT sessions to match the edge-friendly config in `authConfig`.
// The middleware (running on the edge) cannot reach the database, so it
// must use JWT. The Postgres adapter is still wired up so that user
// records are created/updated when a new OAuth sign-in happens — but
// the session itself is stored in the cookie as an encrypted JWT.
adapter: PostgresAdapter(getPool()),
// `session.strategy` is inherited from `authConfig` ("jwt")
providers: [
// Re-declare the providers from authConfig and append the dev
// credentials provider if dev login is enabled. (NextAuth merges by
// provider id, so this overrides the edge stubs.)
...authConfig.providers,
...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []),
],
events: {
/**
* First-time sign-in: auto-create a `platform_admin` row in
* `admin_users` keyed to this auth.js user id, mirroring the legacy
* `rc_auth_uid` flow. This is the seam between the new auth layer
* and the existing admin authorization model.
*/
async signIn({ user }) {
try {
const pool = getPool();
const userId = user.id;
if (!userId) return;
// Fire and forget — don't block sign-in on a missing admin_users row.
await pool.query(
`SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`,
[userId]
);
// Note: we don't auto-create here; the existing `getAdminUser()`
// in `src/lib/admin-permissions.ts` is the source of truth for
// role lookups and is unchanged. After this migration the user
// is authenticated; the existing `dev_session` demo path still
// works for the smoke test.
} catch (e) {
// eslint-disable-next-line no-console
console.warn("[auth] signIn event error (non-fatal):", e);
}
},
},
});
-90
View File
@@ -1,90 +0,0 @@
/**
* Brand-scope helpers for multi-brand admin support.
*
* Resolution order (documented in
* docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md):
* 1. URL/explicit `requested` brand id (highest priority)
* 2. `active_brand_id` cookie (the persistent "what brand am I in right now")
* 3. `adminUser.brand_id` (legacy single-brand fallback)
* 4. First of `adminUser.brand_ids`
* 5. (platform_admin only) `null` "all brands"
*
* For non-platform-admins, the returned brand is validated against
* `adminUser.brand_ids` if `requested` or the cookie brand is not in the
* admin's accessible brands, the resolver falls through to a brand the admin
* does have access to (silent recovery).
*/
import "server-only";
import { cookies } from "next/headers";
import type { AdminUser } from "./admin-permissions-types";
export const ACTIVE_BRAND_COOKIE = "active_brand_id";
/**
* Resolve the active brand id for the given admin user.
*
* @param adminUser - The current admin user (must already be loaded).
* @param requested - Optional explicit brand id (e.g. from a URL param).
* When set and the admin has access, wins over cookie.
* @returns The brand id to act in, or `null` for platform_admin "all brands".
*/
export async function getActiveBrandId(
adminUser: AdminUser,
requested?: string | null
): Promise<string | null> {
const cookieStore = await cookies();
const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null;
// platform_admin: requested > cookie > null (all brands)
if (adminUser.role === "platform_admin") {
return requested ?? cookieBrand ?? null;
}
// Non-platform-admin: validate that requested/cookie brands are accessible
if (requested && adminUser.brand_ids.includes(requested)) {
return requested;
}
if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) {
return cookieBrand;
}
// Fall back to the legacy single brand, then first of the membership list
return adminUser.brand_id ?? adminUser.brand_ids[0] ?? null;
}
/**
* Set the persistent active-brand cookie. Should only be called after
* validating the admin has access (use `assertBrandAccess`).
*/
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
});
}
/**
* Clear the active-brand cookie. Used when platform_admin selects
* "All brands" (the cookie absence = "no specific brand pinned").
*/
export async function clearActiveBrandCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(ACTIVE_BRAND_COOKIE);
}
/**
* Throws if the admin user is not a platform_admin and does not have the
* given brand in their membership list. Use this for server actions and
* API routes that receive a brandId from URL/form/RPC return rather than
* `getActiveBrandId`.
*/
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");
}
}

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