Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32396af193 | |||
| f36419be69 | |||
| 5477b3419f | |||
| 2f3be5426f | |||
| 2d837bc786 | |||
| bb6dbe37a4 | |||
| 3f4f46da7e | |||
| ec1506dc82 | |||
| 2b3fd214d8 | |||
| 858ca0d64d | |||
| 8a91494009 | |||
| b240b7b56e | |||
| fd9d6424d5 | |||
| 0b748adfaa | |||
| 69767eb250 | |||
| 8bca9e86b1 | |||
| 82d81b2f69 | |||
| 7ecb1f2fc0 | |||
| 015b1cf7b5 | |||
| 1b12a0a95d | |||
| b2aa53f274 | |||
| 66c6f45efc | |||
| 6c6b5d3053 | |||
| 73cc7d1dce | |||
| 4763884caf | |||
| bdcaf0f1da | |||
| df6f4181df | |||
| 63842a9efc | |||
| bd623020d5 | |||
| 9b51f5ae29 | |||
| bc29c70e8b | |||
| 1feba25ced | |||
| f26d43ac96 | |||
| 49a9900d15 |
@@ -0,0 +1,82 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# Route Commerce — Environment variables
|
||||||
|
# ============================================================================
|
||||||
|
# Copy to `.env.local` and fill in real values for local development.
|
||||||
|
# Production: set these in your hosting dashboard (Vercel / Netlify / etc.).
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ── App ─────────────────────────────────────────────────────────────────────
|
||||||
|
NEXT_PUBLIC_BASE_URL=http://localhost:4000
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:4000
|
||||||
|
|
||||||
|
# ── Database (Postgres, direct — Supabase is being removed) ────────────────
|
||||||
|
# Single connection string used by `pg.Pool` in src/lib/auth.ts and the
|
||||||
|
# admin-permissions / data-service layer. Format:
|
||||||
|
# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce
|
||||||
|
|
||||||
|
# ── Auth.js (NextAuth v5) ───────────────────────────────────────────────────
|
||||||
|
# Generate with: npx auth secret
|
||||||
|
# Or: openssl rand -base64 32
|
||||||
|
AUTH_SECRET=replace-me-with-a-32-byte-base64-string
|
||||||
|
|
||||||
|
# Base URL used to build OAuth callback URLs. In dev:
|
||||||
|
AUTH_URL=http://localhost:4000
|
||||||
|
# In production, set to https://yourdomain.com
|
||||||
|
|
||||||
|
# Google OAuth provider.
|
||||||
|
# 1. Go to https://console.cloud.google.com/apis/credentials
|
||||||
|
# 2. Create an OAuth 2.0 Client ID (type: Web application)
|
||||||
|
# 3. Add Authorized redirect URI: http://localhost:3000/api/auth/callback/google
|
||||||
|
# 4. Copy client id + client secret below
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Auth.js also reads AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET if you prefer the
|
||||||
|
# default NextAuth variable names. The code in src/auth.config.ts falls
|
||||||
|
# back to those names if GOOGLE_CLIENT_ID is unset.
|
||||||
|
|
||||||
|
# Set to "false" to disable the in-app dev credentials provider even in
|
||||||
|
# development. Default: enabled in dev only.
|
||||||
|
ALLOW_DEV_LOGIN=true
|
||||||
|
|
||||||
|
# ── Supabase (legacy, being removed) ────────────────────────────────────────
|
||||||
|
# Still used by the existing admin pages, server actions, and the
|
||||||
|
# `getAdminUser` flow. Once the auth migration is complete and the
|
||||||
|
# @supabase/* packages are removed, these can go away.
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
|
||||||
|
# ── Stripe ─────────────────────────────────────────────────────────────────
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
|
STRIPE_PRICE_STARTER=
|
||||||
|
STRIPE_PRICE_FARM=
|
||||||
|
STRIPE_PRICE_ENTERPRISE=
|
||||||
|
STRIPE_PRICE_HARVEST_REACH=
|
||||||
|
STRIPE_PRICE_WHOLESALE_PORTAL=
|
||||||
|
STRIPE_PRICE_WATER_LOG=
|
||||||
|
STRIPE_PRICE_AI_TOOLS=
|
||||||
|
STRIPE_PRICE_SQUARE_SYNC=
|
||||||
|
STRIPE_PRICE_SMS_CAMPAIGNS=
|
||||||
|
|
||||||
|
# ── Resend (transactional email) ────────────────────────────────────────────
|
||||||
|
RESEND_API_KEY=
|
||||||
|
RESEND_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# ── AI providers ────────────────────────────────────────────────────────────
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
GOOGLE_API_KEY=
|
||||||
|
XAI_API_KEY=
|
||||||
|
MINIMAX_API_KEY=
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||||
|
|
||||||
|
# ── Square (optional) ───────────────────────────────────────────────────────
|
||||||
|
SQUARE_APP_SECRET=
|
||||||
|
SQUARE_ENVIRONMENT=sandbox
|
||||||
|
|
||||||
|
# ── Cron / automation ───────────────────────────────────────────────────────
|
||||||
|
CRON_SECRET=replace-me-with-a-random-string
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
name: Deploy to route.crispygoat.com
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Start Docker stack
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
|
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||||
|
run: |
|
||||||
|
APP_DIR=/home/tyler/route-commerce
|
||||||
|
mkdir -p $APP_DIR
|
||||||
|
|
||||||
|
# Free the dev-stack port (3001) and the port the previous deploy used
|
||||||
|
# (so a new deploy can pick it back up if it's the lowest free port)
|
||||||
|
PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "")
|
||||||
|
for port in 3001 $PREV_PORT; do
|
||||||
|
if [ -n "$port" ] && ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${port}[[:space:]]"; then
|
||||||
|
echo "Port $port in use, freeing..."
|
||||||
|
fuser -k -9 $port/tcp 2>/dev/null || true
|
||||||
|
docker ps -aq --filter "publish=$port" 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Hard-stop the previous stack. Errors are NOT swallowed: if down
|
||||||
|
# fails, picking a port against a half-torn-down stack is exactly
|
||||||
|
# what produces the TOCTOU "address already in use" we keep hitting.
|
||||||
|
docker compose -f $APP_DIR/docker-compose.yml down --remove-orphans
|
||||||
|
# Belt-and-braces: anything with the postgrest name that survived.
|
||||||
|
docker ps -aq --filter "name=route_commerce_postgrest" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||||
|
# docker-proxy sometimes leaves a listener behind for the published port.
|
||||||
|
pkill -9 -f 'docker-proxy.*3011' 2>/dev/null || true
|
||||||
|
pkill -9 -f 'docker-proxy.*3012' 2>/dev/null || true
|
||||||
|
pkill -9 -f 'docker-proxy.*3013' 2>/dev/null || true
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Verify the postgrest container is actually gone before we pick a port.
|
||||||
|
if docker ps -aq --filter "name=route_commerce_postgrest" | grep -q .; then
|
||||||
|
echo "ERROR: route_commerce_postgrest still running after down"
|
||||||
|
docker ps --filter "name=route_commerce_postgrest"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the first free host port starting from 3011. Persist the choice
|
||||||
|
# so the Build and Deploy steps below can use the same URL.
|
||||||
|
POSTGREST_HOST_PORT=3011
|
||||||
|
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
if ! ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${POSTGREST_HOST_PORT}[[:space:]]"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Port $POSTGREST_HOST_PORT in use, trying next... (attempt $attempt)"
|
||||||
|
POSTGREST_HOST_PORT=$((POSTGREST_HOST_PORT + 1))
|
||||||
|
if [ $POSTGREST_HOST_PORT -gt 30200 ]; then
|
||||||
|
echo "ERROR: no free port in 3011-30200 range"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Using PostgREST host port: $POSTGREST_HOST_PORT"
|
||||||
|
echo "$POSTGREST_HOST_PORT" > .postgrest-port
|
||||||
|
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
|
||||||
|
export POSTGREST_HOST_PORT
|
||||||
|
|
||||||
|
# Seed config files into APP_DIR if missing (they live in the repo, not in APP_DIR)
|
||||||
|
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
|
||||||
|
[ -f $APP_DIR/docker-compose.yml ] || cp deploy/docker-compose.yml $APP_DIR/docker-compose.yml
|
||||||
|
cd $APP_DIR
|
||||||
|
[ -f .env ] || cp .env.example .env
|
||||||
|
# Append production secrets to .env (overriding .env.example defaults)
|
||||||
|
{
|
||||||
|
echo "POSTGRES_USER=${POSTGRES_USER}"
|
||||||
|
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||||
|
echo "POSTGRES_DB=${POSTGRES_DB}"
|
||||||
|
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
|
||||||
|
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
|
||||||
|
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
|
||||||
|
echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT"
|
||||||
|
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL"
|
||||||
|
} >> .env
|
||||||
|
# Bring the stack up fresh — --force-recreate ensures no stale
|
||||||
|
# network/container references from prior failed attempts
|
||||||
|
docker compose up -d --force-recreate db postgrest minio minio_init
|
||||||
|
# Wait for Postgres healthcheck
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then
|
||||||
|
echo "Postgres is ready"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Apply migrations
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
|
run: |
|
||||||
|
APP_DIR=/home/tyler/route-commerce
|
||||||
|
# Seed supabase/ into APP_DIR if missing (the deploy step copies it after, but
|
||||||
|
# we need it here for migrations)
|
||||||
|
[ -d $APP_DIR/supabase ] || cp -r supabase $APP_DIR/supabase
|
||||||
|
cd $APP_DIR
|
||||||
|
# PAGER= prevents psql from launching less/more in a non-interactive shell,
|
||||||
|
# which hangs indefinitely waiting for keypress. Batch all files into one
|
||||||
|
# connection for speed instead of one psql invocation per file.
|
||||||
|
export PAGER=
|
||||||
|
export PGPASSWORD="${POSTGRES_PASSWORD}"
|
||||||
|
PG="psql -h 127.0.0.1 -U ${POSTGRES_USER} -d ${POSTGRES_DB} --no-psqlrc -v ON_ERROR_STOP=0 -q"
|
||||||
|
$PG -f supabase/migrations/000_preflight_supabase_compat.sql || true
|
||||||
|
[ -f supabase/captured_schema.sql ] && $PG -f supabase/captured_schema.sql || true
|
||||||
|
# Concatenate all numbered migrations and run in one session
|
||||||
|
cat supabase/migrations/[0-9]*.sql | $PG
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
|
||||||
|
# Auth.js v5 (NextAuth). Fall back to Better Auth names if the
|
||||||
|
# Gitea secret hasn't been renamed yet.
|
||||||
|
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
|
||||||
|
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
|
||||||
|
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
|
||||||
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
|
||||||
|
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
||||||
|
|
||||||
|
# Supabase (legacy, still used by admin pages/server actions until
|
||||||
|
# the Auth.js migration is finished)
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
|
||||||
|
|
||||||
|
# Storage (MinIO / S3)
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
|
||||||
|
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
|
||||||
|
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
|
||||||
|
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
|
||||||
|
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
|
||||||
|
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||||
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||||
|
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
|
||||||
|
|
||||||
|
# Resend
|
||||||
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
|
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
|
||||||
|
|
||||||
|
# AI providers
|
||||||
|
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||||
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||||
|
|
||||||
|
# Email sender
|
||||||
|
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
||||||
|
run: |
|
||||||
|
POSTGREST_HOST_PORT=$(cat .postgrest-port)
|
||||||
|
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
|
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||||
|
|
||||||
|
# Auth.js v5 (with Better Auth fallback for the secret name)
|
||||||
|
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
|
||||||
|
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
|
||||||
|
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
|
||||||
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
|
||||||
|
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
|
||||||
|
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
|
||||||
|
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
|
||||||
|
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
|
||||||
|
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
|
||||||
|
|
||||||
|
# PostgREST
|
||||||
|
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
|
||||||
|
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
|
||||||
|
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
|
||||||
|
PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||||
|
|
||||||
|
# Supabase (legacy)
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||||
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||||
|
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
|
||||||
|
|
||||||
|
# Resend
|
||||||
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
|
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
|
||||||
|
|
||||||
|
# AI
|
||||||
|
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||||
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||||
|
|
||||||
|
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
||||||
|
run: |
|
||||||
|
APP_DIR=/home/tyler/route-commerce
|
||||||
|
mkdir -p $APP_DIR
|
||||||
|
# Use the port chosen by Start Docker stack (persisted to .postgrest-port)
|
||||||
|
POSTGREST_HOST_PORT=$(cat .postgrest-port)
|
||||||
|
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
|
||||||
|
|
||||||
|
# Write env file from secrets (preserves existing .env for docker compose)
|
||||||
|
{
|
||||||
|
printf "DATABASE_URL=%s\n" "$DATABASE_URL"
|
||||||
|
printf "NEXT_PUBLIC_API_URL=%s\n" "$NEXT_PUBLIC_API_URL"
|
||||||
|
printf "POSTGRES_USER=%s\n" "$POSTGRES_USER"
|
||||||
|
printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD"
|
||||||
|
printf "POSTGRES_DB=%s\n" "$POSTGRES_DB"
|
||||||
|
printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER"
|
||||||
|
printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD"
|
||||||
|
printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
|
||||||
|
printf "AUTH_SECRET=%s\n" "$AUTH_SECRET"
|
||||||
|
printf "AUTH_URL=%s\n" "$AUTH_URL"
|
||||||
|
printf "NEXT_PUBLIC_AUTH_URL=%s\n" "$NEXT_PUBLIC_AUTH_URL"
|
||||||
|
printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID"
|
||||||
|
printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET"
|
||||||
|
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN"
|
||||||
|
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
|
||||||
|
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
|
||||||
|
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
|
||||||
|
printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY"
|
||||||
|
printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX"
|
||||||
|
printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL"
|
||||||
|
printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT"
|
||||||
|
printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI"
|
||||||
|
printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE"
|
||||||
|
printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
|
||||||
|
printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL"
|
||||||
|
printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY"
|
||||||
|
printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY"
|
||||||
|
printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET"
|
||||||
|
printf "STRIPE_PUBLISHABLE_KEY=%s\n" "$STRIPE_PUBLISHABLE_KEY"
|
||||||
|
printf "RESEND_API_KEY=%s\n" "$RESEND_API_KEY"
|
||||||
|
printf "RESEND_WEBHOOK_SECRET=%s\n" "$RESEND_WEBHOOK_SECRET"
|
||||||
|
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY"
|
||||||
|
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL"
|
||||||
|
printf "FROM_EMAIL=%s\n" "$FROM_EMAIL"
|
||||||
|
} > $APP_DIR/.env.production
|
||||||
|
|
||||||
|
# Copy build output and required files
|
||||||
|
rsync -a --delete .next/ $APP_DIR/.next/
|
||||||
|
rsync -a --delete public/ $APP_DIR/public/
|
||||||
|
cp package.json $APP_DIR/
|
||||||
|
cp deploy/docker-compose.yml $APP_DIR/
|
||||||
|
cp -r supabase/ $APP_DIR/
|
||||||
|
cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# Install production deps only
|
||||||
|
cd $APP_DIR
|
||||||
|
npm install --omit=dev
|
||||||
|
|
||||||
|
# Start or restart PM2 process
|
||||||
|
if pm2 describe route-commerce > /dev/null 2>&1; then
|
||||||
|
pm2 restart route-commerce
|
||||||
|
else
|
||||||
|
pm2 start npm --name route-commerce -- start -- -p 3100
|
||||||
|
pm2 save
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deployed successfully"
|
||||||
@@ -2,11 +2,23 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Canonical Remote
|
||||||
|
|
||||||
|
There is exactly one remote — `origin` — pointing to the self-hosted Gitea repo:
|
||||||
|
|
||||||
|
- **URL:** `git@git.crispygoat.com:tyler/route-commerce.git`
|
||||||
|
- **Default branch:** `main`
|
||||||
|
- **Deploy:** push to `origin/main` triggers `.gitea/workflows/deploy.yml`
|
||||||
|
|
||||||
|
Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`).
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
|
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
|
||||||
|
|
||||||
Tech stack: Next.js 16 (App Router) · Supabase (auth + Postgres + RLS) · Stripe · Square · Resend (email) · Tailwind CSS v4
|
Tech stack: Next.js 16 (App Router) · **Postgres** (direct — Supabase is being removed) · Auth.js (NextAuth v5, in-progress migration from bespoke cookie auth) · Stripe · Square · Resend (email) · Tailwind CSS v4
|
||||||
|
|
||||||
|
> **Direction:** Supabase is being removed in favor of a direct Postgres connection. The `supabase/` directory is kept as a path for migrations tooling only (no Supabase platform/CLI/auth). Until the Auth.js migration ships, auth still flows through the `dev_session` / `rc_auth_uid` cookies — see the Authentication section. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,14 +33,12 @@ npx tsc --noEmit # TypeScript check (no emit)
|
|||||||
npx playwright test # Run E2E tests (Playwright)
|
npx playwright test # Run E2E tests (Playwright)
|
||||||
```
|
```
|
||||||
|
|
||||||
> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL.
|
> The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies.
|
||||||
> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
|
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
||||||
> For direct PG mode: `pg` and `dotenv` are already in devDependencies.
|
|
||||||
> If `get_brand_settings` migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
|
||||||
|
|
||||||
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details.
|
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details.
|
||||||
|
|
||||||
No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`).
|
E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,10 +51,24 @@ No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.
|
|||||||
- `dev_session=brand_admin` — full access to assigned brand only
|
- `dev_session=brand_admin` — full access to assigned brand only
|
||||||
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
|
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
|
||||||
|
|
||||||
`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and Supabase Auth in production. **Never import this file directly into Client Components** — use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
|
`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and the legacy `rc_auth_uid` cookie in production (set by the pre-Auth.js `/api/login`) — never import this file directly into Client Components. Use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
|
||||||
|
|
||||||
|
The middleware (`src/middleware.ts`) guards `/admin/:path*` and `/login`. It auto-issues a `dev_session=platform_admin` cookie for the demo flow when no auth is present. A `clerk-auth.ts` helper exists in `src/lib/` but is currently a stub — do not depend on it.
|
||||||
|
|
||||||
The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary.
|
The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary.
|
||||||
|
|
||||||
|
#### Auth.js (NextAuth v5) migration — in progress
|
||||||
|
|
||||||
|
The platform is migrating from the bespoke `dev_session` + `rc_auth_uid` cookie flow to **Auth.js (NextAuth v5)**, with Supabase as the database adapter and email/OAuth providers. While the migration is in flight:
|
||||||
|
|
||||||
|
- Do **not** add new code that depends on the `dev_session` or `rc_auth_uid` cookies — write against the Auth.js API (`auth()`, `signIn`, `signOut`, `getSession`) instead.
|
||||||
|
- New env vars: `AUTH_SECRET`, `AUTH_URL`, and provider-specific keys (`AUTH_GITHUB_ID`/`SECRET`, `AUTH_GOOGLE_ID`/`SECRET`, etc.). See `.env.example` for the full list.
|
||||||
|
- A new route handler at `src/app/api/auth/[...nextauth]/route.ts` will replace the ad-hoc `/api/login`, `/api/auth/uid`, and `/api/logout` endpoints.
|
||||||
|
- The middleware (`src/middleware.ts`) will eventually use `auth()` from NextAuth to populate the session; the existing `dev_session` auto-login branch is a temporary fallback for demos.
|
||||||
|
- `src/lib/admin-permissions.ts` will keep its public surface (`getAdminUser`, `getCurrentAdminUser`) but read the session from NextAuth internally — the `AdminUser` type does not need to change.
|
||||||
|
- `clerk-auth.ts` is being removed in favor of Auth.js; do not extend it.
|
||||||
|
- Until the migration ships, the `dev_session` and `rc_auth_uid` paths remain the source of truth — see the section above for current behavior.
|
||||||
|
|
||||||
### Server Actions Pattern
|
### Server Actions Pattern
|
||||||
|
|
||||||
All database writes go through server actions in `src/actions/`. These:
|
All database writes go through server actions in `src/actions/`. These:
|
||||||
@@ -55,9 +79,19 @@ All database writes go through server actions in `src/actions/`. These:
|
|||||||
|
|
||||||
Server actions are "use server" files that export async functions. Client components import and call them directly.
|
Server actions are "use server" files that export async functions. Client components import and call them directly.
|
||||||
|
|
||||||
### SECURITY DEFINER RPCs + Brand Scoping
|
### Database (Postgres, direct)
|
||||||
|
|
||||||
The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass RLS entirely. This means:
|
The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
|
||||||
|
|
||||||
|
#### Connection
|
||||||
|
|
||||||
|
- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
|
||||||
|
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names.
|
||||||
|
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase.
|
||||||
|
|
||||||
|
#### SECURITY DEFINER RPCs + Brand Scoping
|
||||||
|
|
||||||
|
The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass any future RLS. This means:
|
||||||
|
|
||||||
- Brand isolation must be enforced at the **application layer** (in server actions), not in database policies
|
- Brand isolation must be enforced at the **application layer** (in server actions), not in database policies
|
||||||
- Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it
|
- Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it
|
||||||
@@ -182,10 +216,19 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
|
|||||||
|
|
||||||
### Communications Module ("Harvest Reach")
|
### Communications Module ("Harvest Reach")
|
||||||
|
|
||||||
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`.
|
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.)
|
||||||
|
|
||||||
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
|
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
|
||||||
|
|
||||||
|
**Scheduled automations** (declared in `vercel.json`):
|
||||||
|
- `POST /api/email-automation/abandoned-cart` — every 6h, fires abandoned-cart sequence emails
|
||||||
|
- `POST /api/email-automation/welcome-sequence` — every 6h, fires welcome onboarding sequence
|
||||||
|
- `POST /api/cron/send-scheduled` — daily 09:00, sends scheduled campaigns
|
||||||
|
- `POST /api/wholesale/notifications/{send,dispatch,pickup-reminder}` — wholesale lifecycle
|
||||||
|
- `POST /api/square/process-queue` — every 2 min, drains Square sync queue
|
||||||
|
|
||||||
|
These endpoints are also reachable via curl for manual triggering; the email-automation routes accept `Authorization: Bearer $CRON_SECRET`.
|
||||||
|
|
||||||
### Payments
|
### Payments
|
||||||
|
|
||||||
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
|
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
|
||||||
@@ -200,7 +243,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
|||||||
|
|
||||||
## Key Conventions
|
## Key Conventions
|
||||||
|
|
||||||
- All DB mutations use Supabase REST API (`fetch` to `${supabaseUrl}/rest/v1/rpc/...`) from server actions, NOT the Supabase JS client (avoids SSR cookie issues)
|
- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`.
|
||||||
- `gen_random_uuid()` used in migrations for primary keys
|
- `gen_random_uuid()` used in migrations for primary keys
|
||||||
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
|
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
|
||||||
- Status enums stored as TEXT — no PostgreSQL ENUM type
|
- Status enums stored as TEXT — no PostgreSQL ENUM type
|
||||||
@@ -217,11 +260,11 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
|
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
|
||||||
| Middleware (route protection) | `src/middleware.ts` |
|
| Middleware (route protection) | `src/middleware.ts` |
|
||||||
| Server actions | `src/actions/*.ts` (one file per domain) |
|
| Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) |
|
||||||
| Admin pages | `src/app/admin/[module]/page.tsx` |
|
| Admin pages | `src/app/admin/[module]/page.tsx` |
|
||||||
| Admin client components | `src/components/admin/*.tsx` |
|
| Admin client components | `src/components/admin/*.tsx` |
|
||||||
| Migrations | `supabase/migrations/` |
|
| Migrations | `supabase/migrations/` (kept for now; will likely move to `db/migrations/` in a later pass) |
|
||||||
| Supabase client | `src/lib/supabase.ts` |
|
| Postgres pool / driver | `src/lib/db.ts` (TBD — create during the Supabase removal pass) |
|
||||||
| Email templates | `src/lib/email-templates.ts` |
|
| Email templates | `src/lib/email-templates.ts` |
|
||||||
| Date formatting | `src/lib/format-date.ts` |
|
| Date formatting | `src/lib/format-date.ts` |
|
||||||
| Feature flags | `src/lib/feature-flags.ts` |
|
| Feature flags | `src/lib/feature-flags.ts` |
|
||||||
@@ -238,7 +281,8 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
|||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone.
|
- **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone.
|
||||||
- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have RLS disabled. All brand scoping must be enforced in server actions.
|
- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have no row-level policies. All brand scoping must be enforced in server actions.
|
||||||
|
- **Supabase residue in the wild**: `grep -r "@supabase" src/` will still find imports during the transition. Do not add new ones; if you're touching a file that imports from Supabase, replace the call with the equivalent `pg`-pool call before merging.
|
||||||
- **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead.
|
- **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead.
|
||||||
- **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item.
|
- **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item.
|
||||||
- **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`.
|
- **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`.
|
||||||
@@ -2,11 +2,40 @@
|
|||||||
|
|
||||||
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
|
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
|
||||||
|
|
||||||
**Last updated:** 2026-06-03 (during Supabase migration apply session)
|
**Last updated:** 2026-06-06 (Supabase → Postgres pivot)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Supabase CLI + Migrations Tooling
|
## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
|
||||||
|
|
||||||
|
The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture.
|
||||||
|
|
||||||
|
### What changes immediately
|
||||||
|
- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
|
||||||
|
- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
|
||||||
|
- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
|
||||||
|
- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
|
||||||
|
- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
|
||||||
|
|
||||||
|
### What's TBD / needs follow-up
|
||||||
|
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet)
|
||||||
|
- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
|
||||||
|
- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md)
|
||||||
|
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings?
|
||||||
|
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition
|
||||||
|
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally?
|
||||||
|
|
||||||
|
### Migration content that's now obsolete
|
||||||
|
- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
|
||||||
|
- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world.
|
||||||
|
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
|
||||||
|
|
||||||
|
### Historical sections below
|
||||||
|
The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)*
|
||||||
|
|
||||||
### Login + Link (done in this session)
|
### Login + Link (done in this session)
|
||||||
- User ran `supabase login`
|
- User ran `supabase login`
|
||||||
@@ -35,15 +64,23 @@ Key changes:
|
|||||||
- Falls back to direct `pg` only if CLI path fails.
|
- Falls back to direct `pg` only if CLI path fails.
|
||||||
- Header comments updated with current recommended workflow.
|
- Header comments updated with current recommended workflow.
|
||||||
|
|
||||||
**Recommended commands now:**
|
**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):**
|
||||||
```bash
|
```bash
|
||||||
|
# Supabase CLI path (legacy — do not use going forward)
|
||||||
supabase login
|
supabase login
|
||||||
supabase link --project-ref wnzkhezyhnfzhkhiflrp
|
supabase link --project-ref wnzkhezyhnfzhkhiflrp
|
||||||
node supabase/push-migrations.js 148 # or any prefix
|
node supabase/push-migrations.js 148 # CLI path
|
||||||
# or
|
# or
|
||||||
npm run migrate:one 148
|
npm run migrate:one 148
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Direct pg path (this is the future — only the pg branch is kept alive in the script)
|
||||||
|
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
|
||||||
|
# or
|
||||||
|
DATABASE_URL=postgres://... npm run migrate:one 148
|
||||||
|
```
|
||||||
|
|
||||||
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
|
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -133,19 +170,23 @@ Verification queries (post-apply) confirmed:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current State / Gotchas
|
## Current State / Gotchas (2026-06-06)
|
||||||
|
|
||||||
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. Only early ones (001/002) + many timestamped migrations from other activity are tracked.
|
- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only.
|
||||||
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project.
|
- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.)
|
||||||
- When adding **new** migrations in the future, prefer the standard `supabase migration new` flow if possible, but the custom `push-migrations.js` + numeric prefix style is still the established pattern here.
|
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase.
|
||||||
- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies.
|
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
|
||||||
|
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch.
|
||||||
|
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
|
||||||
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
|
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
|
||||||
|
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How to Use This Memory
|
## How to Use This Memory
|
||||||
|
|
||||||
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
|
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
|
||||||
|
- **Read the Direction Pivot section first** — it supersedes the older Supabase-flavored instructions.
|
||||||
- Update this file with new key facts, applied migrations, or new gotchas.
|
- Update this file with new key facts, applied migrations, or new gotchas.
|
||||||
- Feel free to add dated sections.
|
- Feel free to add dated sections.
|
||||||
|
|
||||||
@@ -245,3 +286,25 @@ Follow-up pass on the original Codex review covering public site, buyer path, bi
|
|||||||
### Migration 203 — applied via Supabase CLI
|
### Migration 203 — applied via Supabase CLI
|
||||||
|
|
||||||
`203_plan_usage_active_products.sql` updates `get_brand_plan_info` to count `products` where `active = true AND deleted_at IS NULL`, matching the dashboard's "Active Products" stat. The `NOTIFY pgrst, 'reload schema'` ensures PostgREST picks up the change without restart.
|
`203_plan_usage_active_products.sql` updates `get_brand_plan_info` to count `products` where `active = true AND deleted_at IS NULL`, matching the dashboard's "Active Products" stat. The `NOTIFY pgrst, 'reload schema'` ensures PostgREST picks up the change without restart.
|
||||||
|
|
||||||
|
## Gitea build fix — 2026-06-06
|
||||||
|
|
||||||
|
Gitea runner (`https://git.crispygoat.com/tyler/route-commerce.git`, branch `main`) was failing `next build` with two errors:
|
||||||
|
|
||||||
|
1. **DYNAMIC_SERVER_USAGE** on `/admin/settings/square-sync` (and the whole admin tree): `getAdminUser()` reads `cookies()` via `next/headers`. The admin layout tried to prerender statically, so the first child page that hit cookies aborted the build.
|
||||||
|
2. **Prerender ECONNREFUSED** on `/indian-river-direct/stops`: `getPublicStopsForBrand` / `getActiveStopsForSitemap` / `getBrandSettingsPublic` fetch `NEXT_PUBLIC_SUPABASE_URL` at build time. The Gitea runner passes a Supabase URL that resolves but is unreachable, so `fetch` throws `ECONNREFUSED` and the prerender aborts.
|
||||||
|
|
||||||
|
The earlier commit `2f3be54 fix(actions): skip Supabase fetch at build time when env vars unset` only added `if (!supabaseUrl || !supabaseKey) return [];` — but in CI the env vars **are** set, so the guard passed and the fetch was still attempted.
|
||||||
|
|
||||||
|
### Fixes applied
|
||||||
|
|
||||||
|
- `src/actions/stops.ts` — wrapped `getActiveStopsForSitemap` and `getPublicStopsForBrand` fetches in `try/catch` returning `[]` on error. Env-var guard kept as fast path.
|
||||||
|
- `src/actions/brand-settings.ts` — wrapped `getBrandSettingsPublic` fetch in `try/catch` returning `{ success: false }` on error.
|
||||||
|
- `src/app/admin/settings/square-sync/page.tsx` — added `export const dynamic = "force-dynamic";` (was missing).
|
||||||
|
- `src/app/admin/layout.tsx` — added `export const dynamic = "force-dynamic";` so the entire admin tree opts out of static prerender (layout calls `getAdminUser()` which reads cookies).
|
||||||
|
- `.gitea/workflows/deploy.yml` was simplified earlier in commit `2d837bc` to a thin wrapper around `deploy/deploy.sh`.
|
||||||
|
|
||||||
|
### Remote
|
||||||
|
|
||||||
|
- The crispygoat repo (`git@git.crispygoat.com:tyler/route-commerce.git`) and the GitHub `origin` repo are separate forks — `tyler/main` is the self-hosted Auth.js + Postgres branch, `origin/main` is the Supabase branch. Don't merge them; they share no deploy workflow.
|
||||||
|
- Push targets `tyler/main` to trigger the Gitea build.
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,52 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# .env.production — secrets + dynamic ports for the running containers
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# deploy.sh writes the first three lines on every successful deploy.
|
||||||
|
# Everything below is YOUR responsibility to populate. deploy.sh preserves
|
||||||
|
# unknown lines verbatim across deploys (it only overwrites the lines it
|
||||||
|
# knows about), so you can safely commit this file to a private repo or
|
||||||
|
# provision it via your secrets manager of choice.
|
||||||
|
#
|
||||||
|
# In production, this file should be mode 0600 and owned by the deploy user.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- managed by deploy.sh (do not edit by hand) -------------------------------
|
||||||
|
POSTGREST_HOST_PORT=3011
|
||||||
|
NEXTJS_HOST_PORT=3012
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3011
|
||||||
|
|
||||||
|
# --- PostgREST connection ---------------------------------------------------
|
||||||
|
PGRST_DB_URI=postgres://app:secret@db.internal:5432/app_production
|
||||||
|
PGRST_DB_ANON_ROLE=anon
|
||||||
|
PGRST_DB_SCHEMA=public
|
||||||
|
|
||||||
|
# --- Next.js server-side secrets -------------------------------------------
|
||||||
|
# Anything not prefixed NEXT_PUBLIC_ is server-only and read at request time.
|
||||||
|
DATABASE_URL=postgres://app:secret@db.internal:5432/app_production
|
||||||
|
|
||||||
|
# Auth.js v5 (NextAuth). Generate AUTH_SECRET with `npx auth secret` or
|
||||||
|
# `openssl rand -base64 32`. AUTH_URL is the public base URL the browser
|
||||||
|
# uses to build OAuth callback URLs.
|
||||||
|
AUTH_SECRET=change-me-to-a-long-random-string
|
||||||
|
AUTH_URL=https://app.example.com
|
||||||
|
NEXT_PUBLIC_AUTH_URL=https://app.example.com
|
||||||
|
ALLOW_DEV_LOGIN=false
|
||||||
|
|
||||||
|
# Google OAuth provider for Auth.js. Set both AUTH_GOOGLE_ID/SECRET and
|
||||||
|
# GOOGLE_CLIENT_ID/SECRET (the v5 code reads either name).
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
AUTH_GOOGLE_ID=
|
||||||
|
AUTH_GOOGLE_SECRET=
|
||||||
|
|
||||||
|
# --- External services ------------------------------------------------------
|
||||||
|
STRIPE_SECRET_KEY=sk_live_replace_me
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_live_replace_me
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_replace_me
|
||||||
|
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=apikey
|
||||||
|
SMTP_PASSWORD=replace_me
|
||||||
|
SMTP_FROM="My App <noreply@example.com>"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Runtime artefacts written by deploy.sh — do NOT commit these.
|
||||||
|
.deploy.lock
|
||||||
|
deploy.log
|
||||||
|
.postgrest-port
|
||||||
|
.nextjs-port
|
||||||
|
.env.production
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Dockerfile.nextjs — multi-stage build for the Next.js frontend
|
||||||
|
# =============================================================================
|
||||||
|
# Used by docker-compose.yml's `nextjs` service.
|
||||||
|
#
|
||||||
|
# Why this looks the way it does:
|
||||||
|
# - `NEXT_PUBLIC_API_URL` must be present at BUILD time (Next.js inlines
|
||||||
|
# it into the client JS). We pass it through as an ARGs so the build
|
||||||
|
# context is reproducible (`docker build --build-arg` or via deploy.sh's
|
||||||
|
# `docker compose --env-file` flow).
|
||||||
|
# - We copy the host's pre-built `.next/` (produced by `npm run build` in
|
||||||
|
# deploy.sh) rather than running `next build` inside the image. This
|
||||||
|
# keeps the image lean and avoids double-building.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ---- builder: produce node_modules with dev deps for the build step --------
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
|
||||||
|
# ---- builder: produce the standalone .next/ output ------------------------
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# These ARGs are wired through docker-compose's `args:` block (or the CLI).
|
||||||
|
# deploy.sh exports them in the build environment.
|
||||||
|
ARG NEXT_PUBLIC_API_URL
|
||||||
|
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
|
ARG NEXTJS_HOST_PORT
|
||||||
|
ENV NEXTJS_HOST_PORT=${NEXTJS_HOST_PORT}
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- runner: minimal image, standalone server -----------------------------
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# Run as non-root.
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Copy only what the standalone server needs.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Adjust this CMD to match the actual server file your build emits.
|
||||||
|
# For `output: "standalone"` in next.config.js the file is server.js.
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Makefile — convenience targets around deploy.sh
|
||||||
|
# =============================================================================
|
||||||
|
# All targets are wrappers; you can also invoke deploy.sh directly.
|
||||||
|
|
||||||
|
SHELL := /usr/bin/env bash
|
||||||
|
.SHELLFLAGS := -Eeu -o pipefail -c
|
||||||
|
.SHELLFLAGS_LOG := $(.SHELLFLAGS)
|
||||||
|
|
||||||
|
DEPLOY := ./deploy.sh
|
||||||
|
HEALTH := ./healthcheck.sh
|
||||||
|
WORKSPACE ?= $(CURDIR)
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Show this help message
|
||||||
|
@awk 'BEGIN {FS = ":.*##"; printf "Targets:\n"} /^[a-zA-Z_-]+:.*##/ { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
.PHONY: deploy
|
||||||
|
deploy: ## Run a full deploy (build + up + nginx + healthcheck)
|
||||||
|
$(DEPLOY)
|
||||||
|
|
||||||
|
.PHONY: deploy-verbose
|
||||||
|
deploy-verbose: ## Deploy with extra logging (PRUNE_IMAGES=0, longer healthcheck)
|
||||||
|
PRUNE_IMAGES=0 HEALTHCHECK_TIMEOUT=120 $(DEPLOY)
|
||||||
|
|
||||||
|
.PHONY: health
|
||||||
|
health: ## Run a one-shot health check against the running stack
|
||||||
|
WORKSPACE=$(WORKSPACE) $(HEALTH)
|
||||||
|
|
||||||
|
.PHONY: health-nginx
|
||||||
|
health-nginx: ## Health check including the nginx-fronted URL
|
||||||
|
WORKSPACE=$(WORKSPACE) $(HEALTH) --nginx
|
||||||
|
|
||||||
|
.PHONY: status
|
||||||
|
status: ## Show current prod ports and running containers
|
||||||
|
@echo "PostgREST port: $$(cat .postgrest-port 2>/dev/null || echo none)"
|
||||||
|
@echo "Next.js port: $$(cat .nextjs-port 2>/dev/null || echo none)"
|
||||||
|
@cd deploy && docker compose -p prod-app ps
|
||||||
|
|
||||||
|
.PHONY: logs
|
||||||
|
logs: ## Tail deploy.log
|
||||||
|
tail -n 200 -f deploy.log
|
||||||
|
|
||||||
|
.PHONY: down
|
||||||
|
down: ## Stop the production stack (without redeploying)
|
||||||
|
cd deploy && docker compose -p prod-app down --remove-orphans
|
||||||
|
|
||||||
|
.PHONY: rollback
|
||||||
|
rollback: ## Restart the previous stack (the one whose ports are still on disk)
|
||||||
|
@if [[ ! -f .postgrest-port ]]; then echo "no .postgrest-port to roll back to"; exit 1; fi
|
||||||
|
cd deploy && \
|
||||||
|
POSTGREST_HOST_PORT=$$(cat ../.postgrest-port) \
|
||||||
|
NEXTJS_HOST_PORT=$$(cat ../.nextjs-port) \
|
||||||
|
docker compose -p prod-app --env-file ../.env.production up -d
|
||||||
Executable
+429
@@ -0,0 +1,429 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# deploy.sh — Idempotent PostgREST + Next.js production deploy
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Self-hosted single-server deploy. Triggered manually, by Gitea webhook, or
|
||||||
|
# by a Gitea Actions runner after a push to `main` (or `gitea-sync`).
|
||||||
|
#
|
||||||
|
# What it does, in order:
|
||||||
|
# 1. Acquires an exclusive flock (concurrent deploys die loudly).
|
||||||
|
# 2. CLEANUP: stops the dev stack on :3001 and the previous prod stack
|
||||||
|
# (port read from .postgrest-port / .nextjs-port).
|
||||||
|
# 3. PORT_SELECTION: picks the lowest free port in [3011..30200] for
|
||||||
|
# PostgREST, then the next free one for the Next.js frontend.
|
||||||
|
# 4. BUILD: runs `npm run build` with NEXT_PUBLIC_API_URL exported so it
|
||||||
|
# gets inlined into the client bundle.
|
||||||
|
# 5. DEPLOY: writes the chosen ports to .env.production, brings the
|
||||||
|
# compose stack up.
|
||||||
|
# 6. NGINX: renders the nginx config from a template (with the current
|
||||||
|
# ports), `nginx -t`s it, and reloads the host systemd nginx.
|
||||||
|
# 7. HEALTHCHECK: curls the new stack; if anything is down, rolls back.
|
||||||
|
# 8. IMAGE_PRUNE: optional, removes dangling images on success.
|
||||||
|
#
|
||||||
|
# Files written to the workspace root:
|
||||||
|
# .postgrest-port current PostgREST host port (atomic)
|
||||||
|
# .nextjs-port current Next.js host port (atomic)
|
||||||
|
# .env.production rendered env fed to docker compose
|
||||||
|
# .deploy.lock flock target
|
||||||
|
# deploy.log append-only log
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
IFS=$'\n\t'
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Configurable variables (override via environment before invoking)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||||
|
COMPOSE_DIR="${COMPOSE_DIR:-${WORKSPACE}/deploy}"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-${COMPOSE_DIR}/docker-compose.yml}"
|
||||||
|
NGINX_TEMPLATE="${NGINX_TEMPLATE:-${COMPOSE_DIR}/nginx.conf.template}"
|
||||||
|
NGINX_RENDERED="${NGINX_RENDERED:-/etc/nginx/sites-available/prod-app.conf}"
|
||||||
|
NGINX_LINK="${NGINX_LINK:-/etc/nginx/sites-enabled/prod-app.conf}"
|
||||||
|
NGINX_OWNER="${NGINX_OWNER:-www-data:www-data}"
|
||||||
|
|
||||||
|
PROJECT_NAME="${PROJECT_NAME:-prod-app}"
|
||||||
|
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
|
||||||
|
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
|
||||||
|
ENV_FILE="${ENV_FILE:-${WORKSPACE}/.env.production}"
|
||||||
|
LOCK_FILE="${LOCK_FILE:-${WORKSPACE}/.deploy.lock}"
|
||||||
|
LOG_FILE="${LOG_FILE:-${WORKSPACE}/deploy.log}"
|
||||||
|
|
||||||
|
DEV_PORT="${DEV_PORT:-3001}"
|
||||||
|
PORT_RANGE_START="${PORT_RANGE_START:-3011}"
|
||||||
|
PORT_RANGE_END="${PORT_RANGE_END:-30200}"
|
||||||
|
HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-60}" # seconds total
|
||||||
|
HEALTHCHECK_INTERVAL="${HEALTHCHECK_INTERVAL:-2}" # seconds between tries
|
||||||
|
|
||||||
|
# Image pruning (set PRUNE_IMAGES=0 to skip)
|
||||||
|
PRUNE_IMAGES="${PRUNE_IMAGES:-1}"
|
||||||
|
|
||||||
|
# Optional: pin the public URL the browser uses. If empty, we default to
|
||||||
|
# http://localhost:${POSTGREST_HOST_PORT}. For production with a real domain
|
||||||
|
# and nginx in front, set e.g. NEXT_PUBLIC_API_URL=https://app.example.com/api
|
||||||
|
NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-}"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging — every line is timestamped, tee'd to stdout AND the log file.
|
||||||
|
# We replace the shell's fd 1/2 with a tee so any tool that prints (npm, docker,
|
||||||
|
# curl) lands in both places automatically.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
|
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||||
|
|
||||||
|
ts() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||||
|
log() { printf '[%s] %s\n' "$(ts)" "$*"; }
|
||||||
|
hr() { printf '%s\n' '----------------------------------------------------------------'; }
|
||||||
|
section() { hr; log "== $* =="; hr; }
|
||||||
|
|
||||||
|
# Trap so we always release the lock and surface a useful message.
|
||||||
|
on_exit() {
|
||||||
|
local exit_code=$?
|
||||||
|
if (( exit_code != 0 )); then
|
||||||
|
log "DEPLOY FAILED with exit code ${exit_code}"
|
||||||
|
log "See ${LOG_FILE} for full output. Rollback hints:"
|
||||||
|
log " - Previous port was: ${PREVIOUS_POSTGREST_PORT:-<unknown>}"
|
||||||
|
log " - Current .postgrest-port value: $(read_port_file "$POSTGREST_PORT_FILE" || echo '<none>')"
|
||||||
|
log " - To restart the old stack manually:"
|
||||||
|
log " POSTGREST_HOST_PORT=${PREVIOUS_POSTGREST_PORT:-3011} \\"
|
||||||
|
log " NEXTJS_HOST_PORT=${PREVIOUS_NEXTJS_PORT:-3012} \\"
|
||||||
|
log " docker compose -p ${PROJECT_NAME} --env-file ${ENV_FILE} up -d"
|
||||||
|
else
|
||||||
|
log "DEPLOY OK — PostgREST on :${NEW_POSTGREST_PORT}, Next.js on :${NEW_NEXTJS_PORT}"
|
||||||
|
fi
|
||||||
|
# flock on fd 9 releases automatically when the script exits.
|
||||||
|
}
|
||||||
|
trap on_exit EXIT
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
read_port_file() {
|
||||||
|
# Echo the port in $1, or empty string if missing/garbage.
|
||||||
|
local f="$1"
|
||||||
|
[[ -f "$f" ]] || return 1
|
||||||
|
local v
|
||||||
|
v=$(tr -d '[:space:]' < "$f" 2>/dev/null || true)
|
||||||
|
[[ "$v" =~ ^[0-9]+$ ]] || return 1
|
||||||
|
printf '%s' "$v"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_template() {
|
||||||
|
# Portable envsubst: replaces $VAR and ${VAR} references in stdin with
|
||||||
|
# values from the current environment. Only the variable names given as
|
||||||
|
# args are expanded (matches `envsubst` behavior). If real envsubst is
|
||||||
|
# available we use it for speed.
|
||||||
|
local vars="$1"
|
||||||
|
if command -v envsubst >/dev/null 2>&1; then
|
||||||
|
envsubst "$vars"
|
||||||
|
else
|
||||||
|
# Build a sed expression like: s/\${VAR}/$VAR/g; s/\bVAR\b/$VAR/g
|
||||||
|
local sed_expr=()
|
||||||
|
for v in $vars; do
|
||||||
|
v="${v#\$}"
|
||||||
|
v="${v#\{}"
|
||||||
|
v="${v%\}}"
|
||||||
|
sed_expr+=( -e "s|\${${v}}|${!v:-}|g" )
|
||||||
|
sed_expr+=( -e "s|\$${v}\b|${!v:-}|g" )
|
||||||
|
done
|
||||||
|
sed "${sed_expr[@]}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
is_listening() {
|
||||||
|
# Returns 0 if port $1 has a TCP listener (v4 or v6) on this host.
|
||||||
|
local port="$1"
|
||||||
|
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${port}$"
|
||||||
|
}
|
||||||
|
|
||||||
|
next_free_port() {
|
||||||
|
# Walk PORT_RANGE_START..PORT_RANGE_END and return the first port nobody
|
||||||
|
# is listening on. Returns 1 if none are free.
|
||||||
|
local p
|
||||||
|
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
|
||||||
|
if ! is_listening "$p"; then
|
||||||
|
printf '%s' "$p"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic_write() {
|
||||||
|
# Write stdin to $1 atomically: write to temp, fsync, rename. This is
|
||||||
|
# what lets us use .postgrest-port as a single source of truth — readers
|
||||||
|
# always see either the old value or the new value, never a half-written one.
|
||||||
|
local target="$1"
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp "${target}.tmp.XXXXXX")
|
||||||
|
cat > "$tmp"
|
||||||
|
sync
|
||||||
|
mv -f "$tmp" "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
free_port() {
|
||||||
|
# Try several strategies to free a port:
|
||||||
|
# 1. docker compose down for our project (idempotent)
|
||||||
|
# 2. brute-force kill of any process bound to the port
|
||||||
|
local port="$1" label="$2"
|
||||||
|
if [[ -z "$port" ]]; then return 0; fi
|
||||||
|
log " ${label} port ${port}: stopping project '${PROJECT_NAME}' (if up)"
|
||||||
|
( cd "$COMPOSE_DIR" && docker compose -p "$PROJECT_NAME" down --remove-orphans --timeout 10 ) \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if is_listening "$port"; then
|
||||||
|
log " ${label} port ${port}: still listening, attempting pkill"
|
||||||
|
# fuser prints PIDs holding the port; xargs kills them.
|
||||||
|
local pids
|
||||||
|
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
|
||||||
|
if [[ -n "$pids" ]]; then
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
kill $pids 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
|
||||||
|
[[ -n "$pids" ]] && kill -9 $pids 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if is_listening "$port"; then
|
||||||
|
log " ${label} port ${port}: WARNING — still in use after cleanup"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log " ${label} port ${port}: free"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
healthcheck() {
|
||||||
|
# Hit $1 (URL) until it returns 2xx within HEALTHCHECK_TIMEOUT seconds.
|
||||||
|
local url="$1" label="$2" elapsed=0
|
||||||
|
log " ${label}: ${url}"
|
||||||
|
while (( elapsed < HEALTHCHECK_TIMEOUT )); do
|
||||||
|
if curl -fsS --max-time 5 -o /dev/null "$url"; then
|
||||||
|
log " ${label}: OK (after ${elapsed}s)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep "$HEALTHCHECK_INTERVAL"
|
||||||
|
elapsed=$(( elapsed + HEALTHCHECK_INTERVAL ))
|
||||||
|
done
|
||||||
|
log " ${label}: FAILED after ${HEALTHCHECK_TIMEOUT}s"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Lock — refuse to run if another deploy is in flight.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
section "LOCK"
|
||||||
|
exec 9>"$LOCK_FILE"
|
||||||
|
if ! flock -n 9; then
|
||||||
|
log "Another deploy holds ${LOCK_FILE}. Exiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "Acquired exclusive lock on ${LOCK_FILE}"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 0. Banner
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
section "DEPLOY START"
|
||||||
|
log "Workspace: ${WORKSPACE}"
|
||||||
|
log "Project: ${PROJECT_NAME}"
|
||||||
|
log "Compose: ${COMPOSE_FILE}"
|
||||||
|
log "Nginx tpl: ${NGINX_TEMPLATE}"
|
||||||
|
log "Port range: ${PORT_RANGE_START}..${PORT_RANGE_END}"
|
||||||
|
log "Caller: ${USER:-<unknown>}@$(hostname)"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 1. CLEANUP — port 3001 (dev) and the previous prod ports.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
section "CLEANUP"
|
||||||
|
|
||||||
|
free_port "$DEV_PORT" "dev"
|
||||||
|
PREVIOUS_POSTGREST_PORT=$(read_port_file "$POSTGREST_PORT_FILE" || true)
|
||||||
|
PREVIOUS_NEXTJS_PORT=$(read_port_file "$NEXTJS_PORT_FILE" || true)
|
||||||
|
log "Previous prod ports: PostgREST=${PREVIOUS_POSTGREST_PORT:-<none>} Next.js=${PREVIOUS_NEXTJS_PORT:-<none>}"
|
||||||
|
|
||||||
|
# Stale-port guard: if the file points to a port that is NOT in our standard
|
||||||
|
# range, or to a port that nothing is listening on anymore, we still tear
|
||||||
|
# down the project (cheap) but we don't try to free the port itself —
|
||||||
|
# someone else might be using it.
|
||||||
|
free_port "${PREVIOUS_POSTGREST_PORT:-}" "prev-postgrest"
|
||||||
|
free_port "${PREVIOUS_NEXTJS_PORT:-}" "prev-nextjs"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 2. PORT_SELECTION — find the two lowest free ports.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
section "PORT_SELECTION"
|
||||||
|
|
||||||
|
NEW_POSTGREST_PORT=$(next_free_port) || {
|
||||||
|
log "No free port in [${PORT_RANGE_START}..${PORT_RANGE_END}]. Bailing out."
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
log "PostgREST: ${NEW_POSTGREST_PORT}"
|
||||||
|
|
||||||
|
# Re-check after allocation, since we want distinct ports for both services.
|
||||||
|
NEW_NEXTJS_PORT=""
|
||||||
|
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
|
||||||
|
if (( p == NEW_POSTGREST_PORT )); then continue; fi
|
||||||
|
if ! is_listening "$p"; then NEW_NEXTJS_PORT="$p"; break; fi
|
||||||
|
done
|
||||||
|
if [[ -z "$NEW_NEXTJS_PORT" ]]; then
|
||||||
|
log "No free port for Next.js after allocating ${NEW_POSTGREST_PORT}. Bailing out."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
log "Next.js: ${NEW_NEXTJS_PORT}"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 3. BUILD — Next.js, with NEXT_PUBLIC_API_URL inlined into the client bundle.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
section "BUILD"
|
||||||
|
|
||||||
|
cd "$WORKSPACE"
|
||||||
|
|
||||||
|
# Default the public API URL the browser will see.
|
||||||
|
if [[ -z "$NEXT_PUBLIC_API_URL" ]]; then
|
||||||
|
NEXT_PUBLIC_API_URL="http://localhost:${NEW_POSTGREST_PORT}"
|
||||||
|
fi
|
||||||
|
log "NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}"
|
||||||
|
|
||||||
|
# Node-only check: don't try to build if there's no package.json.
|
||||||
|
if [[ -f package.json ]]; then
|
||||||
|
# Make sure the deps are present (idempotent — npm ci is a no-op when locked).
|
||||||
|
if [[ -f package-lock.json ]]; then
|
||||||
|
log "npm ci (locked install)"
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
else
|
||||||
|
log "npm install (no lockfile present — consider committing package-lock.json)"
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
fi
|
||||||
|
log "npm run build"
|
||||||
|
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
|
||||||
|
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
|
||||||
|
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
|
||||||
|
npm run build
|
||||||
|
else
|
||||||
|
log "No package.json in ${WORKSPACE} — skipping build step."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 4. ENV FILE — render .env.production for the running containers.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
section "ENV"
|
||||||
|
|
||||||
|
# Preserve any pre-existing secrets in .env.production. We only own the lines
|
||||||
|
# we write; everything else is left alone. (The simplest sane strategy.)
|
||||||
|
SECRETS_FILE=""
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
SECRETS_FILE=$(mktemp)
|
||||||
|
# Drop any lines we manage; keep the rest verbatim.
|
||||||
|
grep -v -E '^(POSTGREST_HOST_PORT|NEXTJS_HOST_PORT|NEXT_PUBLIC_API_URL)=' \
|
||||||
|
"$ENV_FILE" > "$SECRETS_FILE" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '# Generated by deploy.sh on %s — safe to edit, lines below are managed\n' "$(ts)"
|
||||||
|
printf 'POSTGREST_HOST_PORT=%s\n' "$NEW_POSTGREST_PORT"
|
||||||
|
printf 'NEXTJS_HOST_PORT=%s\n' "$NEW_NEXTJS_PORT"
|
||||||
|
printf 'NEXT_PUBLIC_API_URL=%q\n' "$NEXT_PUBLIC_API_URL"
|
||||||
|
if [[ -n "$SECRETS_FILE" ]]; then
|
||||||
|
cat "$SECRETS_FILE"
|
||||||
|
rm -f "$SECRETS_FILE"
|
||||||
|
fi
|
||||||
|
} > "${ENV_FILE}.new"
|
||||||
|
|
||||||
|
mv -f "${ENV_FILE}.new" "$ENV_FILE"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
log "Wrote ${ENV_FILE}"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 5. DEPLOY — bring the stack up.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
section "DEPLOY"
|
||||||
|
|
||||||
|
cd "$COMPOSE_DIR"
|
||||||
|
log "docker compose -p ${PROJECT_NAME} up -d --build"
|
||||||
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --build
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 6. NGINX — render config from template, test, reload.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
section "NGINX"
|
||||||
|
|
||||||
|
if [[ -f "$NGINX_TEMPLATE" ]]; then
|
||||||
|
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
|
||||||
|
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
|
||||||
|
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
|
||||||
|
render_template '${POSTGREST_HOST_PORT} ${NEXTJS_HOST_PORT} ${NEXT_PUBLIC_API_URL}' \
|
||||||
|
< "$NGINX_TEMPLATE" > "$NGINX_RENDERED"
|
||||||
|
|
||||||
|
log "Rendered: ${NGINX_RENDERED}"
|
||||||
|
chown "$NGINX_OWNER" "$NGINX_RENDERED" 2>/dev/null || true
|
||||||
|
chmod 644 "$NGINX_RENDERED"
|
||||||
|
|
||||||
|
# Wire it into sites-enabled if not already linked.
|
||||||
|
if [[ ! -L "$NGINX_LINK" && ! -e "$NGINX_LINK" ]]; then
|
||||||
|
log "Enabling site: ${NGINX_LINK} -> ${NGINX_RENDERED}"
|
||||||
|
ln -s "$NGINX_RENDERED" "$NGINX_LINK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "nginx -t"
|
||||||
|
nginx -t
|
||||||
|
log "systemctl reload nginx"
|
||||||
|
systemctl reload nginx
|
||||||
|
else
|
||||||
|
log "No nginx template at ${NGINX_TEMPLATE} — skipping reverse proxy step."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 7. HEALTHCHECK — direct + via nginx (when applicable).
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
section "HEALTHCHECK"
|
||||||
|
|
||||||
|
# Direct checks (bypass nginx, catch compose issues)
|
||||||
|
healthcheck "http://127.0.0.1:${NEW_POSTGREST_PORT}/" "postgrest-direct" || ROLLBACK=1
|
||||||
|
healthcheck "http://127.0.0.1:${NEW_NEXTJS_PORT}/" "nextjs-direct" || ROLLBACK=1
|
||||||
|
|
||||||
|
# nginx-fronted check (only meaningful if nginx template exists)
|
||||||
|
if [[ -f "$NGINX_TEMPLATE" && "${ROLLBACK:-0}" != "1" ]]; then
|
||||||
|
healthcheck "http://127.0.0.1/" "nginx-front" || ROLLBACK=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${ROLLBACK:-0}" == "1" ]]; then
|
||||||
|
log "HEALTHCHECK FAILED — rolling back."
|
||||||
|
log "Tearing down the new stack on :${NEW_POSTGREST_PORT} / :${NEW_NEXTJS_PORT}"
|
||||||
|
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" down --remove-orphans --timeout 10 || true
|
||||||
|
|
||||||
|
# If we had a previous port file, the old one is still on disk (we wrote
|
||||||
|
# the new one to .new and only mv'd on success... but we DID mv already,
|
||||||
|
# so re-write the old value).
|
||||||
|
if [[ -n "${PREVIOUS_POSTGREST_PORT:-}" ]]; then
|
||||||
|
printf '%s\n' "$PREVIOUS_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
|
||||||
|
else
|
||||||
|
rm -f "$POSTGREST_PORT_FILE"
|
||||||
|
fi
|
||||||
|
if [[ -n "${PREVIOUS_NEXTJS_PORT:-}" ]]; then
|
||||||
|
printf '%s\n' "$PREVIOUS_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
|
||||||
|
else
|
||||||
|
rm -f "$NEXTJS_PORT_FILE"
|
||||||
|
fi
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 8. PERSIST — commit the chosen ports as the new single source of truth.
|
||||||
|
# (Done AFTER healthcheck so a failed deploy doesn't clobber the old one.)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
section "PERSIST"
|
||||||
|
printf '%s\n' "$NEW_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
|
||||||
|
printf '%s\n' "$NEW_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
|
||||||
|
log ".postgrest-port = ${NEW_POSTGREST_PORT}"
|
||||||
|
log ".nextjs-port = ${NEW_NEXTJS_PORT}"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 9. IMAGE_PRUNE — optional housekeeping.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if [[ "$PRUNE_IMAGES" == "1" ]]; then
|
||||||
|
section "IMAGE_PRUNE"
|
||||||
|
docker image prune -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "DONE"
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# docker-compose.yml — production stack consumed by deploy.sh
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# The host-side ports (POSTGREST_HOST_PORT, NEXTJS_HOST_PORT) are written by
|
||||||
|
# deploy.sh into .env.production. We interpolate from there with ${VAR:-3011}
|
||||||
|
# so a manual `docker compose up` without the deploy script still works.
|
||||||
|
#
|
||||||
|
# Note on networking: the Next.js container calls PostgREST on
|
||||||
|
# `host.docker.internal:POSTGREST_HOST_PORT` so the inlined
|
||||||
|
# NEXT_PUBLIC_API_URL (a localhost URL, per the deploy contract) resolves
|
||||||
|
# correctly. On Linux you may need to add
|
||||||
|
# extra_hosts:
|
||||||
|
# - "host.docker.internal:host-gateway"
|
||||||
|
# which is included below for that reason.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
name: prod-app # default project name; deploy.sh overrides with -p
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgrest:
|
||||||
|
image: postgrest/postgrest:latest
|
||||||
|
container_name: prod-app-postgrest
|
||||||
|
restart: unless-stopped
|
||||||
|
# The host port is dynamic. The container always listens on 3000.
|
||||||
|
ports:
|
||||||
|
- "${POSTGREST_HOST_PORT:-3011}:3000"
|
||||||
|
environment:
|
||||||
|
PGRST_DB_URI: ${PGRST_DB_URI}
|
||||||
|
PGRST_DB_ANON_ROLE: ${PGRST_DB_ANON_ROLE:-anon}
|
||||||
|
PGRST_DB_SCHEMA: ${PGRST_DB_SCHEMA:-public}
|
||||||
|
PGRST_SERVER_PORT: 3000
|
||||||
|
# Optional: tighten CORS for your real domain
|
||||||
|
PGRST_DB_TXN_END: "commit-allow-overwrite"
|
||||||
|
# Healthcheck lets `docker compose ps` show healthy state.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 6
|
||||||
|
|
||||||
|
nextjs:
|
||||||
|
# Build context is the workspace root (one level up from this file).
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile.nextjs
|
||||||
|
container_name: prod-app-nextjs
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${NEXTJS_HOST_PORT:-3012}:3000"
|
||||||
|
environment:
|
||||||
|
# Runtime vars — these can change without rebuilding. NEXT_PUBLIC_*
|
||||||
|
# is also exported here for completeness, but the BROWSER's view of
|
||||||
|
# NEXT_PUBLIC_API_URL is baked in at build time (see Dockerfile).
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
|
||||||
|
env_file:
|
||||||
|
- ../.env.production # server-side secrets read at runtime
|
||||||
|
extra_hosts:
|
||||||
|
# Lets the container reach the host on the dynamically allocated port.
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
postgrest:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 6
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# healthcheck.sh — standalone, callable from cron / monitoring
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Reads the current prod ports from .postgrest-port / .nextjs-port and curls
|
||||||
|
# each service. Exit code is the count of failed checks (0 = all healthy).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./healthcheck.sh
|
||||||
|
# ./healthcheck.sh --nginx # also check the fronted URL
|
||||||
|
# WORKSPACE=/srv/app ./healthcheck.sh
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
IFS=$'\n\t'
|
||||||
|
|
||||||
|
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||||
|
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
|
||||||
|
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
|
||||||
|
TIMEOUT="${HEALTHCHECK_TIMEOUT:-5}"
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local label="$1" url="$2"
|
||||||
|
if curl -fsS --max-time "$TIMEOUT" -o /dev/null "$url"; then
|
||||||
|
printf ' [ OK ] %-20s %s\n' "$label" "$url"
|
||||||
|
else
|
||||||
|
printf ' [FAIL] %-20s %s\n' "$label" "$url"
|
||||||
|
failures=$(( failures + 1 ))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
pgrest_port=$(tr -d '[:space:]' < "$POSTGREST_PORT_FILE" 2>/dev/null || echo "")
|
||||||
|
next_port=$(tr -d '[:space:]' < "$NEXTJS_PORT_FILE" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [[ -n "$pgrest_port" ]]; then
|
||||||
|
check "postgrest" "http://127.0.0.1:${pgrest_port}/"
|
||||||
|
else
|
||||||
|
printf ' [SKIP] postgrest (no .postgrest-port)\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$next_port" ]]; then
|
||||||
|
check "nextjs" "http://127.0.0.1:${next_port}/"
|
||||||
|
else
|
||||||
|
printf ' [SKIP] nextjs (no .nextjs-port)\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--nginx" ]]; then
|
||||||
|
check "nginx" "http://127.0.0.1/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$failures"
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# nginx.conf.template — rendered by deploy.sh on every deploy
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Variables substituted by `envsubst`:
|
||||||
|
# ${POSTGREST_HOST_PORT} dynamic host port of the PostgREST container
|
||||||
|
# ${NEXTJS_HOST_PORT} dynamic host port of the Next.js container
|
||||||
|
# ${NEXT_PUBLIC_API_URL} (informational only — used in comment header)
|
||||||
|
#
|
||||||
|
# Layout:
|
||||||
|
# /api/* -> http://127.0.0.1:${POSTGREST_HOST_PORT}
|
||||||
|
# /* -> http://127.0.0.1:${NEXTJS_HOST_PORT}
|
||||||
|
#
|
||||||
|
# Tested against nginx >= 1.18 (Debian 11 / Ubuntu 22.04). Adjust ssl_*
|
||||||
|
# lines if you don't have a cert yet — deploy.sh only tests/renders, the
|
||||||
|
# operator decides whether to terminate TLS here.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- upstream definitions ---------------------------------------------------
|
||||||
|
upstream postgrest_upstream {
|
||||||
|
server 127.0.0.1:${POSTGREST_HOST_PORT};
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream nextjs_upstream {
|
||||||
|
server 127.0.0.1:${NEXTJS_HOST_PORT};
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- HTTP -> HTTPS upgrade (optional; remove if you only run on LAN) --------
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# ACME http-01 challenge needs to be served on port 80.
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/letsencrypt;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect everything else to HTTPS. Comment out for plain-HTTP dev.
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- main server block ------------------------------------------------------
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# --- TLS (uncomment + adjust after you obtain a cert) ------------------
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
|
||||||
|
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
# --- sensible defaults ------------------------------------------------
|
||||||
|
client_max_body_size 25m;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
# --- API: /api/* -> PostgREST ----------------------------------------
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://postgrest_upstream;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# PostgREST exposes its OpenAPI spec at the root of the API; expose it
|
||||||
|
# under a stable URL too.
|
||||||
|
location = /api {
|
||||||
|
proxy_pass http://postgrest_upstream;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- everything else -> Next.js --------------------------------------
|
||||||
|
location / {
|
||||||
|
proxy_pass http://nextjs_upstream;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
# Multi-Brand Admin Support
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Draft → Approved
|
||||||
|
**Author:** Grok (brainstorming session)
|
||||||
|
**Migration file:** `supabase/migrations/204_multi_brand_admin.sql`
|
||||||
|
**Follow-up migration (out of scope):** `220_drop_legacy_brand_id.sql`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`admin_users.brand_id` is a single `UUID | null`. The platform supports a `platform_admin` role (no brand) and a `brand_admin` role (one brand). There is no representation for an admin who legitimately needs access to 2+ specific brands — e.g., a parent company operating multiple storefronts with shared operations staff.
|
||||||
|
|
||||||
|
The current `effectiveBrandId = brandId ?? adminUser.brand_id ?? null` pattern silently does the wrong thing for multi-brand use cases:
|
||||||
|
|
||||||
|
- Data model can't represent "Jane is admin for Brand A AND Brand B" — would require two `admin_users` rows (and the auth-user→admin-user join gets messy).
|
||||||
|
- No central validation that an admin is acting in a brand they actually have access to.
|
||||||
|
- No persistent "current brand" context — every page re-derives it from scratch.
|
||||||
|
- No per-brand permission overrides possible (locks in flexibility for the future).
|
||||||
|
|
||||||
|
This spec addresses multi-brand tenants / franchises: same staff managing multiple brands under a parent org, where brands are separate for storefront/billing but share operations.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- An admin can be associated with multiple brands via a junction table.
|
||||||
|
- A persistent "active brand" is stored in a cookie, switchable via UI, and used as the default when no explicit brand is requested.
|
||||||
|
- A new `multi_brand_admin` role makes the relationship explicit in the data and the UI.
|
||||||
|
- `platform_admin` continues to work unchanged (gets all brands implicitly).
|
||||||
|
- Existing single-brand `brand_admin`, `store_employee`, and `staff` users continue to work with zero behavior change.
|
||||||
|
- Server actions get a single, central place to resolve and validate the active brand.
|
||||||
|
|
||||||
|
## Non-Goals (YAGNI)
|
||||||
|
|
||||||
|
- Per-(admin, brand) permission overrides. The user explicitly chose "same perms across all brands."
|
||||||
|
- Brand-group / parent-org concept. The junction table makes this possible later, but it's not built now.
|
||||||
|
- "Last accessed brand" auto-redirect. The cookie is the source of truth; no extra logic.
|
||||||
|
- UI for managing `admin_user_brands` rows. Use Supabase Studio or a follow-up admin UI PR.
|
||||||
|
- Dropping the legacy `admin_users.brand_id` column. A follow-up migration `220_drop_legacy_brand_id.sql` will do this after we verify nothing reads it. Out of scope for this spec.
|
||||||
|
|
||||||
|
## Approach: Junction Table + Backwards-Compat `brand_id`
|
||||||
|
|
||||||
|
Selected from among three options:
|
||||||
|
|
||||||
|
| Option | Why not |
|
||||||
|
|---|---|
|
||||||
|
| A. Junction + keep `brand_id` (selected) | — |
|
||||||
|
| B. Junction only, drop `brand_id` | High migration risk; every server action reference must change. |
|
||||||
|
| C. `brand_ids UUID[]` on `admin_users` | No FK, awkward reverse lookups, locks out per-brand metadata. |
|
||||||
|
|
||||||
|
A is the lowest-risk additive path that solves the problem.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### New table: `admin_user_brands`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE admin_user_brands (
|
||||||
|
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
added_by UUID REFERENCES admin_users(id),
|
||||||
|
PRIMARY KEY (admin_user_id, brand_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Composite PK enforces uniqueness.
|
||||||
|
- Index on `brand_id` makes "which admins are in Brand X?" queries fast.
|
||||||
|
- `added_at` / `added_by` for audit trail.
|
||||||
|
- `ON DELETE CASCADE` for both FKs — deleting an admin or brand cleans up the junction.
|
||||||
|
|
||||||
|
### New role: `multi_brand_admin`
|
||||||
|
|
||||||
|
Added to the `role` CHECK constraint. Functionally equivalent to `brand_admin` permission-wise — same `can_manage_*` flags apply. The role label disambiguates intent in the UI ("this person manages 3 brands" vs "this person manages 1 brand") and in audit logs.
|
||||||
|
|
||||||
|
### Updated `AdminUser` type
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/admin-permissions-types.ts
|
||||||
|
export type AdminUser = {
|
||||||
|
// ... existing fields
|
||||||
|
brand_id: string | null; // active brand (one of brand_ids, or null for platform_admin)
|
||||||
|
brand_ids: string[]; // all brands this admin can act in
|
||||||
|
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Membership rules
|
||||||
|
|
||||||
|
| Role | `brand_id` (active) | `brand_ids` (membership) |
|
||||||
|
|---|---|---|
|
||||||
|
| `platform_admin` | `null` (or cookie-selected brand) | Implicitly all brands; `brand_ids` populated by `listBrandsForAdmin` querying the `brands` table |
|
||||||
|
| `multi_brand_admin` | First/selected brand | 2+ brands from `admin_user_brands` |
|
||||||
|
| `brand_admin` | Their single brand | `[that one brand]` |
|
||||||
|
| `store_employee` | Their single brand | `[that one brand]` |
|
||||||
|
| `staff` | Their single brand | `[that one brand]` |
|
||||||
|
|
||||||
|
For `platform_admin`, the application layer short-circuits brand-access checks (`if (adminUser.role === "platform_admin") return ...`). The `brand_ids` field is only used by `listBrandsForAdmin` to render the dropdown options; it is not used for permission gating for `platform_admin`.
|
||||||
|
|
||||||
|
### `getAdminUser()` resolution order
|
||||||
|
|
||||||
|
1. If `dev_session` cookie set → return dev admin. For `platform_admin` dev: `brand_id: null, brand_ids: []` (resolved against `brands` table by `listBrandsForAdmin`). For `store_employee` dev: `brand_id: <first-real-brand-id>, brand_ids: [<that-id>]` — fetched from the `brands` table so dev store_employee can browse a real brand's data. (If no brands exist, dev store_employee sees `<AdminAccessDenied />` — known limitation.)
|
||||||
|
2. If `NEXT_PUBLIC_USE_MOCK_DATA=true` → same as `platform_admin` dev.
|
||||||
|
3. Real auth → load `admin_users` row, then JOIN `admin_user_brands` to populate `brand_ids`.
|
||||||
|
4. Set `brand_id` from (in order): `active_brand_id` cookie (if in `brand_ids` for non-platform-admin, or always for platform-admin) → `admin_users.brand_id` (if in `brand_ids`) → first of `brand_ids`.
|
||||||
|
|
||||||
|
## Server Action Patterns
|
||||||
|
|
||||||
|
### New file: `src/lib/brand-scope.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import type { AdminUser } from "./admin-permissions-types";
|
||||||
|
|
||||||
|
const ACTIVE_BRAND_COOKIE = "active_brand_id";
|
||||||
|
|
||||||
|
export async function getActiveBrandId(
|
||||||
|
adminUser: AdminUser,
|
||||||
|
requested?: string | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
// Cookie is the source of truth for "what brand am I acting in right now"
|
||||||
|
// for everyone — including platform_admin (who can pin a specific brand
|
||||||
|
// or fall back to null = "all brands").
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null;
|
||||||
|
|
||||||
|
if (adminUser.role === "platform_admin") {
|
||||||
|
// requested > cookie > null (all brands)
|
||||||
|
return requested ?? cookieBrand ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-platform-admin: requested (if in brand_ids) > cookie (if in brand_ids) > adminUser.brand_id
|
||||||
|
if (requested) {
|
||||||
|
return adminUser.brand_ids.includes(requested) ? requested : null;
|
||||||
|
}
|
||||||
|
if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) {
|
||||||
|
return cookieBrand;
|
||||||
|
}
|
||||||
|
return adminUser.brand_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setActiveBrandCookie(brandId: string): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(ACTIVE_BRAND_COOKIE, brandId, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearActiveBrandCookie(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete(ACTIVE_BRAND_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertBrandAccess(adminUser: AdminUser, brandId: string): void {
|
||||||
|
if (adminUser.role === "platform_admin") return;
|
||||||
|
if (!adminUser.brand_ids.includes(brandId)) {
|
||||||
|
throw new Error("Brand access denied");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server action: set active brand
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/actions/admin/set-active-brand.ts
|
||||||
|
"use server";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { setActiveBrandCookie, clearActiveBrandCookie } from "@/lib/brand-scope";
|
||||||
|
|
||||||
|
export async function setActiveBrand(brandId: string | null): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
|
// null = "All brands" (platform_admin only)
|
||||||
|
if (brandId === null) {
|
||||||
|
if (adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Only platform admins can select 'All brands'" };
|
||||||
|
}
|
||||||
|
await clearActiveBrandCookie();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser.role !== "platform_admin" && !adminUser.brand_ids.includes(brandId)) {
|
||||||
|
return { success: false, error: "No access to that brand" };
|
||||||
|
}
|
||||||
|
await setActiveBrandCookie(brandId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New server function: list brands for admin
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/actions/brands.ts
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
|
export async function listBrandsForAdmin(): Promise<
|
||||||
|
{ id: string; name: string; slug: string; logo_url: string | null }[]
|
||||||
|
> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
|
||||||
|
if (adminUser.role === "platform_admin") {
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
|
||||||
|
{ headers: svcHeaders(serviceKey) }
|
||||||
|
);
|
||||||
|
return res.ok ? await res.json() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser.brand_ids.length === 0) return [];
|
||||||
|
|
||||||
|
const filter = `id=in.(${adminUser.brand_ids.map((id) => `"${id}"`).join(",")})`;
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
|
||||||
|
{ headers: svcHeaders(serviceKey) }
|
||||||
|
);
|
||||||
|
return res.ok ? await res.json() : [];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server action migration pattern
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Before:
|
||||||
|
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||||
|
|
||||||
|
// After:
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a mechanical one-line swap in ~30 server actions identified by the grep:
|
||||||
|
|
||||||
|
- `src/actions/wholesale.ts` (multiple)
|
||||||
|
- `src/actions/products.ts`
|
||||||
|
- `src/actions/communications/templates.ts`
|
||||||
|
- `src/actions/communications/campaigns.ts`
|
||||||
|
- `src/actions/orders/create-admin-order.ts`
|
||||||
|
- `src/actions/stops.ts`
|
||||||
|
- `src/actions/analytics.ts`
|
||||||
|
- `src/actions/square-sync-ui.ts`
|
||||||
|
- `src/actions/shipping.ts`
|
||||||
|
- `src/actions/payments.ts`
|
||||||
|
- `src/actions/ai-import.ts`
|
||||||
|
- `src/actions/wholesale-register.ts`
|
||||||
|
|
||||||
|
### Page server component pattern
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Before (src/app/admin/orders/page.tsx):
|
||||||
|
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
|
||||||
|
|
||||||
|
// After:
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
|
||||||
|
export default async function AdminOrdersPage() {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return <AdminAccessDenied />;
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||||
|
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return <AdminAccessDenied />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... rest of the page
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to every page under `src/app/admin/` — including the existing `/admin/taxes/[brandId]` and `/admin/settings/billing/[brandId]` which already do brand param resolution.
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
### Brand selector
|
||||||
|
|
||||||
|
**Location:** `src/components/admin/AdminHeader.tsx` (the top bar already rendered on every `/admin/*` page).
|
||||||
|
|
||||||
|
**Behavior:** A dropdown showing:
|
||||||
|
- Brand logo + name (current active brand)
|
||||||
|
- Chevron
|
||||||
|
- "All brands" option at the top (only for `platform_admin`)
|
||||||
|
- List of accessible brands (`adminUser.brand_ids`)
|
||||||
|
- Small badge "Multi-brand manager" next to the user's name when `role === "multi_brand_admin"`
|
||||||
|
|
||||||
|
**Visibility matrix:**
|
||||||
|
|
||||||
|
| Admin | Show dropdown? | Options |
|
||||||
|
|---|---|---|
|
||||||
|
| `platform_admin` | Yes | "All brands" + list of all brands |
|
||||||
|
| `multi_brand_admin` (2+ brands) | Yes | List of their brands |
|
||||||
|
| `brand_admin` / `store_employee` / `staff` (1 brand) | No | — |
|
||||||
|
| `platform_admin` (dev_session) | Yes | "All brands" + list of all brands (same UX as production) |
|
||||||
|
|
||||||
|
**On select:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/components/admin/BrandSelector.tsx (client component)
|
||||||
|
"use client";
|
||||||
|
async function handleSelect(brandId: string | null) {
|
||||||
|
await setActiveBrand(brandId); // null = "All brands"
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`router.refresh()` re-runs server components and re-reads the cookie, so all data on the current page reloads in the new brand context. The URL is **not** changed — the cookie is the source of truth for "what brand am I acting in right now."
|
||||||
|
|
||||||
|
### URL-level brand params
|
||||||
|
|
||||||
|
Keep URL-level brand as-is — URLs are shareable links. The resolution order for the `brandId` param passed to `getActiveBrandId` is:
|
||||||
|
|
||||||
|
1. URL `brandId` param (if present)
|
||||||
|
2. `active_brand_id` cookie
|
||||||
|
3. `adminUser.brand_id` (legacy single brand)
|
||||||
|
4. First of `adminUser.brand_ids`
|
||||||
|
5. (platform_admin only) `null` → "all brands"
|
||||||
|
|
||||||
|
## Migration: `supabase/migrations/204_multi_brand_admin.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. Add multi_brand_admin to role CHECK constraint
|
||||||
|
ALTER TABLE admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check;
|
||||||
|
ALTER TABLE admin_users ADD CONSTRAINT admin_users_role_check
|
||||||
|
CHECK (role IN ('platform_admin', 'brand_admin', 'multi_brand_admin', 'store_employee', 'staff'));
|
||||||
|
|
||||||
|
-- 2. Create junction table
|
||||||
|
CREATE TABLE admin_user_brands (
|
||||||
|
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
added_by UUID REFERENCES admin_users(id),
|
||||||
|
PRIMARY KEY (admin_user_id, brand_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
|
||||||
|
|
||||||
|
-- 3. Backfill from existing brand_id (single-brand admins)
|
||||||
|
INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
||||||
|
SELECT id, brand_id FROM admin_users
|
||||||
|
WHERE brand_id IS NOT NULL
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- 4. Promote anyone with > 1 brand to multi_brand_admin
|
||||||
|
UPDATE admin_users
|
||||||
|
SET role = 'multi_brand_admin'
|
||||||
|
WHERE role = 'brand_admin'
|
||||||
|
AND id IN (
|
||||||
|
SELECT admin_user_id FROM admin_user_brands
|
||||||
|
GROUP BY admin_user_id HAVING COUNT(*) > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. New RPCs for adding/removing brand access
|
||||||
|
CREATE OR REPLACE FUNCTION add_admin_user_brand(
|
||||||
|
p_admin_user_id UUID, p_brand_id UUID, p_added_by UUID
|
||||||
|
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
|
||||||
|
INSERT INTO admin_user_brands (admin_user_id, brand_id, added_by)
|
||||||
|
VALUES (p_admin_user_id, p_brand_id, p_added_by)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
UPDATE admin_users SET role = 'multi_brand_admin'
|
||||||
|
WHERE id = p_admin_user_id
|
||||||
|
AND role = 'brand_admin'
|
||||||
|
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) > 1;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION remove_admin_user_brand(
|
||||||
|
p_admin_user_id UUID, p_brand_id UUID
|
||||||
|
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
|
||||||
|
DELETE FROM admin_user_brands
|
||||||
|
WHERE admin_user_id = p_admin_user_id AND brand_id = p_brand_id;
|
||||||
|
UPDATE admin_users SET role = 'brand_admin'
|
||||||
|
WHERE id = p_admin_user_id
|
||||||
|
AND role = 'multi_brand_admin'
|
||||||
|
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) = 1;
|
||||||
|
$$;
|
||||||
|
```
|
||||||
|
|
||||||
|
The `admin_users.brand_id` column is **kept** for backwards compat. A follow-up migration drops it after we verify nothing reads it.
|
||||||
|
|
||||||
|
## Error Handling & Security Boundaries
|
||||||
|
|
||||||
|
### Three failure modes
|
||||||
|
|
||||||
|
1. **Requested brand not in `brand_ids`** (e.g., URL param has a brand the admin doesn't have):
|
||||||
|
- `getActiveBrandId()` returns `null`
|
||||||
|
- Server action returns `{ success: false, error: "Brand access required" }`
|
||||||
|
- Page renders `<AdminAccessDenied />` with: "You don't have access to that brand. [Switch to a brand you have access to]"
|
||||||
|
|
||||||
|
2. **Cookie brand no longer in `brand_ids`** (admin's access was revoked while cookie was still set):
|
||||||
|
- `getActiveBrandId()` falls through to `adminUser.brand_id`, then first of `brand_ids`
|
||||||
|
- Silent recovery — no error, no UI flash
|
||||||
|
- The dropped brand just disappears from the dropdown next page load
|
||||||
|
|
||||||
|
3. **Platform admin acting on a brand they don't own:**
|
||||||
|
- Platform admin: `brand_ids = ["*"]` (sentinel) — RPCs treat as "all brands"
|
||||||
|
- Server action: never blocks platform admin from any brand
|
||||||
|
- This is intentional — platform admin = superuser
|
||||||
|
|
||||||
|
### Validation placement (defense in depth)
|
||||||
|
|
||||||
|
- Server action: `getActiveBrandId(adminUser, requested)` validates.
|
||||||
|
- Server action: `assertBrandAccess(adminUser, brandIdFromUrl)` validates (separate, for cases where the brandId comes from URL/form/RPC return rather than `getActiveBrandId`).
|
||||||
|
- RPC: still trusts `p_brand_id` (SECURITY DEFINER) — application layer is the gate, matching the existing architecture.
|
||||||
|
|
||||||
|
### Audit logging (additive)
|
||||||
|
|
||||||
|
- `admin_user_brands.added_by` column tracks who added an admin to a brand.
|
||||||
|
- Audit log entry on add/remove: out of scope for v1; documented for follow-up.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit tests (`vitest` — new dev dependency)
|
||||||
|
|
||||||
|
- `src/lib/brand-scope.test.ts`:
|
||||||
|
- `resolveActiveBrandId(platformAdmin, "X")` → `"X"`
|
||||||
|
- `resolveActiveBrandId(brandAdmin, "X")` where X is their brand → `"X"`
|
||||||
|
- `resolveActiveBrandId(brandAdmin, "Y")` where Y is not their brand → `null`
|
||||||
|
- `getActiveBrandId(multiBrandAdmin)` with cookie set to valid brand → that brand
|
||||||
|
- `getActiveBrandId(multiBrandAdmin)` with cookie set to revoked brand → first of `brand_ids`
|
||||||
|
- `assertBrandAccess(...)` throws for non-platform-admin with invalid brand
|
||||||
|
- `setActiveBrand(null)` rejected for non-platform-admin
|
||||||
|
- `setActiveBrand("X")` rejected for admin without X in `brand_ids`
|
||||||
|
|
||||||
|
### Integration tests (Playwright — already in repo)
|
||||||
|
|
||||||
|
- `tests/admin/multi-brand.spec.ts`:
|
||||||
|
- As `multi_brand_admin`, dropdown shows 2+ brands
|
||||||
|
- Click brand B → URL stays, cookie updates, page data refreshes to brand B
|
||||||
|
- Direct-navigate to `/admin/orders?brand=<other-brand>` for a brand admin returns access denied
|
||||||
|
- As `platform_admin`, "All brands" option is present and works
|
||||||
|
- As `brand_admin` with 1 brand, no dropdown is shown
|
||||||
|
|
||||||
|
### Migration smoke test (manual, documented in MEMORY.md)
|
||||||
|
|
||||||
|
- Before migration: 5 brand_admins exist, each with 1 brand
|
||||||
|
- After migration: 5 rows in `admin_user_brands`, all `role = 'brand_admin'`
|
||||||
|
- Create a 6th admin with 2 brands via `add_admin_user_brand` → `role = 'multi_brand_admin'`, 2 rows in junction
|
||||||
|
- Remove one of their brands via `remove_admin_user_brand` → `role` demotes to `brand_admin`, 1 row in junction
|
||||||
|
|
||||||
|
## Out of Scope (v1)
|
||||||
|
|
||||||
|
- Per-(admin, brand) permission overrides
|
||||||
|
- Brand-group / parent-org concept
|
||||||
|
- "Last accessed brand" auto-redirect
|
||||||
|
- UI for managing `admin_user_brands` rows (Supabase Studio works for now)
|
||||||
|
- Dropping the legacy `admin_users.brand_id` column (follow-up `220_*` migration)
|
||||||
|
- Audit log entries for add/remove (junction's `added_by` column is the seed)
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Migration `204_multi_brand_admin.sql` applied
|
||||||
|
2. `src/lib/brand-scope.ts` + unit tests (TDD)
|
||||||
|
3. `AdminUser` type updated; `getAdminUser()` returns `brand_ids`
|
||||||
|
4. `setActiveBrand` server action + tests
|
||||||
|
5. `listBrandsForAdmin` server function + tests
|
||||||
|
6. BrandSelector UI component
|
||||||
|
7. Wire BrandSelector into AdminHeader
|
||||||
|
8. Server action migration (~30 actions) — mechanical one-line swap each
|
||||||
|
9. Page server component migration (~10+ pages) — same pattern
|
||||||
|
10. Playwright integration tests
|
||||||
|
11. Manual smoke test of the migration on dev DB
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import openpyxl
|
||||||
|
path = "/home/coder/dev/x1/kyle/route_commerce-main/Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
|
||||||
|
wb = openpyxl.load_workbook(path, data_only=True)
|
||||||
|
for name in wb.sheetnames:
|
||||||
|
ws = wb[name]
|
||||||
|
print(f"=== SHEET: {name} ({ws.max_row} rows x {ws.max_column} cols) ===")
|
||||||
|
for row in ws.iter_rows(values_only=False):
|
||||||
|
for cell in row:
|
||||||
|
if cell.value is not None:
|
||||||
|
v = str(cell.value)
|
||||||
|
if len(v) > 200:
|
||||||
|
v = v[:200] + "..."
|
||||||
|
print(f" {cell.coordinate}: {v!r}")
|
||||||
|
print()
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
|
||||||
|
|
||||||
const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000";
|
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
|
||||||
const response = NextResponse.next({ request });
|
|
||||||
|
|
||||||
// ── Dev session bypass (enabled in all envs for demo) ──────────────
|
|
||||||
// Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
|
|
||||||
const devSession = request.cookies.get("dev_session")?.value;
|
|
||||||
const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee";
|
|
||||||
const rcAuthUid = request.cookies.get("rc_auth_uid")?.value;
|
|
||||||
|
|
||||||
let authUid: string | null = null;
|
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
// Dev session only valid in development
|
|
||||||
authUid = DEV_UID;
|
|
||||||
} else if (rcAuthUid) {
|
|
||||||
// rc_auth_uid is set by /api/login — treat as authenticated
|
|
||||||
authUid = rcAuthUid;
|
|
||||||
}
|
|
||||||
// No rc_auth_uid in production → authUid stays null → redirect to /login
|
|
||||||
|
|
||||||
const isAdmin = request.nextUrl.pathname.startsWith("/admin");
|
|
||||||
const isLogin = request.nextUrl.pathname === "/login";
|
|
||||||
|
|
||||||
if (isAdmin && !authUid) {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
// Auto-login for demo: no Supabase configured, no auth cookie present
|
|
||||||
if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
|
|
||||||
const url = request.nextUrl.clone();
|
|
||||||
url.pathname = "/admin";
|
|
||||||
url.searchParams.set("demo", "1");
|
|
||||||
const response = NextResponse.redirect(url);
|
|
||||||
response.cookies.set("dev_session", "platform_admin", {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "strict",
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
const url = request.nextUrl.clone();
|
|
||||||
url.pathname = "/login";
|
|
||||||
return NextResponse.redirect(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLogin && authUid) {
|
|
||||||
const url = request.nextUrl.clone();
|
|
||||||
url.pathname = "/admin";
|
|
||||||
return NextResponse.redirect(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
"/admin/:path*",
|
|
||||||
"/admin",
|
|
||||||
"/login",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
+7
-2
@@ -3,7 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0",
|
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000",
|
||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
@@ -15,10 +15,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.96.0",
|
"@anthropic-ai/sdk": "^0.96.0",
|
||||||
|
"@auth/pg-adapter": "^1.11.2",
|
||||||
"@clerk/nextjs": "^7.4.2",
|
"@clerk/nextjs": "^7.4.2",
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
"@sentry/nextjs": "^10.55.0",
|
"@sentry/nextjs": "^10.55.0",
|
||||||
|
"@stripe/react-stripe-js": "^3.10.0",
|
||||||
|
"@stripe/stripe-js": "^5.10.0",
|
||||||
"@supabase/ssr": "^0.10.2",
|
"@supabase/ssr": "^0.10.2",
|
||||||
"@supabase/supabase-js": "^2.105.3",
|
"@supabase/supabase-js": "^2.105.3",
|
||||||
"@upstash/ratelimit": "^2.0.8",
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
@@ -28,6 +31,7 @@
|
|||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"openai": "^6.37.0",
|
"openai": "^6.37.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
@@ -48,6 +52,7 @@
|
|||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -58,7 +63,7 @@
|
|||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"overrides": "{}"
|
"overrides": "{}"
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+99
@@ -0,0 +1,99 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# End-to-end validation test for the local Postgres + PostgREST + MinIO + Next.js stack
|
||||||
|
# Exit 0 = all green, exit 1 = at least one failure.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
API="http://localhost:3001"
|
||||||
|
WEB="http://localhost:4000"
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
fail=0
|
||||||
|
declare -a FAILURES
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local name="$1" url="$2" expected="${3:-200}" cookies="${4:-}"
|
||||||
|
local cmd="curl -s -o /dev/null -w '%{http_code}'"
|
||||||
|
if [ -n "$cookies" ]; then cmd="$cmd -b \"$cookies\""; fi
|
||||||
|
local code=$(eval "$cmd $url")
|
||||||
|
if [ "$code" = "$expected" ]; then
|
||||||
|
echo " PASS $name ($code)"
|
||||||
|
pass=$((pass+1))
|
||||||
|
else
|
||||||
|
echo " FAIL $name expected=$expected got=$code url=$url"
|
||||||
|
fail=$((fail+1))
|
||||||
|
FAILURES+=("$name")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Postgres connection ==="
|
||||||
|
PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT 'ok';" >/dev/null
|
||||||
|
echo " PASS postgres responds"
|
||||||
|
pass=$((pass+1))
|
||||||
|
|
||||||
|
echo "=== DB integrity ==="
|
||||||
|
TABLE_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';")
|
||||||
|
[ "$TABLE_COUNT" -ge 65 ] && echo " PASS $TABLE_COUNT public tables (>=65)" && pass=$((pass+1)) || { echo " FAIL $TABLE_COUNT tables"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
FN_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname='public';")
|
||||||
|
[ "$FN_COUNT" -ge 250 ] && echo " PASS $FN_COUNT functions (>=250)" && pass=$((pass+1)) || { echo " FAIL $FN_COUNT functions"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands;")
|
||||||
|
[ "$BRANDS" -ge 2 ] && echo " PASS $BRANDS brands" && pass=$((pass+1)) || { echo " FAIL $BRANDS brands"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
STOPS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM stops WHERE active=true AND deleted_at IS NULL;")
|
||||||
|
[ "$STOPS" -ge 200 ] && echo " PASS $STOPS active stops" && pass=$((pass+1)) || { echo " FAIL $STOPS stops"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
# No test brands
|
||||||
|
TEST_BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands WHERE slug IN ('sunrise-farms','green-valley','orchard-fresh');")
|
||||||
|
[ "$TEST_BRANDS" = "0" ] && echo " PASS no test brands" && pass=$((pass+1)) || { echo " FAIL test brands found"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
echo "=== Tuxedo Corn data ==="
|
||||||
|
PHONE=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT phone FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de';")
|
||||||
|
[ "$PHONE" = "970-323-6874" ] && echo " PASS phone=$PHONE" && pass=$((pass+1)) || { echo " FAIL phone=$PHONE (expected 970-323-6874)"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
LOGOS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de' AND logo_url LIKE '/storage/%';")
|
||||||
|
[ "$LOGOS" = "1" ] && echo " PASS logo_url is /storage/ path" && pass=$((pass+1)) || { echo " FAIL logo_url not /storage/"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
echo "=== PostgREST ==="
|
||||||
|
check "GET /" "$API/"
|
||||||
|
check "GET /brands" "$API/brands?select=id,name,slug&order=name"
|
||||||
|
check "GET /brands?slug=eq.tuxedo" "$API/brands?slug=eq.tuxedo&select=*"
|
||||||
|
check "GET /stops" "$API/stops?select=id,city&limit=5"
|
||||||
|
check "GET /products" "$API/products?select=id,name&limit=5"
|
||||||
|
check "POST rpc get_brand_settings_by_slug" "$API/rpc/get_brand_settings_by_slug" 200 \
|
||||||
|
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
|
||||||
|
check "POST rpc get_brand_plan_info" "$API/rpc/get_brand_plan_info" 200 \
|
||||||
|
"-X POST -H Content-Type:application/json -d {\"p_brand_id\":\"64294306-5f42-463d-a5e8-2ad6c81a96de\"}"
|
||||||
|
check "POST rpc get_public_stops_for_brand" "$API/rpc/get_public_stops_for_brand" 200 \
|
||||||
|
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
|
||||||
|
check "GET /admin_users with brand join" "$API/admin_users?select=id,user_id,role,brand_id,brands(name)&limit=5"
|
||||||
|
check "GET /stops with brand join" "$API/stops?select=id,city,brand_id,brands(name)&limit=5"
|
||||||
|
|
||||||
|
echo "=== MinIO ==="
|
||||||
|
check "Storage logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
|
||||||
|
check "Storage olathe-sweet-logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
|
||||||
|
check "Storage olathe-sweet-logo-dark.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
|
||||||
|
|
||||||
|
echo "=== Public storefronts ==="
|
||||||
|
check "GET /" "$WEB/"
|
||||||
|
check "GET /tuxedo" "$WEB/tuxedo"
|
||||||
|
check "GET /tuxedo/about" "$WEB/tuxedo/about"
|
||||||
|
check "GET /tuxedo/stops" "$WEB/tuxedo/stops"
|
||||||
|
check "GET /indian-river-direct" "$WEB/indian-river-direct"
|
||||||
|
check "GET /login" "$WEB/login"
|
||||||
|
|
||||||
|
echo "=== Admin pages (dev_session=platform_admin) ==="
|
||||||
|
for p in /admin /admin/products /admin/stops /admin/orders /admin/users /admin/settings /admin/settings/billing /admin/settings/apps /admin/settings/payments /admin/communications /admin/communications/compose /admin/time-tracking /admin/wholesale /admin/water-log /admin/analytics /admin/reports; do
|
||||||
|
check "GET $p" "$WEB$p" 200 "dev_session=platform_admin"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Summary ==="
|
||||||
|
echo " PASS: $pass"
|
||||||
|
echo " FAIL: $fail"
|
||||||
|
if [ $fail -gt 0 ]; then
|
||||||
|
echo " Failures:"
|
||||||
|
for f in "${FAILURES[@]}"; do echo " - $f"; done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ALL GREEN"
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
seed_tuxedo_tour.py
|
||||||
|
|
||||||
|
Parses Tuxedo_Corn_2026_Tour_Schedule-3.xlsx and seeds the `stops` table for
|
||||||
|
the Tuxedo brand via the admin_create_stops_batch RPC.
|
||||||
|
|
||||||
|
Skips:
|
||||||
|
- Title / subtitle / legend rows (rows 1-3)
|
||||||
|
- Week header rows (col A = "Wk N", col D-J empty)
|
||||||
|
- Cross-Dock / Monday OFF rows (col D contains "OFF" or "Cross-Dock")
|
||||||
|
|
||||||
|
Joins with the Stop Directory sheet to enrich each stop with:
|
||||||
|
- address, phone, contact
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/seed_tuxedo_tour.py --dry-run # show what would be inserted
|
||||||
|
python3 scripts/seed_tuxedo_tour.py # actually insert
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
- supabase CLI linked to project wnzkhezyhnfzhkhiflrp
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"
|
||||||
|
YEAR = 2026
|
||||||
|
|
||||||
|
MONTH_MAP = {
|
||||||
|
"Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04",
|
||||||
|
"May": "05", "Jun": "06", "Jul": "07", "Aug": "08",
|
||||||
|
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_XLSX = (
|
||||||
|
"/home/coder/dev/x1/kyle/route_commerce-main/"
|
||||||
|
"Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_excel_date(s):
|
||||||
|
"""'Jul 22' -> '2026-07-22'"""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
m = re.match(r"^([A-Za-z]{3})\s+(\d{1,2})$", str(s).strip())
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
mm = MONTH_MAP.get(m.group(1))
|
||||||
|
if not mm:
|
||||||
|
return None
|
||||||
|
return f"{YEAR}-{mm}-{int(m.group(2)):02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_range(s):
|
||||||
|
"""'10:00 AM - 1:00 PM' -> '10:00 AM' (start time)"""
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
cleaned = re.sub(r"[–—]", "-", str(s)).strip()
|
||||||
|
cleaned = re.sub(r"\s+", " ", cleaned)
|
||||||
|
m = re.match(r"^(\d{1,2}:\d{2}\s*[AP]M)", cleaned, re.IGNORECASE)
|
||||||
|
return m.group(1).upper().replace(" ", " ") if m else cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def split_city_state(s):
|
||||||
|
"""'Cheyenne, WY' -> ('Cheyenne', 'WY')"""
|
||||||
|
if not s:
|
||||||
|
return "", ""
|
||||||
|
parts = [p.strip() for p in str(s).split(",")]
|
||||||
|
if len(parts) == 1:
|
||||||
|
return parts[0], ""
|
||||||
|
return parts[0], parts[1]
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(s):
|
||||||
|
s = (s or "").lower()
|
||||||
|
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||||
|
return s.strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def is_week_header(row):
|
||||||
|
a = str(row[0] or "").strip()
|
||||||
|
d = str(row[3] or "").strip()
|
||||||
|
return re.match(r"^Wk\s", a) and d == ""
|
||||||
|
|
||||||
|
|
||||||
|
def is_off_row(row):
|
||||||
|
d = str(row[3] or "").strip()
|
||||||
|
return "OFF" in d or "Cross-Dock" in d or "Cross‑Dock" 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,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser";
|
import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser";
|
||||||
import { importProductsBatch } from "@/actions/import-products";
|
import { importProductsBatch } from "@/actions/import-products";
|
||||||
import { importOrdersBatch } from "@/actions/import-orders";
|
import { importOrdersBatch } from "@/actions/import-orders";
|
||||||
@@ -41,7 +42,9 @@ export async function analyzeImport(
|
|||||||
): Promise<AnalyzeImportResult> {
|
): Promise<AnalyzeImportResult> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
try {
|
||||||
|
assertBrandAccess(adminUser, brandId);
|
||||||
|
} catch {
|
||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -96,7 +97,7 @@ async function brandScopedRPC<T>(
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const brandId = adminUser.brand_id ?? null;
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
|
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -232,7 +233,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const brandId = adminUser.brand_id ?? null;
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
select: "id,customer_name,subtotal,status,created_at,fulfillment",
|
select: "id,customer_name,subtotal,status,created_at,fulfillment",
|
||||||
@@ -293,7 +294,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const brandId = adminUser.brand_id ?? null;
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
select: "id,status",
|
select: "id,status",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { signIn, signOut } from "@/lib/auth";
|
||||||
|
import { AuthError } from "next-auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
|
||||||
|
* use from client components.
|
||||||
|
*
|
||||||
|
* Why server actions?
|
||||||
|
* • The Auth.js v5 `signIn` function has to run on the server (it
|
||||||
|
* needs to set the session cookie, talk to the database adapter,
|
||||||
|
* and redirect the user to the OAuth provider).
|
||||||
|
* • Calling it from a client component via a server action keeps the
|
||||||
|
* client bundle small and avoids exposing the OAuth client secret.
|
||||||
|
*
|
||||||
|
* Usage from a client component:
|
||||||
|
* <form action={signInWithGoogle}>
|
||||||
|
* <button type="submit">Sign in with Google</button>
|
||||||
|
* </form>
|
||||||
|
*
|
||||||
|
* Usage for the dev credentials provider (dev only):
|
||||||
|
* <form action={signInWithDev}>
|
||||||
|
* <input name="username" />
|
||||||
|
* <input name="password" type="password" />
|
||||||
|
* <button type="submit">Dev login</button>
|
||||||
|
* </form>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function signInWithGoogle(): Promise<void> {
|
||||||
|
await signIn("google", { redirectTo: "/admin" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signInWithDev(formData: FormData): Promise<void> {
|
||||||
|
const username = String(formData.get("username") ?? "admin");
|
||||||
|
const password = String(formData.get("password") ?? "dev");
|
||||||
|
try {
|
||||||
|
await signIn("dev-login", {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
redirectTo: "/admin",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// signIn() throws a `NEXT_REDIRECT` to navigate — let that through
|
||||||
|
// so the redirect actually happens. Re-throw any other error so the
|
||||||
|
// caller can render a meaningful message.
|
||||||
|
if (e instanceof AuthError) {
|
||||||
|
throw new Error(`Dev sign-in failed: ${e.type}`);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOutAction(): Promise<void> {
|
||||||
|
await signOut({ redirectTo: "/login" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Stripe PaymentIntent for the supplied cart so the browser
|
||||||
|
* can confirm the payment with embedded Stripe Elements (Apple Pay /
|
||||||
|
* Google Pay / card) without redirecting to a hosted page.
|
||||||
|
*
|
||||||
|
* Mirrors `createRetailStripeCheckoutSession` in `retail-checkout.ts`:
|
||||||
|
* the PaymentIntent is the embedded equivalent of the Checkout Session.
|
||||||
|
* Order creation itself still happens on `/checkout/success` so the
|
||||||
|
* webhooks and order pipeline don't need to know which path the buyer
|
||||||
|
* used.
|
||||||
|
*/
|
||||||
|
type LineItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomerInfo = {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreatePaymentIntentResult =
|
||||||
|
| { success: true; clientSecret: string; paymentIntentId: string; amount: number }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
export async function createRetailPaymentIntent(
|
||||||
|
items: LineItem[],
|
||||||
|
customer: CustomerInfo,
|
||||||
|
brandId: string | null,
|
||||||
|
stopId: string | null,
|
||||||
|
shippingAddress?: { state?: string; postal_code?: string; city?: string } | null
|
||||||
|
): Promise<CreatePaymentIntentResult> {
|
||||||
|
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||||
|
if (!stripeKey) {
|
||||||
|
return { success: false, error: "Stripe not configured on this server." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return { success: false, error: "Cart is empty." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stripe = (await import("stripe")).default;
|
||||||
|
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||||
|
|
||||||
|
// Compute the subtotal in cents. We don't compute sales tax here —
|
||||||
|
// Stripe's `automatic_tax` would be ideal but requires address collection
|
||||||
|
// client-side; for this single-product corn box the price is tax-inclusive
|
||||||
|
// (see Tuxedo Corn product card) so we treat the line total as final.
|
||||||
|
const amount = items.reduce((sum, item) => {
|
||||||
|
const qty = Math.max(1, Math.floor(item.quantity || 1));
|
||||||
|
const unit = Math.max(0, Math.round(Number(item.price) * 100));
|
||||||
|
return sum + unit * qty;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
if (amount <= 0) {
|
||||||
|
return { success: false, error: "Cart total must be greater than $0." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the brand name for Stripe receipts + metadata
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
let brandName = "Route Commerce";
|
||||||
|
if (supabaseUrl && supabaseKey && brandId) {
|
||||||
|
try {
|
||||||
|
const brandRes = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
|
||||||
|
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||||
|
);
|
||||||
|
const brands = (await brandRes.json()) as Array<{ name: string }>;
|
||||||
|
if (brands?.[0]?.name) brandName = brands[0].name;
|
||||||
|
} catch {
|
||||||
|
// ignore — use default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a short human-readable description for the Stripe dashboard
|
||||||
|
const description = items
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((i) => `${i.quantity}× ${i.name}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const intent = await stripe.paymentIntents.create({
|
||||||
|
amount,
|
||||||
|
currency: "usd",
|
||||||
|
automatic_payment_methods: { enabled: true },
|
||||||
|
receipt_email: customer.email || undefined,
|
||||||
|
description,
|
||||||
|
metadata: {
|
||||||
|
brand_id: brandId ?? "unknown",
|
||||||
|
brand_name: brandName,
|
||||||
|
stop_id: stopId ?? "",
|
||||||
|
customer_name: customer.name ?? "",
|
||||||
|
customer_email: customer.email ?? "",
|
||||||
|
shipping_state: shippingAddress?.state ?? "",
|
||||||
|
shipping_postal_code: shippingAddress?.postal_code ?? "",
|
||||||
|
shipping_city: shippingAddress?.city ?? "",
|
||||||
|
item_count: String(items.reduce((s, i) => s + i.quantity, 0)),
|
||||||
|
source: "storefront_express",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!intent.client_secret) {
|
||||||
|
return { success: false, error: "Stripe did not return a client secret." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
clientSecret: intent.client_secret,
|
||||||
|
paymentIntentId: intent.id,
|
||||||
|
amount: intent.amount,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to create payment intent.";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -271,25 +271,37 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
|
|||||||
|
|
||||||
// Public version for storefront pages — uses slug, no auth required
|
// Public version for storefront pages — uses slug, no auth required
|
||||||
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
// Build-time prerender runs before Supabase env is configured. Return
|
||||||
|
// a not-configured result; the page falls back to slug-based defaults.
|
||||||
|
if (!supabaseUrl || !supabaseKey) {
|
||||||
|
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
|
// doesn't crash the prerender — the page just falls back to its
|
||||||
{
|
// default brand name and revalidates from a real request later.
|
||||||
method: "POST",
|
try {
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
const response = await fetch(
|
||||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
|
||||||
}
|
{
|
||||||
);
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
settings: data,
|
settings: data,
|
||||||
wholesaleEnabled: data?.wholesale_enabled,
|
wholesaleEnabled: data?.wholesale_enabled,
|
||||||
};
|
};
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveBrandSettings(params: {
|
export async function saveBrandSettings(params: {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
|
export type BrandListItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
logo_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of brands the current admin user can act in.
|
||||||
|
*
|
||||||
|
* - platform_admin: all brands (queried directly from the `brands` table)
|
||||||
|
* - everyone else: brands in `adminUser.brand_ids`
|
||||||
|
* - empty array for unauthenticated / no-access admins
|
||||||
|
*
|
||||||
|
* This is a plain async function (not a server action) so it can be called
|
||||||
|
* from server components and server actions without the "use server" wrapper.
|
||||||
|
* The BrandSelector client component receives the result as a prop.
|
||||||
|
*/
|
||||||
|
export async function listBrandsForAdmin(): Promise<BrandListItem[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
if (!supabaseUrl || !serviceKey) return [];
|
||||||
|
|
||||||
|
if (adminUser.role === "platform_admin") {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
|
||||||
|
{ headers: svcHeaders(serviceKey), cache: "no-store" }
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser.brand_ids.length === 0) return [];
|
||||||
|
|
||||||
|
// Use PostgREST `in` filter. The brand_ids are UUIDs, so the quoting
|
||||||
|
// pattern in the spec is safe; the inner quotes are required by PostgREST
|
||||||
|
// for UUID literals.
|
||||||
|
const filter = `id=in.(${adminUser.brand_ids
|
||||||
|
.map((id) => `"${id}"`)
|
||||||
|
.join(",")})`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
|
||||||
|
{ headers: svcHeaders(serviceKey), cache: "no-store" }
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||||
@@ -57,7 +58,11 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
@@ -147,7 +152,7 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: adminUser.brand_id ?? null }),
|
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -167,7 +172,7 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: brandId ?? adminUser.brand_id ?? null }),
|
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser, brandId)) ?? null }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
import type { AudienceRules } from "./campaigns";
|
import type { AudienceRules } from "./campaigns";
|
||||||
|
|
||||||
@@ -120,11 +121,15 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
// Resolve brand from campaign or parameter
|
// Resolve brand from campaign or parameter (URL > cookie > legacy > first of brand_ids)
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
|
||||||
// Brand scoping: brand_admin can only send their own brand's campaigns
|
// Brand scoping: brand_admin can only send their own brand's campaigns
|
||||||
if (adminUser.role === "brand_admin" && effectiveBrandId && adminUser.brand_id !== effectiveBrandId) {
|
if (adminUser.role === "brand_admin" && effectiveBrandId && !adminUser.brand_ids.includes(effectiveBrandId)) {
|
||||||
return { success: false, error: "Not authorized to send this brand's campaigns" };
|
return { success: false, error: "Not authorized to send this brand's campaigns" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
import type { AudienceRules } from "./campaigns";
|
import type { AudienceRules } from "./campaigns";
|
||||||
|
|
||||||
@@ -33,7 +34,11 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
|
||||||
// Brand scoping: brand_admin can only see their own brand's templates
|
// Brand scoping: brand_admin can only see their own brand's templates
|
||||||
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
|
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
|
||||||
@@ -112,7 +117,7 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? null }),
|
body: JSON.stringify({ p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -66,7 +67,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const brandId = adminUser.brand_id ?? null;
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
// Get today's date range
|
// Get today's date range
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -202,7 +203,7 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const brandId = adminUser.brand_id ?? null;
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
const thirtyDaysAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidateTag } from "next/cache";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
|
export type LocationInput = {
|
||||||
|
name: string;
|
||||||
|
address?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
contact_name?: string | null;
|
||||||
|
contact_email?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Location = {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
zip: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
contact_name: string | null;
|
||||||
|
contact_email: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
active: boolean;
|
||||||
|
deleted_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
slug: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationWithCount = Location & { stop_count: number };
|
||||||
|
|
||||||
|
// ── Create (single) ──────────────────────────────────────────────────────────
|
||||||
|
export async function createLocation(
|
||||||
|
brandId: string,
|
||||||
|
input: LocationInput
|
||||||
|
): Promise<{ success: true; id: string; slug: string } | { success: false; error: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_stops) {
|
||||||
|
return { success: false, error: "Not authorized to manage locations" };
|
||||||
|
}
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
if (!effectiveBrandId) return { success: false, error: "No brand selected" };
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/admin_create_location`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
p_brand_id: effectiveBrandId,
|
||||||
|
p_name: input.name,
|
||||||
|
p_address: input.address ?? null,
|
||||||
|
p_city: input.city ?? null,
|
||||||
|
p_state: input.state ?? null,
|
||||||
|
p_zip: input.zip ?? null,
|
||||||
|
p_phone: input.phone ?? null,
|
||||||
|
p_contact_name: input.contact_name ?? null,
|
||||||
|
p_contact_email: input.contact_email ?? null,
|
||||||
|
p_notes: input.notes ?? null,
|
||||||
|
p_active: input.active ?? true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||||
|
return { success: false, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
revalidateTag("locations", "default");
|
||||||
|
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||||
|
return { success: true, id: data.id, slug: data.slug };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create (batch) ───────────────────────────────────────────────────────────
|
||||||
|
export async function createLocationsBatch(
|
||||||
|
brandId: string,
|
||||||
|
locations: LocationInput[]
|
||||||
|
): Promise<{ success: boolean; created: number; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_stops) {
|
||||||
|
return { success: false, created: 0, error: "Not authorized" };
|
||||||
|
}
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, created: 0, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/admin_create_locations_batch`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||||
|
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await res.json();
|
||||||
|
revalidateTag("locations", "default");
|
||||||
|
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
created: Array.isArray(inserted) ? inserted.length : locations.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update (partial) ─────────────────────────────────────────────────────────
|
||||||
|
export async function updateLocation(
|
||||||
|
locationId: string,
|
||||||
|
brandId: string,
|
||||||
|
updates: Partial<LocationInput>
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/admin_update_location`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||||
|
return { success: false, error: (err as { message?: string }).message ?? "Update failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
|
||||||
|
|
||||||
|
revalidateTag("locations", "default");
|
||||||
|
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete (soft) ────────────────────────────────────────────────────────────
|
||||||
|
export async function deleteLocation(
|
||||||
|
locationId: string,
|
||||||
|
brandId: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/admin_delete_location`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||||
|
return { success: false, error: (err as { message?: string }).message ?? "Delete failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) return { success: false, error: data.error ?? "Delete failed" };
|
||||||
|
|
||||||
|
revalidateTag("locations", "default");
|
||||||
|
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read (admin, by brand_id) ────────────────────────────────────────────────
|
||||||
|
export async function adminListLocations(
|
||||||
|
brandId: string
|
||||||
|
): Promise<LocationWithCount[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|
||||||
|
// Application-layer brand scoping: platform_admin (brand_id: null) gets all
|
||||||
|
// brands; everyone else gets only their own brand.
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/admin_list_locations`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||||
|
next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? (data as LocationWithCount[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read (public, by brand slug) ─────────────────────────────────────────────
|
||||||
|
export type PublicLocation = Pick<
|
||||||
|
Location,
|
||||||
|
"id" | "name" | "address" | "city" | "state" | "zip" | "phone" | "slug"
|
||||||
|
> & { stop_count: number };
|
||||||
|
|
||||||
|
export async function getPublicLocationsForBrand(
|
||||||
|
brandSlug: string
|
||||||
|
): Promise<PublicLocation[]> {
|
||||||
|
if (!brandSlug) return [];
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/get_locations_for_brand`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||||
|
next: { revalidate: 300, tags: ["locations", `brand:${brandSlug}:locations`] },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? (data as PublicLocation[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attach a stop to a location ──────────────────────────────────────────────
|
||||||
|
export async function attachStopToLocation(
|
||||||
|
stopId: string,
|
||||||
|
locationId: string,
|
||||||
|
brandId: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
p_stop_id: stopId,
|
||||||
|
p_location_id: locationId,
|
||||||
|
p_brand_id: brandId,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||||
|
return { success: false, error: (err as { message?: string }).message ?? "Attach failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) return { success: false, error: data.error ?? "Attach failed" };
|
||||||
|
|
||||||
|
revalidateTag("stops", "default");
|
||||||
|
revalidateTag("locations", "default");
|
||||||
|
revalidateTag(`brand:${brandId}:stops`, "default");
|
||||||
|
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
@@ -41,7 +42,11 @@ export async function createAdminOrder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Brand scoping
|
// Brand scoping
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id && effectiveBrandId !== adminUser.brand_id) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id && effectiveBrandId !== adminUser.brand_id) {
|
||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type PaymentProvider = "stripe" | "square" | "manual";
|
export type PaymentProvider = "stripe" | "square" | "manual";
|
||||||
@@ -65,10 +66,9 @@ export async function savePaymentSettings(params: {
|
|||||||
return { success: false, error: "Not authorized" };
|
return { success: false, error: "Not authorized" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
try {
|
||||||
adminUser.role === "brand_admin" &&
|
assertBrandAccess(adminUser, params.brandId);
|
||||||
adminUser.brand_id !== params.brandId
|
} catch {
|
||||||
) {
|
|
||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export async function deleteProduct(
|
export async function deleteProduct(
|
||||||
@@ -16,7 +17,11 @@ export async function deleteProduct(
|
|||||||
return { success: false, error: "Not authorized to manage products" };
|
return { success: false, error: "Not authorized to manage products" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ export async function uploadProductImage(
|
|||||||
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
|
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" },
|
headers: {
|
||||||
|
"apikey": supabaseKey,
|
||||||
|
"Authorization": `Bearer ${supabaseKey}`,
|
||||||
|
"Content-Type": `image/${ext}`,
|
||||||
|
"x-upsert": "true"
|
||||||
|
},
|
||||||
body: buffer,
|
body: buffer,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -153,7 +154,14 @@ export interface FieldYieldSummary {
|
|||||||
export async function getRouteTraceLots(brandId: string, status?: string) {
|
export async function getRouteTraceLots(brandId: string, status?: string) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -235,7 +243,14 @@ export async function createHarvestLot(
|
|||||||
) {
|
) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -298,7 +313,14 @@ export async function updateHarvestLotStatus(
|
|||||||
export async function getRouteTraceStats(brandId: string) {
|
export async function getRouteTraceStats(brandId: string) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -318,7 +340,14 @@ export async function getRouteTraceStats(brandId: string) {
|
|||||||
export async function searchHarvestLots(brandId: string, query: string) {
|
export async function searchHarvestLots(brandId: string, query: string) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -347,7 +376,14 @@ export async function getTraceChain(lotId: string) {
|
|||||||
export async function getHarvestLotsReadyToHaul(brandId: string) {
|
export async function getHarvestLotsReadyToHaul(brandId: string) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -364,7 +400,14 @@ export async function getHarvestLotsReadyToHaul(brandId: string) {
|
|||||||
export async function getFieldYieldSummary(brandId: string) {
|
export async function getFieldYieldSummary(brandId: string) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -405,7 +448,14 @@ export interface InventoryByCrop {
|
|||||||
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> {
|
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -451,7 +501,14 @@ export async function getRecentLotEvents(
|
|||||||
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> {
|
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
if (activeBrandId) {
|
||||||
|
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? undefined;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import {
|
||||||
|
setActiveBrandCookie,
|
||||||
|
clearActiveBrandCookie,
|
||||||
|
} from "@/lib/brand-scope";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the persistent "active brand" for the current admin user.
|
||||||
|
*
|
||||||
|
* - `brandId === null`: "All brands" — only allowed for platform_admin.
|
||||||
|
* Clears the cookie (cookie absence = no specific brand pinned).
|
||||||
|
* - `brandId` string: sets the cookie, after validating the admin has access.
|
||||||
|
*
|
||||||
|
* The active brand is the default the UI uses for pages that don't receive
|
||||||
|
* an explicit `brandId` from the URL. The cookie is the source of truth —
|
||||||
|
* the URL is only for deep-linking.
|
||||||
|
*/
|
||||||
|
export async function setActiveBrand(
|
||||||
|
brandId: string | null
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
|
// null = "All brands" (platform_admin only)
|
||||||
|
if (brandId === null) {
|
||||||
|
if (adminUser.role !== "platform_admin") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Only platform admins can select 'All brands'",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await clearActiveBrandCookie();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
adminUser.role !== "platform_admin" &&
|
||||||
|
!adminUser.brand_ids.includes(brandId)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "No access to that brand" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await setActiveBrandCookie(brandId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type UpdateShippingStatusResult =
|
export type UpdateShippingStatusResult =
|
||||||
@@ -28,7 +29,7 @@ export async function updateShippingStatus(
|
|||||||
p_order_id: orderId,
|
p_order_id: orderId,
|
||||||
p_shipping_status: status,
|
p_shipping_status: status,
|
||||||
p_tracking_number: trackingNumber ?? null,
|
p_tracking_number: trackingNumber ?? null,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: await getActiveBrandId(adminUser),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -79,7 +80,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: await getActiveBrandId(adminUser),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
import type { FedExServiceType } from "./fedex-rates";
|
import type { FedExServiceType } from "./fedex-rates";
|
||||||
|
|
||||||
@@ -156,6 +157,10 @@ export async function createFedExShipment(
|
|||||||
const order = orders[0];
|
const order = orders[0];
|
||||||
if (!order) return { success: false, error: "Order not found" };
|
if (!order) return { success: false, error: "Order not found" };
|
||||||
|
|
||||||
|
// The order's brand is the source of truth. Validate the admin has access.
|
||||||
|
if (order.brand_id) {
|
||||||
|
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||||
|
|
||||||
// Get FedEx settings
|
// Get FedEx settings
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -177,6 +178,10 @@ export async function getFedExRates(
|
|||||||
const order = orders[0];
|
const order = orders[0];
|
||||||
if (!order) return { success: false, error: "Order not found" };
|
if (!order) return { success: false, error: "Order not found" };
|
||||||
|
|
||||||
|
// The order's brand is the source of truth. Validate the admin has access.
|
||||||
|
if (order.brand_id) {
|
||||||
|
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||||
|
}
|
||||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||||
|
|
||||||
// Fetch shipping settings for this brand
|
// Fetch shipping settings for this brand
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type SyncLogEntry = {
|
export type SyncLogEntry = {
|
||||||
@@ -29,7 +30,9 @@ export async function syncSquareNow(
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
|
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, synced: 0, errors: ["Not authorized"] };
|
if (!adminUser.can_manage_orders) return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
try {
|
||||||
|
assertBrandAccess(adminUser, brandId);
|
||||||
|
} catch {
|
||||||
return { success: false, synced: 0, errors: ["Not authorized"] };
|
return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +64,9 @@ export async function getSyncLog(brandId: string): Promise<{
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, logs: [] };
|
if (!adminUser) return { success: false, logs: [] };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, logs: [] };
|
if (!adminUser.can_manage_orders) return { success: false, logs: [] };
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
try {
|
||||||
|
assertBrandAccess(adminUser, brandId);
|
||||||
|
} catch {
|
||||||
return { success: false, logs: [] };
|
return { success: false, logs: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-31
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type StopImportRow = {
|
export type StopImportRow = {
|
||||||
@@ -25,7 +26,11 @@ export async function createStopsBatch(
|
|||||||
return { success: false, created: 0, error: "Not authorized to manage stops" };
|
return { success: false, created: 0, error: "Not authorized to manage stops" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveBrandId = brandId || adminUser.brand_id;
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return { success: false, created: 0, error: "Brand access required" };
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId;
|
||||||
if (!effectiveBrandId) {
|
if (!effectiveBrandId) {
|
||||||
return { success: false, created: 0, error: "No brand selected" };
|
return { success: false, created: 0, error: "No brand selected" };
|
||||||
}
|
}
|
||||||
@@ -105,22 +110,31 @@ export type StopForSitemap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
// Build-time prerender runs before Supabase env is configured. Returning
|
||||||
|
// an empty list lets the sitemap render with only the static URLs.
|
||||||
|
if (!supabaseUrl || !supabaseKey) return [];
|
||||||
|
|
||||||
// Get all active stops with their brand slug
|
// Get all active stops with their brand slug. Wrapped in try/catch so a
|
||||||
const response = await fetch(
|
// build-time outage (ECONNREFUSED) doesn't crash the prerender — the
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
|
// sitemap just renders without stop URLs.
|
||||||
{
|
try {
|
||||||
method: "POST",
|
const response = await fetch(
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
|
||||||
}
|
{
|
||||||
);
|
method: "POST",
|
||||||
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
|
|
||||||
const stops = await response.json();
|
const stops = await response.json();
|
||||||
return Array.isArray(stops) ? stops : [];
|
return Array.isArray(stops) ? stops : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,24 +164,35 @@ export async function getPublicStopsForBrand(
|
|||||||
): Promise<PublicStop[]> {
|
): Promise<PublicStop[]> {
|
||||||
if (!brandSlug) return [];
|
if (!brandSlug) return [];
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
// Build-time prerender runs before Supabase env is configured. Returning
|
||||||
|
// an empty list lets the page render with zero stops; at runtime the
|
||||||
|
// fetch path is unchanged.
|
||||||
|
if (!supabaseUrl || !supabaseKey) return [];
|
||||||
|
|
||||||
const response = await fetch(
|
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
|
// doesn't crash the prerender — the page just renders with no stops
|
||||||
{
|
// and revalidates from a real request once the cache is warm.
|
||||||
method: "POST",
|
try {
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
const response = await fetch(
|
||||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
|
||||||
next: {
|
{
|
||||||
revalidate: 300,
|
method: "POST",
|
||||||
tags: ["stops", `brand:${brandSlug}:stops`],
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
},
|
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||||
}
|
next: {
|
||||||
);
|
revalidate: 300,
|
||||||
|
tags: ["stops", `brand:${brandSlug}:stops`],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
|
|
||||||
const stops = await response.json();
|
const stops = await response.json();
|
||||||
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
export type StopDetail = {
|
||||||
|
id: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
location: string;
|
||||||
|
slug: string;
|
||||||
|
active: boolean;
|
||||||
|
brand_id: string;
|
||||||
|
address: string | null;
|
||||||
|
zip: string | null;
|
||||||
|
cutoff_time: string | null;
|
||||||
|
brands: { name: string; slug: string } | { name: string; slug: string }[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssignedProduct = {
|
||||||
|
id: string;
|
||||||
|
product_id: string;
|
||||||
|
products: { name: string; type: string; price: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StopDetailsResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
stop: StopDetail;
|
||||||
|
allProducts: { id: string; name: string; type: string; price: number }[];
|
||||||
|
assignedProducts: AssignedProduct[];
|
||||||
|
brands: { id: string; name: string; slug: string }[];
|
||||||
|
/** admin_users.user_id of the caller, forwarded to RPCs that authorise via user_id lookup */
|
||||||
|
callerUid: string;
|
||||||
|
}
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single stop with its brand, all candidate products, currently
|
||||||
|
* assigned products, and the list of brands (for the brand switcher in the
|
||||||
|
* edit form). Mirrors the data the old `/admin/stops/[id]` page server
|
||||||
|
* component loaded, so the modal can be a drop-in replacement.
|
||||||
|
*/
|
||||||
|
export async function getStopDetails(stopId: string): Promise<StopDetailsResult> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_stops) {
|
||||||
|
return { success: false, error: "Not authorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||||
|
|
||||||
|
// Use a fresh server-side client (cookie-less) so RLS doesn't block reads
|
||||||
|
// for platform_admin dev sessions. The auth check above has already gated
|
||||||
|
// access.
|
||||||
|
const server = useMockData
|
||||||
|
? null
|
||||||
|
: createClient(supabaseUrl, supabaseKey, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Stop + brand
|
||||||
|
let stop: StopDetail | null = null;
|
||||||
|
let stopErr: string | null = null;
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
const { data, error } = await server
|
||||||
|
.from("stops")
|
||||||
|
.select("*, brands(name, slug)")
|
||||||
|
.eq("id", stopId)
|
||||||
|
.single();
|
||||||
|
if (error) stopErr = error.message;
|
||||||
|
else stop = (data ?? null) as StopDetail | null;
|
||||||
|
} else {
|
||||||
|
// Mock fallback — empty
|
||||||
|
stopErr = "Stop not found";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stop) {
|
||||||
|
return { success: false, error: stopErr ?? "Stop not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brand-scope check for brand_admin
|
||||||
|
if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) {
|
||||||
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Candidate products for this brand
|
||||||
|
const { data: allProducts } = server
|
||||||
|
? await server
|
||||||
|
.from("products")
|
||||||
|
.select("id, name, type, price")
|
||||||
|
.eq("brand_id", stop.brand_id)
|
||||||
|
.eq("active", true)
|
||||||
|
: { data: [] as { id: string; name: string; type: string; price: number }[] };
|
||||||
|
|
||||||
|
// 3. Assigned products (joined with product info)
|
||||||
|
const { data: productStops } = server
|
||||||
|
? await server
|
||||||
|
.from("product_stops")
|
||||||
|
.select("id, product_id, products(id, name, type, price)")
|
||||||
|
.eq("stop_id", stopId)
|
||||||
|
: { data: [] as AssignedProduct[] };
|
||||||
|
|
||||||
|
// 4. Brands for the brand switcher
|
||||||
|
const { data: brands } = server
|
||||||
|
? await server.from("brands").select("id, name, slug")
|
||||||
|
: { data: [] as { id: string; name: string; slug: string }[] };
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stop,
|
||||||
|
allProducts: allProducts ?? [],
|
||||||
|
assignedProducts: (productStops ?? []) as AssignedProduct[],
|
||||||
|
brands: brands ?? [],
|
||||||
|
callerUid: adminUser.user_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
type Irrigator = {
|
type Irrigator = {
|
||||||
@@ -94,7 +95,7 @@ export async function updateWaterHeadgate(
|
|||||||
p_name: name,
|
p_name: name,
|
||||||
p_active: active,
|
p_active: active,
|
||||||
p_unit: unit ?? null,
|
p_unit: unit ?? null,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
p_high_threshold: highThreshold ?? null,
|
p_high_threshold: highThreshold ?? null,
|
||||||
p_low_threshold: lowThreshold ?? null,
|
p_low_threshold: lowThreshold ?? null,
|
||||||
}),
|
}),
|
||||||
@@ -186,7 +187,7 @@ export async function updateWaterIrrigator(
|
|||||||
p_active: active,
|
p_active: active,
|
||||||
p_lang: lang,
|
p_lang: lang,
|
||||||
p_role: role,
|
p_role: role,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -215,7 +216,7 @@ export async function resetWaterIrrigatorPin(
|
|||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_user_id: irrigatorId,
|
p_user_id: irrigatorId,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -242,7 +243,7 @@ export async function deleteWaterUser(userId: string): Promise<{ success: boolea
|
|||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_user_id: userId,
|
p_user_id: userId,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -269,7 +270,7 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
|
|||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_headgate_id: headgateId,
|
p_headgate_id: headgateId,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -356,7 +357,7 @@ export async function regenerateHeadgateToken(headgateId: string): Promise<{ suc
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: adminUser.brand_id ?? null }),
|
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -392,7 +393,7 @@ export async function updateWaterEntry(
|
|||||||
p_measurement: measurement,
|
p_measurement: measurement,
|
||||||
p_notes: notes,
|
p_notes: notes,
|
||||||
p_unit: unit ?? null,
|
p_unit: unit ?? null,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -421,7 +422,7 @@ export async function deleteWaterEntry(
|
|||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_entry_id: entryId,
|
p_entry_id: entryId,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export async function registerWholesaleCustomer(params: {
|
export async function registerWholesaleCustomer(params: {
|
||||||
@@ -81,7 +82,9 @@ export async function approveWholesaleRegistration(
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
if (adminUser.role !== "platform_admin" && adminUser.brand_id !== brandId) {
|
try {
|
||||||
|
assertBrandAccess(adminUser, brandId);
|
||||||
|
} catch {
|
||||||
return { success: false, error: "Not authorized to operate on this brand" };
|
return { success: false, error: "Not authorized to operate on this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-27
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
export type WholesaleOrder = {
|
export type WholesaleOrder = {
|
||||||
@@ -129,15 +130,16 @@ export type WholesaleDashboardStats = {
|
|||||||
* platform_admin → null (means "all brands" — passes to RPC unchanged)
|
* platform_admin → null (means "all brands" — passes to RPC unchanged)
|
||||||
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
|
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
|
||||||
* store_employee → their own brand_id
|
* store_employee → their own brand_id
|
||||||
|
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
|
||||||
* unauthenticated → null (actions should already bail out earlier)
|
* unauthenticated → null (actions should already bail out earlier)
|
||||||
*
|
*
|
||||||
* This prevents brand_admin from seeing or modifying another brand's data
|
* This prevents brand_admin from seeing or modifying another brand's data
|
||||||
* even if they manually pass a different brandId to the action.
|
* even if they manually pass a different brandId to the action.
|
||||||
*/
|
*/
|
||||||
function resolveBrandId(
|
async function resolveBrandId(
|
||||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||||
requestedBrandId?: string
|
requestedBrandId?: string
|
||||||
): string | null {
|
): Promise<string | null> {
|
||||||
if (!adminUser) return null;
|
if (!adminUser) return null;
|
||||||
|
|
||||||
if (adminUser.role === "platform_admin") {
|
if (adminUser.role === "platform_admin") {
|
||||||
@@ -145,15 +147,15 @@ function resolveBrandId(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// brand_admin and store_employee are scoped to their own brand
|
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||||
const userBrand = adminUser.brand_id ?? null;
|
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||||
|
|
||||||
if (requestedBrandId && requestedBrandId !== userBrand) {
|
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||||
// Brand admin trying to operate on another brand's data — block it
|
// Brand admin trying to operate on another brand's data — block it
|
||||||
return null; // caller should check and return unauthorized
|
return null; // caller should check and return unauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
return userBrand;
|
return activeBrandId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,23 +163,24 @@ function resolveBrandId(
|
|||||||
* if a brand_admin tries to operate outside their brand.
|
* if a brand_admin tries to operate outside their brand.
|
||||||
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
|
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
|
||||||
*/
|
*/
|
||||||
function enforceBrandScope(
|
async function enforceBrandScope(
|
||||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||||
requestedBrandId?: string
|
requestedBrandId?: string
|
||||||
): { brandId: string | null; error?: string } {
|
): Promise<{ brandId: string | null; error?: string }> {
|
||||||
if (!adminUser) return { brandId: null, error: "Not authenticated" };
|
if (!adminUser) return { brandId: null, error: "Not authenticated" };
|
||||||
|
|
||||||
if (adminUser.role === "platform_admin") {
|
if (adminUser.role === "platform_admin") {
|
||||||
return { brandId: null }; // unrestricted
|
return { brandId: null }; // unrestricted
|
||||||
}
|
}
|
||||||
|
|
||||||
const userBrand = adminUser.brand_id ?? null;
|
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||||
|
|
||||||
if (requestedBrandId && requestedBrandId !== userBrand) {
|
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||||
return { brandId: null, error: "Not authorized to operate on this brand" };
|
return { brandId: null, error: "Not authorized to operate on this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { brandId: userBrand };
|
return { brandId: activeBrandId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Orders ──────────────────────────────────────────────────────────────────
|
// ── Orders ──────────────────────────────────────────────────────────────────
|
||||||
@@ -185,7 +188,7 @@ function enforceBrandScope(
|
|||||||
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return [];
|
if (!adminUser) return [];
|
||||||
const bid = resolveBrandId(adminUser, brandId);
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
@@ -209,7 +212,7 @@ export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrd
|
|||||||
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return [];
|
if (!adminUser) return [];
|
||||||
const bid = resolveBrandId(adminUser, brandId);
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
@@ -256,7 +259,7 @@ export async function markWholesaleOrderFulfilled(orderId: string, brandId?: str
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { brandId: resolved, error } = enforceBrandScope(adminUser, brandId);
|
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -305,7 +308,7 @@ export async function updateWholesaleOrderStatus(
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { error } = enforceBrandScope(adminUser, brandId);
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -332,7 +335,7 @@ export async function deleteWholesaleOrder(orderId: string, brandId?: string): P
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { error } = enforceBrandScope(adminUser, brandId);
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -359,7 +362,7 @@ export async function deleteWholesaleCustomer(customerId: string, brandId?: stri
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { error } = enforceBrandScope(adminUser, brandId);
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -388,7 +391,7 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { error } = enforceBrandScope(adminUser, brandId);
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -417,7 +420,7 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
|
|||||||
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
|
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return [];
|
if (!adminUser) return [];
|
||||||
const bid = resolveBrandId(adminUser, brandId);
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
@@ -458,7 +461,7 @@ export async function saveWholesaleCustomer(params: {
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { error } = enforceBrandScope(adminUser, params.brandId);
|
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -501,7 +504,7 @@ export async function saveWholesaleCustomer(params: {
|
|||||||
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
|
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return [];
|
if (!adminUser) return [];
|
||||||
const bid = resolveBrandId(adminUser, brandId);
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
@@ -545,7 +548,7 @@ export async function saveWholesaleProduct(params: {
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const { error } = enforceBrandScope(adminUser, params.brandId);
|
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||||
if (error) return { success: false, error };
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
@@ -591,7 +594,10 @@ export async function saveWholesaleProduct(params: {
|
|||||||
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
|
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return null;
|
if (!adminUser) return null;
|
||||||
const bid = brandId ?? adminUser.brand_id ?? null;
|
const bid = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!bid && adminUser.role !== "platform_admin") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
@@ -700,7 +706,7 @@ export async function recordWholesaleDeposit(
|
|||||||
p_method: method,
|
p_method: method,
|
||||||
p_reference: reference ?? null,
|
p_reference: reference ?? null,
|
||||||
p_recorded_by: adminUser.user_id,
|
p_recorded_by: adminUser.user_id,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: await getActiveBrandId(adminUser),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -710,7 +716,7 @@ export async function recordWholesaleDeposit(
|
|||||||
if (!data?.success) return { success: false, error: data?.error ?? "Failed to record deposit" };
|
if (!data?.success) return { success: false, error: data?.error ?? "Failed to record deposit" };
|
||||||
|
|
||||||
// Fire webhook — fire-and-forget
|
// Fire webhook — fire-and-forget
|
||||||
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, adminUser.brand_id ?? undefined).catch(() => {});
|
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, (await getActiveBrandId(adminUser)) ?? undefined).catch(() => {});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -753,7 +759,7 @@ export async function bulkFulfillWholesaleOrders(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_order_ids: orderIds,
|
p_order_ids: orderIds,
|
||||||
p_by: adminUser.user_id,
|
p_by: adminUser.user_id,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: await getActiveBrandId(adminUser),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -791,7 +797,7 @@ export async function bulkRecordWholesaleDeposit(
|
|||||||
p_method: method,
|
p_method: method,
|
||||||
p_reference: reference ?? null,
|
p_reference: reference ?? null,
|
||||||
p_recorded_by: adminUser.user_id,
|
p_recorded_by: adminUser.user_id,
|
||||||
p_brand_id: adminUser.brand_id ?? null,
|
p_brand_id: await getActiveBrandId(adminUser),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import AdminSidebar from "@/components/admin/AdminSidebar";
|
import AdminSidebar from "@/components/admin/AdminSidebar";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { listBrandsForAdmin } from "@/actions/brands";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import "@/styles/admin-design-system.css";
|
import "@/styles/admin-design-system.css";
|
||||||
import { ToastProvider } from "@/components/admin/Toast";
|
import { ToastProvider } from "@/components/admin/Toast";
|
||||||
import { ToastContainer } from "@/components/admin/ToastContainer";
|
import { ToastContainer } from "@/components/admin/ToastContainer";
|
||||||
|
|
||||||
|
// Admin layout calls getAdminUser() which reads cookies(). Without this,
|
||||||
|
// Next.js tries to prerender the entire /admin/* tree statically and the
|
||||||
|
// first page that hits cookies() aborts the build with DYNAMIC_SERVER_USAGE.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// Toast provider wrapper component
|
// Toast provider wrapper component
|
||||||
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
|
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -57,9 +64,22 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
redirect("/change-password");
|
redirect("/change-password");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the active brand (URL > cookie > legacy > first of brand_ids)
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
|
// Fetch accessible brands for the sidebar BrandSelector. We do this
|
||||||
|
// unconditionally — `listBrandsForAdmin` is cheap and the sidebar
|
||||||
|
// decides whether to show the dropdown.
|
||||||
|
const brands = await listBrandsForAdmin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProviderWrapper>
|
<ToastProviderWrapper>
|
||||||
<AdminSidebar userRole={adminUser.role} />
|
<AdminSidebar
|
||||||
|
userRole={adminUser.role}
|
||||||
|
brandIds={adminUser.brand_ids}
|
||||||
|
activeBrandId={activeBrandId}
|
||||||
|
brands={brands}
|
||||||
|
/>
|
||||||
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
|
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { getAdminOrders } from "@/actions/orders";
|
import { getAdminOrders } from "@/actions/orders";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import { PageHeader } from "@/components/admin/design-system";
|
import { PageHeader } from "@/components/admin/design-system";
|
||||||
@@ -17,13 +18,19 @@ export default async function AdminOrdersPage() {
|
|||||||
redirect("/admin/pickup");
|
redirect("/admin/pickup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
// Platform admin can browse all brands' orders; everyone else must have a brand
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return <AdminAccessDenied message="You don't have access to any brand." />;
|
||||||
|
}
|
||||||
|
|
||||||
const { orders, stops } = await getAdminOrders();
|
const { orders, stops } = await getAdminOrders();
|
||||||
|
|
||||||
const brandStops = adminUser?.brand_id
|
const brandStops = activeBrandId
|
||||||
? stops.filter((s) => s.brand_id === adminUser.brand_id)
|
? stops.filter((s) => s.brand_id === activeBrandId)
|
||||||
: stops;
|
: stops;
|
||||||
|
|
||||||
const brandOrders = adminUser?.brand_id
|
const brandOrders = activeBrandId
|
||||||
? orders.filter(
|
? orders.filter(
|
||||||
(o) =>
|
(o) =>
|
||||||
o.stops && brandStops.some((s) => s.id === o.stop_id)
|
o.stops && brandStops.some((s) => s.id === o.stop_id)
|
||||||
@@ -41,8 +48,8 @@ export default async function AdminOrdersPage() {
|
|||||||
.order("name")
|
.order("name")
|
||||||
.limit(200);
|
.limit(200);
|
||||||
|
|
||||||
if (adminUser?.brand_id) {
|
if (activeBrandId) {
|
||||||
prodQuery = prodQuery.eq("brand_id", adminUser.brand_id);
|
prodQuery = prodQuery.eq("brand_id", activeBrandId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: prods } = await prodQuery;
|
const { data: prods } = await prodQuery;
|
||||||
@@ -80,7 +87,7 @@ export default async function AdminOrdersPage() {
|
|||||||
initialOrders={brandOrders}
|
initialOrders={brandOrders}
|
||||||
initialStops={brandStops}
|
initialStops={brandStops}
|
||||||
initialProducts={brandProducts}
|
initialProducts={brandProducts}
|
||||||
brandId={adminUser?.brand_id ?? null}
|
brandId={activeBrandId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||||
import DashboardClient from "@/components/admin/DashboardClient";
|
import DashboardClient from "@/components/admin/DashboardClient";
|
||||||
@@ -23,12 +24,11 @@ export default async function AdminPage() {
|
|||||||
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
|
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
|
||||||
let brandDisplayName = "Admin";
|
let brandDisplayName = "Admin";
|
||||||
|
|
||||||
// For platform_admin in dev mode, adminUser.brand_id is null. To keep
|
// Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
|
||||||
// the dashboard's "Active Products" stat in sync with the billing page,
|
// > first of brand_ids). For platform_admin in dev mode this is null, so we
|
||||||
// we need to pick a brand and use the same getBillingOverview action
|
// fall back to the first brand in the brands table to keep the dashboard's
|
||||||
// the billing page does. Otherwise the dashboard falls back to default
|
// "Active Products" stat in sync with the billing page.
|
||||||
// (0/0/0) usage values and contradicts the billing page's "Products 1/25".
|
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
|
||||||
let dashboardBrandId: string | null = adminUser?.brand_id ?? null;
|
|
||||||
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
||||||
const { data: firstBrand } = await supabase
|
const { data: firstBrand } = await supabase
|
||||||
.from("brands")
|
.from("brands")
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import ProductsClient from "@/components/admin/ProductsClient";
|
import ProductsClient from "@/components/admin/ProductsClient";
|
||||||
|
import { getBrands } from "@/actions/admin/users";
|
||||||
|
|
||||||
// Icon for page header
|
// Icon for page header
|
||||||
const PackageIcon = () => (
|
const PackageIcon = () => (
|
||||||
@@ -29,7 +31,16 @@ export default async function AdminProductsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const brandId = adminUser.brand_id;
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
const brandId = activeBrandId;
|
||||||
|
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||||
|
|
||||||
|
// Platform admins need a brand picker for new products
|
||||||
|
let brands: { id: string; name: string }[] = [];
|
||||||
|
if (isPlatformAdmin) {
|
||||||
|
const result = await getBrands();
|
||||||
|
brands = result.brands ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from("products")
|
.from("products")
|
||||||
@@ -48,8 +59,8 @@ export default async function AdminProductsPage() {
|
|||||||
.is("deleted_at", null)
|
.is("deleted_at", null)
|
||||||
.order("name");
|
.order("name");
|
||||||
|
|
||||||
if (adminUser.brand_id) {
|
if (brandId) {
|
||||||
query = query.eq("brand_id", adminUser.brand_id);
|
query = query.eq("brand_id", brandId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: products, error } = await query;
|
const { data: products, error } = await query;
|
||||||
@@ -69,7 +80,12 @@ export default async function AdminProductsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||||
<ProductsClient products={products ?? []} brandId={brandId ?? undefined} />
|
<ProductsClient
|
||||||
|
products={products ?? []}
|
||||||
|
brandId={brandId ?? undefined}
|
||||||
|
brands={brands}
|
||||||
|
isPlatformAdmin={isPlatformAdmin}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import ReportsDashboard from "@/components/admin/ReportsDashboard";
|
import ReportsDashboard from "@/components/admin/ReportsDashboard";
|
||||||
import { PageHeader } from "@/components/admin/design-system";
|
import { PageHeader } from "@/components/admin/design-system";
|
||||||
@@ -19,7 +20,7 @@ export default async function ReportsPage() {
|
|||||||
|
|
||||||
const initialBrandId = isPlatformAdmin
|
const initialBrandId = isPlatformAdmin
|
||||||
? null
|
? null
|
||||||
: adminUser.brand_id ?? null;
|
: await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import BillingClientPage from "./BillingClientPage";
|
import BillingClientPage from "./BillingClientPage";
|
||||||
@@ -13,7 +14,11 @@ export default async function BillingPage({ params }: Props) {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
if (!adminUser) return <AdminAccessDenied />;
|
||||||
|
|
||||||
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
|
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return <AdminAccessDenied message="You don't have access to that brand." />;
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? "";
|
||||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||||
|
|
||||||
let resolvedBrandId = effectiveBrandId;
|
let resolvedBrandId = effectiveBrandId;
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { getPaymentSettings } from "@/actions/payments";
|
|||||||
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||||
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
|
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
|
||||||
|
|
||||||
|
// Uses cookies() via getAdminUser — must be dynamic to avoid the
|
||||||
|
// "couldn't be rendered statically" build error.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function SquareSyncSettingsPage() {
|
export default async function SquareSyncSettingsPage() {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) redirect("/login");
|
if (!adminUser) redirect("/login");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import StopTableClient from "@/components/admin/StopTableClient";
|
import StopsDashboardClient from "@/components/admin/stops/StopsDashboardClient";
|
||||||
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
@@ -90,9 +90,7 @@ export default async function AdminStopsPage() {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||||
<div className="overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
|
<StopsDashboardClient stops={stops ?? []} />
|
||||||
<StopTableClient stops={stops ?? []} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import TaxDashboard from "@/components/admin/TaxDashboard";
|
import TaxDashboard from "@/components/admin/TaxDashboard";
|
||||||
@@ -15,7 +16,11 @@ export default async function TaxesPage({ params }: Props) {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
if (!adminUser) return <AdminAccessDenied />;
|
||||||
|
|
||||||
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
|
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return <AdminAccessDenied message="You don't have access to that brand." />;
|
||||||
|
}
|
||||||
|
const effectiveBrandId = activeBrandId ?? "";
|
||||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||||
|
|
||||||
let resolvedBrandId = effectiveBrandId;
|
let resolvedBrandId = effectiveBrandId;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import WholesaleClient from "./WholesaleClient";
|
import WholesaleClient from "./WholesaleClient";
|
||||||
|
|
||||||
export default async function WholesalePage() {
|
export default async function WholesalePage() {
|
||||||
@@ -11,10 +13,10 @@ export default async function WholesalePage() {
|
|||||||
devSession === "store_employee";
|
devSession === "store_employee";
|
||||||
|
|
||||||
if (!isDevMode) {
|
if (!isDevMode) {
|
||||||
const { getAdminUser } = await import("@/lib/admin-permissions");
|
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) redirect("/login");
|
if (!adminUser) redirect("/login");
|
||||||
return <WholesaleClient brandId={adminUser.brand_id ?? ""} />;
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
return <WholesaleClient brandId={activeBrandId ?? ""} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev mode: platform_admin sees all brands, use first brand as default
|
// Dev mode: platform_admin sees all brands, use first brand as default
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth.js v5 catch-all route handler. Exposes:
|
||||||
|
* GET /api/auth/signin
|
||||||
|
* GET /api/auth/signout
|
||||||
|
* GET /api/auth/session
|
||||||
|
* GET /api/auth/csrf
|
||||||
|
* GET /api/auth/providers
|
||||||
|
* POST /api/auth/callback/:provider
|
||||||
|
* POST /api/auth/signin/:provider
|
||||||
|
* POST /api/auth/signout
|
||||||
|
*
|
||||||
|
* The actual OAuth + session logic is in `src/lib/auth.ts`.
|
||||||
|
*/
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
+229
-161
@@ -4,25 +4,36 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCart } from "@/context/CartContext";
|
import { useCart } from "@/context/CartContext";
|
||||||
|
import type { StopInfo } from "@/context/CartContext";
|
||||||
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
|
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
|
||||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||||
|
import StripeExpressCheckout, {
|
||||||
type Stop = {
|
type CheckoutItem as ExpressItem,
|
||||||
id: string;
|
} from "@/components/storefront/StripeExpressCheckout";
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
date: string;
|
|
||||||
location: string;
|
|
||||||
brand_id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CheckoutClient() {
|
export default function CheckoutClient() {
|
||||||
const { cart, subtotal, selectedStop, setSelectedStop, clearCart, cartBrandId } = useCart();
|
const cartContext = useCart();
|
||||||
|
const cart = cartContext.cart;
|
||||||
|
const subtotal = cartContext.subtotal;
|
||||||
|
const selectedStop: StopInfo | null = cartContext.selectedStop;
|
||||||
|
const setSelectedStop = cartContext.setSelectedStop;
|
||||||
|
const cartBrandId = cartContext.cartBrandId;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [stops, setStops] = useState<Stop[]>([]);
|
const [stops, setStops] = useState<StopInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
// Controlled form state — passed down to StripeExpressCheckout so the
|
||||||
|
// Express + Card paths know who the buyer is and where to ship / pick up.
|
||||||
|
const [customerName, setCustomerName] = useState("");
|
||||||
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
|
const [shippingState, setShippingState] = useState("");
|
||||||
|
const [shippingPostal, setShippingPostal] = useState("");
|
||||||
|
const [shippingCity, setShippingCity] = useState("");
|
||||||
|
|
||||||
|
// Hosted-checkout (legacy Stripe Checkout) fallback state
|
||||||
|
const [hostedLoading, setHostedLoading] = useState(false);
|
||||||
|
const [hostedError, setHostedError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Stable idempotency key per checkout session — survives retries
|
// Stable idempotency key per checkout session — survives retries
|
||||||
const idempotencyKeyRef = useRef<string | null>(null);
|
const idempotencyKeyRef = useRef<string | null>(null);
|
||||||
@@ -33,7 +44,7 @@ export default function CheckoutClient() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cartBrandId) return;
|
if (!cartBrandId) return;
|
||||||
fetch(
|
fetch(
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,location,brand_id&order=date`,
|
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
@@ -47,45 +58,37 @@ export default function CheckoutClient() {
|
|||||||
|
|
||||||
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
|
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
|
||||||
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
|
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
|
||||||
const hasStopPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type !== "shed");
|
const hasStopPickupItems = cart.some(
|
||||||
|
(i) => i.fulfillment === "pickup" && i.pickup_type !== "shed"
|
||||||
|
);
|
||||||
const hasShipItems = cart.some((i) => i.fulfillment === "ship");
|
const hasShipItems = cart.some((i) => i.fulfillment === "ship");
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
/**
|
||||||
e.preventDefault();
|
* Legacy "Use secure hosted checkout" fallback. Creates a Stripe
|
||||||
|
* Checkout Session and redirects — this is the path used when the
|
||||||
|
* embedded Stripe Elements can't load (no publishable key, browser
|
||||||
|
* incompatibility, etc.) or when the buyer prefers Stripe's hosted page.
|
||||||
|
*/
|
||||||
|
const handleHostedCheckout = useCallback(async () => {
|
||||||
if (cart.length === 0) return;
|
if (cart.length === 0) return;
|
||||||
|
|
||||||
// Guard: if selected stop brand mismatches cart brand, block checkout
|
|
||||||
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
|
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
|
||||||
setSelectedStop(null);
|
setSelectedStop(null);
|
||||||
router.replace("/cart");
|
router.replace("/cart");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
if (!customerEmail.trim()) {
|
||||||
setError(null);
|
setHostedError("Please enter your email before continuing to checkout.");
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
const customerName = formData.get("customer_name") as string;
|
|
||||||
const customerEmail = formData.get("customer_email") as string;
|
|
||||||
const customerPhone = formData.get("customer_phone") as string;
|
|
||||||
const stopId = (selectedStop?.id ?? formData.get("stop_id") as string | null) as string | null;
|
|
||||||
|
|
||||||
// Shipping address for tax calculation (ship items only)
|
|
||||||
let shippingAddress;
|
|
||||||
if (hasShipItems) {
|
|
||||||
const shippingPostal = formData.get("shipping_postal_code") as string;
|
|
||||||
const shippingState = formData.get("shipping_state") as string;
|
|
||||||
const shippingCity = formData.get("shipping_city") as string;
|
|
||||||
if (shippingState) {
|
|
||||||
shippingAddress = { state: shippingState, postal_code: shippingPostal, city: shippingCity };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasStopPickupItems && !stopId) {
|
|
||||||
setError("Please select a pickup location.");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hasStopPickupItems && !selectedStop) {
|
||||||
|
setHostedError("Please select a pickup location.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHostedLoading(true);
|
||||||
|
setHostedError(null);
|
||||||
|
|
||||||
const items = cart.map((item) => ({
|
const items = cart.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -95,36 +98,61 @@ export default function CheckoutClient() {
|
|||||||
fulfillment: item.fulfillment ?? "pickup",
|
fulfillment: item.fulfillment ?? "pickup",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Build return URLs for Stripe
|
const siteUrl =
|
||||||
const siteUrl = typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
|
typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
|
||||||
const successUrl = `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
|
const successUrl = `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
|
||||||
const cancelUrl = `${siteUrl}/checkout?status=cancelled`;
|
const cancelUrl = `${siteUrl}/checkout?status=cancelled`;
|
||||||
|
|
||||||
// Store customer info for order creation on success page
|
|
||||||
if (typeof sessionStorage !== "undefined") {
|
if (typeof sessionStorage !== "undefined") {
|
||||||
sessionStorage.setItem("pending_checkout", JSON.stringify({
|
sessionStorage.setItem(
|
||||||
customerName,
|
"pending_checkout",
|
||||||
customerEmail,
|
JSON.stringify({
|
||||||
customerPhone,
|
customerName,
|
||||||
stopId,
|
customerEmail,
|
||||||
items: items.map((item) => ({ ...item, fulfillment: item.fulfillment as "pickup" | "ship" })),
|
customerPhone,
|
||||||
cartBrandId,
|
stopId: selectedStop?.id ?? null,
|
||||||
shippingAddress,
|
items: items.map((item) => ({
|
||||||
idempotencyKey: idempotencyKeyRef.current,
|
...item,
|
||||||
}));
|
fulfillment: item.fulfillment as "pickup" | "ship",
|
||||||
|
})),
|
||||||
|
cartBrandId,
|
||||||
|
shippingAddress: hasShipItems
|
||||||
|
? { state: shippingState, postal_code: shippingPostal, city: shippingCity }
|
||||||
|
: undefined,
|
||||||
|
idempotencyKey: idempotencyKeyRef.current,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Stripe Checkout session first
|
const stripeResult = await createRetailStripeCheckoutSession(
|
||||||
const stripeResult = await createRetailStripeCheckoutSession(items, "", cartBrandId ?? "unknown", successUrl, cancelUrl);
|
items,
|
||||||
|
"",
|
||||||
|
cartBrandId ?? "unknown",
|
||||||
|
successUrl,
|
||||||
|
cancelUrl
|
||||||
|
);
|
||||||
if (!stripeResult.success || !stripeResult.url) {
|
if (!stripeResult.success || !stripeResult.url) {
|
||||||
setError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
|
setHostedError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
|
||||||
setLoading(false);
|
setHostedLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to Stripe Checkout
|
|
||||||
window.location.href = stripeResult.url;
|
window.location.href = stripeResult.url;
|
||||||
}, [cart, selectedStop, cartBrandId, hasShipItems, hasStopPickupItems, router, setSelectedStop]);
|
}, [
|
||||||
|
cart,
|
||||||
|
customerName,
|
||||||
|
customerEmail,
|
||||||
|
customerPhone,
|
||||||
|
selectedStop,
|
||||||
|
shippingState,
|
||||||
|
shippingPostal,
|
||||||
|
shippingCity,
|
||||||
|
cartBrandId,
|
||||||
|
hasShipItems,
|
||||||
|
hasStopPickupItems,
|
||||||
|
router,
|
||||||
|
setSelectedStop,
|
||||||
|
]);
|
||||||
|
|
||||||
if (cart.length === 0) {
|
if (cart.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -132,9 +160,7 @@ export default function CheckoutClient() {
|
|||||||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||||||
<main className="px-6 py-12">
|
<main className="px-6 py-12">
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
<h1 className="text-3xl font-bold text-stone-900">
|
<h1 className="text-3xl font-bold text-stone-900">Your cart is empty</h1>
|
||||||
Your cart is empty
|
|
||||||
</h1>
|
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
|
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
|
||||||
@@ -147,71 +173,81 @@ export default function CheckoutClient() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map cart → StripeExpressCheckout item shape
|
||||||
|
const expressItems: ExpressItem[] = cart.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
price: item.price,
|
||||||
|
quantity: item.quantity,
|
||||||
|
fulfillment: (item.fulfillment ?? "pickup") as "pickup" | "ship",
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
|
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
|
||||||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||||||
<main className="px-6 py-12">
|
<main className="px-6 py-12">
|
||||||
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
|
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
|
||||||
|
{/* LEFT — Form + Embedded Stripe Elements */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-bold text-slate-900">
|
<h1 className="text-4xl font-bold text-slate-900">Checkout</h1>
|
||||||
Checkout
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-3 text-slate-600">
|
<p className="mt-3 text-slate-600">
|
||||||
Complete your order details.
|
Tap to pay with Apple Pay, Google Pay, or card. Your details stay secure — we never see them.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Error alert */}
|
{/* Error alert (shared across both payment paths) */}
|
||||||
{error && (
|
{(hostedError) && (
|
||||||
<div className="mt-6 rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm shadow-sm" role="alert">
|
<div className="mt-6 rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm shadow-sm" role="alert">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<svg className="h-5 w-5 text-red-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<svg className="h-5 w-5 text-red-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{error}</span>
|
<span>{hostedError}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading indicator */}
|
<div className="mt-8 space-y-6">
|
||||||
{loading && (
|
{/* Customer Information — controlled inputs feed the embedded
|
||||||
<div className="mt-6 flex items-center gap-3 rounded-xl bg-emerald-50 border border-emerald-200 p-4" role="status" aria-live="polite">
|
Stripe Elements above. The wallet (Apple Pay) will overwrite
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-300 border-t-emerald-600" aria-hidden="true" />
|
name/email at confirm time, but we still collect them here
|
||||||
<span className="text-sm text-emerald-700">Placing your order...</span>
|
so the order row has them in case the wallet omits them. */}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
|
||||||
{/* Customer Information */}
|
|
||||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
||||||
<legend className="text-xl font-bold text-slate-900">Customer Information</legend>
|
<legend className="text-xl font-bold text-slate-900">Your details</legend>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Used for the order confirmation. Apple Pay / Google Pay will fill these in for you.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div className="sm:col-span-2">
|
||||||
<label htmlFor="customer_name" className="block text-sm font-medium text-slate-700">
|
<label htmlFor="customer_name" className="block text-sm font-medium text-slate-700">
|
||||||
Full Name <span className="text-red-500" aria-hidden="true">*</span>
|
Full Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="customer_name"
|
id="customer_name"
|
||||||
name="customer_name"
|
name="customer_name"
|
||||||
required
|
autoComplete="name"
|
||||||
|
value={customerName}
|
||||||
|
onChange={(e) => setCustomerName(e.target.value)}
|
||||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||||
placeholder="Jane Smith"
|
placeholder="Jane Smith"
|
||||||
aria-required="true"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
|
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
|
||||||
Email <span className="text-slate-400 text-xs">(optional)</span>
|
Email <span className="text-red-500" aria-hidden="true">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="customer_email"
|
id="customer_email"
|
||||||
name="customer_email"
|
name="customer_email"
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={customerEmail}
|
||||||
|
onChange={(e) => setCustomerEmail(e.target.value)}
|
||||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||||
placeholder="jane@example.com"
|
placeholder="jane@example.com"
|
||||||
|
aria-required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -224,6 +260,8 @@ export default function CheckoutClient() {
|
|||||||
name="customer_phone"
|
name="customer_phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
|
value={customerPhone}
|
||||||
|
onChange={(e) => setCustomerPhone(e.target.value)}
|
||||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||||
placeholder="(555) 555-5555"
|
placeholder="(555) 555-5555"
|
||||||
/>
|
/>
|
||||||
@@ -231,7 +269,7 @@ export default function CheckoutClient() {
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{/* Shed Pickup */}
|
{/* Shed Pickup notice */}
|
||||||
{hasShedPickupItems && (
|
{hasShedPickupItems && (
|
||||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
|
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
|
||||||
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
|
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
|
||||||
@@ -278,6 +316,11 @@ export default function CheckoutClient() {
|
|||||||
id="stop_id"
|
id="stop_id"
|
||||||
name="stop_id"
|
name="stop_id"
|
||||||
required
|
required
|
||||||
|
value={(selectedStop as StopInfo | null)?.id ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const found = stops.find((s) => s.id === e.target.value) ?? null;
|
||||||
|
setSelectedStop(found as StopInfo | null);
|
||||||
|
}}
|
||||||
className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all appearance-none bg-white"
|
className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all appearance-none bg-white"
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
>
|
>
|
||||||
@@ -301,8 +344,8 @@ export default function CheckoutClient() {
|
|||||||
Enter your shipping details for delivery.
|
Enter your shipping details for delivery.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div>
|
<div className="sm:col-span-3">
|
||||||
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
|
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
|
||||||
Street Address
|
Street Address
|
||||||
</label>
|
</label>
|
||||||
@@ -314,80 +357,97 @@ export default function CheckoutClient() {
|
|||||||
placeholder="123 Main St"
|
placeholder="123 Main St"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div>
|
||||||
<div>
|
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
|
||||||
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
|
City
|
||||||
City
|
</label>
|
||||||
</label>
|
<input
|
||||||
<input
|
id="shipping_city"
|
||||||
id="shipping_city"
|
name="shipping_city"
|
||||||
name="shipping_city"
|
autoComplete="address-level2"
|
||||||
autoComplete="address-level2"
|
value={shippingCity}
|
||||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
onChange={(e) => setShippingCity(e.target.value)}
|
||||||
/>
|
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||||
</div>
|
/>
|
||||||
<div>
|
</div>
|
||||||
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
|
<div>
|
||||||
State
|
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
|
||||||
</label>
|
State
|
||||||
<input
|
</label>
|
||||||
id="shipping_state"
|
<input
|
||||||
name="shipping_state"
|
id="shipping_state"
|
||||||
autoComplete="address-level1"
|
name="shipping_state"
|
||||||
maxLength={2}
|
autoComplete="address-level1"
|
||||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase"
|
maxLength={2}
|
||||||
placeholder="NC"
|
value={shippingState}
|
||||||
/>
|
onChange={(e) => setShippingState(e.target.value.toUpperCase())}
|
||||||
</div>
|
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase"
|
||||||
<div>
|
placeholder="NC"
|
||||||
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
|
/>
|
||||||
ZIP Code
|
</div>
|
||||||
</label>
|
<div>
|
||||||
<input
|
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
|
||||||
id="shipping_postal_code"
|
ZIP Code
|
||||||
name="shipping_postal_code"
|
</label>
|
||||||
autoComplete="postal-code"
|
<input
|
||||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
id="shipping_postal_code"
|
||||||
placeholder="28147"
|
name="shipping_postal_code"
|
||||||
/>
|
autoComplete="postal-code"
|
||||||
</div>
|
value={shippingPostal}
|
||||||
|
onChange={(e) => setShippingPostal(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||||
|
placeholder="28147"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Submit button */}
|
{/* Embedded Stripe Elements (Express + Card) — real checkout */}
|
||||||
<button
|
<div data-testid="stripe-express-checkout">
|
||||||
type="submit"
|
<StripeExpressCheckout
|
||||||
disabled={loading}
|
items={expressItems}
|
||||||
className="w-full rounded-xl bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 px-6 py-4 text-lg font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/30 hover:-translate-y-0.5 flex items-center justify-center gap-2"
|
brandId={cartBrandId}
|
||||||
>
|
customerName={customerName}
|
||||||
{loading ? (
|
customerEmail={customerEmail}
|
||||||
<>
|
customerPhone={customerPhone}
|
||||||
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
selectedStop={selectedStop as StopInfo | null}
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
shippingState={shippingState}
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
shippingPostal={shippingPostal}
|
||||||
</svg>
|
shippingCity={shippingCity}
|
||||||
Redirecting to payment...
|
checkoutSessionKey={idempotencyKeyRef.current ?? undefined}
|
||||||
</>
|
onHostedCheckout={() => void handleHostedCheckout()}
|
||||||
) : (
|
/>
|
||||||
<>
|
</div>
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
{/* Hosted-checkout fallback (legacy Stripe Checkout) */}
|
||||||
</svg>
|
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-4">
|
||||||
Pay Now — ${subtotal.toFixed(2)}
|
<p className="text-xs text-slate-500 mb-2 text-center">
|
||||||
</>
|
Prefer Stripe’s hosted page? Tap below.
|
||||||
)}
|
</p>
|
||||||
</button>
|
<button
|
||||||
</form>
|
type="button"
|
||||||
|
onClick={() => void handleHostedCheckout()}
|
||||||
|
disabled={hostedLoading}
|
||||||
|
className="w-full rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-800 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{hostedLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="h-4 w-4 rounded-full border-2 border-slate-300 border-t-slate-700 animate-spin" />
|
||||||
|
Redirecting to Stripe…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Use secure hosted checkout →</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Order Summary */}
|
{/* Order Summary */}
|
||||||
<aside aria-label="Order summary">
|
<aside aria-label="Order summary">
|
||||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100 sticky top-6">
|
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100 sticky top-6">
|
||||||
<h2 className="text-xl font-bold text-slate-900">
|
<h2 className="text-xl font-bold text-slate-900">Order Summary</h2>
|
||||||
Order Summary
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{cart.map((item) => (
|
{cart.map((item) => (
|
||||||
@@ -420,12 +480,20 @@ export default function CheckoutClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secure payment badge */}
|
{/* Trust badges */}
|
||||||
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-slate-500">
|
<div className="mt-4 space-y-1.5">
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
<span>Secured by Stripe</span>
|
</svg>
|
||||||
|
<span>Secured by Stripe</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
<span>100% Sweetness Guarantee</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -47,12 +47,16 @@ function formatStopDate(dateStr: string): string {
|
|||||||
function SuccessContent() {
|
function SuccessContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const sessionId = searchParams.get("session_id");
|
const sessionId = searchParams.get("session_id");
|
||||||
|
// Embedded Stripe Elements path — we navigated here programmatically
|
||||||
|
// with ?payment_intent=pi_... after `stripe.confirmPayment` resolved.
|
||||||
|
const paymentIntentId = searchParams.get("payment_intent");
|
||||||
|
const redirectStatus = searchParams.get("redirect_status");
|
||||||
const orderIdParam = searchParams.get("order_id");
|
const orderIdParam = searchParams.get("order_id");
|
||||||
// Direct access with order_id — load from sessionStorage in lazy initializer.
|
// Direct access with order_id — load from sessionStorage in lazy initializer.
|
||||||
// searchParams values are stable, and sessionStorage is client-only, so this
|
// searchParams values are stable, and sessionStorage is client-only, so this
|
||||||
// is safe in a client component.
|
// is safe in a client component.
|
||||||
const [order, setOrder] = useState<StoredOrder | null>(() => {
|
const [order, setOrder] = useState<StoredOrder | null>(() => {
|
||||||
if (!orderIdParam || sessionId) return null;
|
if (!orderIdParam || sessionId || paymentIntentId) return null;
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
try {
|
try {
|
||||||
const stored = sessionStorage.getItem(`order_${orderIdParam}`);
|
const stored = sessionStorage.getItem(`order_${orderIdParam}`);
|
||||||
@@ -62,14 +66,20 @@ function SuccessContent() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [creating, setCreating] = useState(!!sessionId);
|
const [creating, setCreating] = useState(Boolean(sessionId || (paymentIntentId && redirectStatus === "succeeded")));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Stripe redirected back — create order from pending checkout data.
|
// Create the order from the pending-checkout payload, regardless of
|
||||||
// Wrapped in an async IIFE so all setState calls happen inside a callback,
|
// whether the user paid via hosted Stripe Checkout (?session_id=...) or
|
||||||
// not in the synchronous effect body (satisfies set-state-in-effect rule).
|
// embedded Stripe Elements (?payment_intent=...). Both paths store the
|
||||||
|
// same payload in `pending_checkout` before payment is initiated.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return;
|
if (!sessionId && !paymentIntentId) return;
|
||||||
|
if (paymentIntentId && redirectStatus && redirectStatus !== "succeeded") {
|
||||||
|
setError("Payment was not completed. Please try again.");
|
||||||
|
setCreating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const pendingStr = sessionStorage.getItem("pending_checkout");
|
const pendingStr = sessionStorage.getItem("pending_checkout");
|
||||||
@@ -88,6 +98,7 @@ function SuccessContent() {
|
|||||||
cartBrandId?: string;
|
cartBrandId?: string;
|
||||||
shippingAddress?: { state: string; postal_code: string; city?: string };
|
shippingAddress?: { state: string; postal_code: string; city?: string };
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
|
paymentIntentId?: string;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
pending = JSON.parse(pendingStr);
|
pending = JSON.parse(pendingStr);
|
||||||
@@ -123,7 +134,7 @@ function SuccessContent() {
|
|||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [sessionId]);
|
}, [sessionId, paymentIntentId, redirectStatus]);
|
||||||
|
|
||||||
if (!order || !orderIdParam) {
|
if (!order || !orderIdParam) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+589
-1
@@ -1,6 +1,11 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
/* Custom typefaces — Field Almanac */
|
||||||
|
--font-display: var(--font-fraunces, "Georgia"), Georgia, serif;
|
||||||
|
--font-sans: var(--font-manrope, system-ui), system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--font-mono: var(--font-fragment-mono, "JetBrains Mono"), ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
|
||||||
/* Apple-inspired dark palette — sophisticated depth */
|
/* Apple-inspired dark palette — sophisticated depth */
|
||||||
--color-surface-50: #f5f5f7;
|
--color-surface-50: #f5f5f7;
|
||||||
--color-surface-100: #e8e8ed;
|
--color-surface-100: #e8e8ed;
|
||||||
@@ -85,7 +90,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
font-family: var(--font-sans);
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
@@ -442,3 +447,586 @@ select:-webkit-autofill:focus {
|
|||||||
.transition-glass {
|
.transition-glass {
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Editorial Modal — "Atelier des Récoltes" ───────────────── */
|
||||||
|
|
||||||
|
/* Warm cream canvas for the modal — like aged paper */
|
||||||
|
.atelier-canvas {
|
||||||
|
background-color: #FAF7F0;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 70% 50% at 15% 0%, rgba(180, 155, 100, 0.10) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 60% 45% at 100% 100%, rgba(34, 78, 47, 0.06) 0%, transparent 55%);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle grain texture for tactile feel */
|
||||||
|
.atelier-grain {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.35;
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.18 0 0 0 0 0.15 0 0 0 0 0.10 0 0 0 0.045 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botanical wreath / corner ornament */
|
||||||
|
.atelier-flourish {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='80' height='80' viewBox='0 0 80 80' xmlns='http://www.w3.org/2000/svg' fill='none' stroke='%23224E2F' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 40 Q 25 20 40 30 Q 55 40 70 30'/%3E%3Cpath d='M22 32 Q 25 28 28 30'/%3E%3Cpath d='M52 38 Q 55 34 58 36'/%3E%3Cpath d='M14 45 Q 18 50 24 48'/%3E%3Ccircle cx='40' cy='30' r='2' fill='%23224E2F'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The big editorial numeral — large, italic, gold */
|
||||||
|
.atelier-numeral {
|
||||||
|
font-family: var(--font-fraunces);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variation-settings: "opsz" 144, "SOFT" 30;
|
||||||
|
line-height: 0.85;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
background: linear-gradient(135deg, #CA8A04 0%, #854D0E 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editorial title — Fraunces with optical sizing */
|
||||||
|
.atelier-title {
|
||||||
|
font-family: var(--font-fraunces);
|
||||||
|
font-weight: 500;
|
||||||
|
font-variation-settings: "opsz" 144, "SOFT" 50;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
line-height: 0.95;
|
||||||
|
color: #1C1917;
|
||||||
|
}
|
||||||
|
|
||||||
|
.atelier-italic {
|
||||||
|
font-family: var(--font-fraunces);
|
||||||
|
font-style: italic;
|
||||||
|
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||||
|
color: #786B53;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section label — monospace, small caps style */
|
||||||
|
.atelier-section-label {
|
||||||
|
font-family: var(--font-fragment-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #786B53;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section number badge */
|
||||||
|
.atelier-section-num {
|
||||||
|
font-family: var(--font-fragment-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #CA8A04;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.atelier-section-num::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 18px;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, #CA8A04, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editorial hairline rule with fade at edges */
|
||||||
|
.atelier-rule {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, transparent, rgba(180, 155, 100, 0.4) 20%, rgba(34, 78, 47, 0.3) 50%, rgba(180, 155, 100, 0.4) 80%, transparent);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input fields — bottom border only, editorial */
|
||||||
|
.atelier-input {
|
||||||
|
font-family: var(--font-manrope);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #1C1917;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1.5px solid rgba(28, 25, 23, 0.12);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 10px 0 10px 0;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 220ms cubic-bezier(0.4, 0, 0.2, 1), padding 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.atelier-input::placeholder {
|
||||||
|
color: #A8A29E;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--font-fraunces);
|
||||||
|
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||||
|
}
|
||||||
|
.atelier-input:hover {
|
||||||
|
border-bottom-color: rgba(28, 25, 23, 0.28);
|
||||||
|
}
|
||||||
|
.atelier-input:focus {
|
||||||
|
border-bottom-color: #224E2F;
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
padding-bottom: 9.5px;
|
||||||
|
}
|
||||||
|
.atelier-input.is-error {
|
||||||
|
border-bottom-color: #B91C1C;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Large display input — name field */
|
||||||
|
.atelier-input--display {
|
||||||
|
font-family: var(--font-fraunces);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variation-settings: "opsz" 144, "SOFT" 50;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
padding: 8px 0 14px 0;
|
||||||
|
}
|
||||||
|
.atelier-input--display::placeholder {
|
||||||
|
color: #D6D3D1;
|
||||||
|
font-style: italic;
|
||||||
|
font-variation-settings: "opsz" 144, "SOFT" 100;
|
||||||
|
}
|
||||||
|
.atelier-input--display:focus {
|
||||||
|
border-bottom-width: 2.5px;
|
||||||
|
padding-bottom: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Price input — large currency treatment */
|
||||||
|
.atelier-price {
|
||||||
|
font-family: var(--font-fraunces);
|
||||||
|
font-variation-settings: "opsz" 144, "SOFT" 50;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 38px;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #1C1917;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid #224E2F;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 4px 0 12px 36px;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 200ms ease;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
.atelier-price::-webkit-outer-spin-button,
|
||||||
|
.atelier-price::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.atelier-price:focus {
|
||||||
|
border-bottom-color: #14532D;
|
||||||
|
border-bottom-width: 2.5px;
|
||||||
|
}
|
||||||
|
.atelier-price-sigil {
|
||||||
|
font-family: var(--font-fraunces);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 38px;
|
||||||
|
color: #CA8A04;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type selector — visual cards */
|
||||||
|
.atelier-type-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border: 1.5px solid rgba(28, 25, 23, 0.10);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 92px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.atelier-type-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(34, 78, 47, 0.02) 0%, transparent 60%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 220ms ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.atelier-type-card:hover {
|
||||||
|
border-color: rgba(34, 78, 47, 0.25);
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.atelier-type-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.atelier-type-card:active {
|
||||||
|
transform: translateY(0) scale(0.985);
|
||||||
|
}
|
||||||
|
.atelier-type-card.is-selected {
|
||||||
|
background: linear-gradient(135deg, #224E2F 0%, #14532D 100%);
|
||||||
|
border-color: #14532D;
|
||||||
|
box-shadow: 0 8px 24px -8px rgba(20, 83, 45, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.10);
|
||||||
|
}
|
||||||
|
.atelier-type-card.is-selected::before {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.atelier-type-card .atelier-type-icon {
|
||||||
|
color: #786B53;
|
||||||
|
transition: color 220ms ease, transform 220ms ease;
|
||||||
|
}
|
||||||
|
.atelier-type-card.is-selected .atelier-type-icon {
|
||||||
|
color: #FCD34D;
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
.atelier-type-card .atelier-type-name {
|
||||||
|
font-family: var(--font-fraunces);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variation-settings: "opsz" 14, "SOFT" 30;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: #1C1917;
|
||||||
|
transition: color 220ms ease;
|
||||||
|
}
|
||||||
|
.atelier-type-card.is-selected .atelier-type-name {
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
.atelier-type-card .atelier-type-check {
|
||||||
|
opacity: 0;
|
||||||
|
color: #FCD34D;
|
||||||
|
transform: scale(0.6);
|
||||||
|
transition: opacity 200ms ease, transform 200ms ease;
|
||||||
|
}
|
||||||
|
.atelier-type-card.is-selected .atelier-type-check {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pill toggle — Active / Taxable */
|
||||||
|
.atelier-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.atelier-toggle-track {
|
||||||
|
position: relative;
|
||||||
|
width: 38px;
|
||||||
|
height: 22px;
|
||||||
|
background: rgba(28, 25, 23, 0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: background 220ms ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.atelier-toggle.is-on .atelier-toggle-track {
|
||||||
|
background: linear-gradient(135deg, #224E2F 0%, #16A34A 100%);
|
||||||
|
box-shadow: 0 2px 8px -2px rgba(34, 78, 47, 0.4);
|
||||||
|
}
|
||||||
|
.atelier-toggle.is-on.is-gold .atelier-toggle-track {
|
||||||
|
background: linear-gradient(135deg, #CA8A04 0%, #EAB308 100%);
|
||||||
|
box-shadow: 0 2px 8px -2px rgba(202, 138, 4, 0.4);
|
||||||
|
}
|
||||||
|
.atelier-toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
.atelier-toggle.is-on .atelier-toggle-thumb {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
.atelier-toggle-label {
|
||||||
|
font-family: var(--font-fragment-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #786B53;
|
||||||
|
transition: color 220ms ease;
|
||||||
|
}
|
||||||
|
.atelier-toggle.is-on .atelier-toggle-label {
|
||||||
|
color: #1C1917;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image drop zone — large square with botanical treatment */
|
||||||
|
.atelier-drop {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border: 1.5px dashed rgba(34, 78, 47, 0.25);
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.atelier-drop::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 12px;
|
||||||
|
border: 1px solid rgba(180, 155, 100, 0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.atelier-drop::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(circle at center, rgba(34, 78, 47, 0.02) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.atelier-drop:hover {
|
||||||
|
border-color: rgba(34, 78, 47, 0.45);
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
transform: scale(1.005);
|
||||||
|
}
|
||||||
|
.atelier-drop.is-drag {
|
||||||
|
border-color: #224E2F;
|
||||||
|
border-style: solid;
|
||||||
|
background: rgba(34, 78, 47, 0.04);
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
.atelier-drop.is-error {
|
||||||
|
border-color: #B91C1C;
|
||||||
|
background: rgba(185, 28, 28, 0.03);
|
||||||
|
}
|
||||||
|
.atelier-drop.has-image {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: rgba(34, 78, 47, 0.3);
|
||||||
|
background: #FFFFFF;
|
||||||
|
}
|
||||||
|
.atelier-drop-eyebrow {
|
||||||
|
font-family: var(--font-fragment-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #A8A29E;
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
.atelier-drop-title {
|
||||||
|
font-family: var(--font-fraunces);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 18px;
|
||||||
|
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||||
|
color: #57534E;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.atelier-drop-hint {
|
||||||
|
font-family: var(--font-manrope);
|
||||||
|
font-size: 11px;
|
||||||
|
color: #A8A29E;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status pill in corner of image */
|
||||||
|
.atelier-status-pill {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px 5px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 1px solid rgba(28, 25, 23, 0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: var(--font-fragment-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #1C1917;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.atelier-status-pill .atelier-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #16A34A;
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
||||||
|
}
|
||||||
|
.atelier-status-pill.is-empty .atelier-dot {
|
||||||
|
background: #D6D3D1;
|
||||||
|
box-shadow: 0 0 0 2px rgba(214, 211, 209, 0.2);
|
||||||
|
}
|
||||||
|
.atelier-status-pill.is-empty {
|
||||||
|
color: #A8A29E;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal enter animation */
|
||||||
|
@keyframes atelier-enter {
|
||||||
|
from { opacity: 0; transform: translateY(20px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes atelier-backdrop {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes atelier-stagger {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.atelier-enter {
|
||||||
|
animation: atelier-enter 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
.atelier-backdrop {
|
||||||
|
animation: atelier-backdrop 300ms ease both;
|
||||||
|
}
|
||||||
|
.atelier-stagger > * {
|
||||||
|
animation: atelier-stagger 480ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
.atelier-stagger > *:nth-child(1) { animation-delay: 80ms; }
|
||||||
|
.atelier-stagger > *:nth-child(2) { animation-delay: 140ms; }
|
||||||
|
.atelier-stagger > *:nth-child(3) { animation-delay: 200ms; }
|
||||||
|
.atelier-stagger > *:nth-child(4) { animation-delay: 260ms; }
|
||||||
|
.atelier-stagger > *:nth-child(5) { animation-delay: 320ms; }
|
||||||
|
.atelier-stagger > *:nth-child(6) { animation-delay: 380ms; }
|
||||||
|
.atelier-stagger > *:nth-child(7) { animation-delay: 440ms; }
|
||||||
|
|
||||||
|
/* Primary CTA — gradient with subtle inner highlight */
|
||||||
|
.atelier-cta {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 22px;
|
||||||
|
font-family: var(--font-manrope);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
color: #FFFFFF;
|
||||||
|
background: linear-gradient(135deg, #14532D 0%, #1F6B3E 50%, #14532D 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
background-position: 0% 50%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow:
|
||||||
|
0 6px 18px -6px rgba(20, 83, 45, 0.55),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||||
|
inset 0 -1px 0 rgba(0, 0, 0, 0.10);
|
||||||
|
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.atelier-cta:hover {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 28px -8px rgba(20, 83, 45, 0.65),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.20),
|
||||||
|
inset 0 -1px 0 rgba(0, 0, 0, 0.10);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.atelier-cta:active {
|
||||||
|
transform: translateY(0) scale(0.985);
|
||||||
|
}
|
||||||
|
.atelier-cta:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
background: #57534E;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.atelier-cta .atelier-cta-shimmer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 60%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(105deg, transparent 30%, rgba(255, 255, 255, 0.18) 50%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: left 700ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.atelier-cta:hover .atelier-cta-shimmer {
|
||||||
|
left: 130%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Discard button */
|
||||||
|
.atelier-discard {
|
||||||
|
font-family: var(--font-fraunces);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 14px;
|
||||||
|
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||||
|
color: #786B53;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 8px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 180ms ease;
|
||||||
|
}
|
||||||
|
.atelier-discard:hover {
|
||||||
|
color: #1C1917;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand selector */
|
||||||
|
.atelier-select {
|
||||||
|
font-family: var(--font-manrope);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1C1917;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1.5px solid rgba(28, 25, 23, 0.12);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 10px 24px 10px 0;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23786B53' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right center;
|
||||||
|
transition: border-color 220ms ease;
|
||||||
|
}
|
||||||
|
.atelier-select:hover { border-bottom-color: rgba(28, 25, 23, 0.28); }
|
||||||
|
.atelier-select:focus { border-bottom-color: #224E2F; border-bottom-width: 2px; padding-bottom: 9.5px; }
|
||||||
|
|
||||||
|
/* Hint / help text */
|
||||||
|
.atelier-hint {
|
||||||
|
font-family: var(--font-fraunces);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||||
|
color: #A8A29E;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error banner */
|
||||||
|
.atelier-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: linear-gradient(135deg, rgba(185, 28, 28, 0.06) 0%, rgba(185, 28, 28, 0.03) 100%);
|
||||||
|
border: 1px solid rgba(185, 28, 28, 0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: var(--font-manrope);
|
||||||
|
font-size: 13px;
|
||||||
|
color: #991B1B;
|
||||||
|
}
|
||||||
|
.atelier-error svg { flex-shrink: 0; margin-top: 1px; }
|
||||||
|
|||||||
+23
-2
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Fraunces, Manrope, Fragment_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Providers } from "@/components/Providers";
|
import { Providers } from "@/components/Providers";
|
||||||
import ToastNotificationContainer from "@/components/notifications/ToastNotification";
|
import ToastNotificationContainer from "@/components/notifications/ToastNotification";
|
||||||
@@ -6,6 +7,26 @@ import CookieConsentBanner from "@/components/legal/CookieConsentBanner";
|
|||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||||
|
|
||||||
|
const fraunces = Fraunces({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-fraunces",
|
||||||
|
axes: ["SOFT", "WONK", "opsz"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const manrope = Manrope({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-manrope",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fragmentMono = Fragment_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-fragment-mono",
|
||||||
|
weight: "400",
|
||||||
|
});
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
@@ -50,8 +71,8 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning className={`${fraunces.variable} ${manrope.variable} ${fragmentMono.variable}`}>
|
||||||
<body>
|
<body className="font-sans">
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
<ToastNotificationContainer />
|
<ToastNotificationContainer />
|
||||||
<CookieConsentBanner />
|
<CookieConsentBanner />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback, Suspense } from "react";
|
import { useState, useEffect, useCallback, Suspense } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin";
|
||||||
|
|
||||||
function LoginForm() {
|
function LoginForm() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -124,6 +125,82 @@ function LoginForm() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
|
||||||
{globalError && (
|
{globalError && (
|
||||||
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
|
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { auth, signOut } from "@/lib/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /protected-example
|
||||||
|
*
|
||||||
|
* Smoke-test page that demonstrates the new Auth.js v5 pattern. Calling
|
||||||
|
* `auth()` server-side returns the current session (null if not signed
|
||||||
|
* in). The middleware in `../middleware.ts` already redirects
|
||||||
|
* unauthenticated visitors to `/login`, so by the time this page renders
|
||||||
|
* we always have a session.
|
||||||
|
*
|
||||||
|
* The page shows:
|
||||||
|
* • The user's name, email, and provider
|
||||||
|
* • The session token (first 8 chars only — never expose the whole thing)
|
||||||
|
* • A "Sign out" form action that calls `signOut()` from `next-auth`
|
||||||
|
*/
|
||||||
|
export default async function ProtectedExamplePage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
// Defensive: middleware should have already redirected. Render a
|
||||||
|
// friendly hint if we ever reach here unauthenticated.
|
||||||
|
if (!session?.user) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6">
|
||||||
|
<div className="max-w-md rounded-2xl bg-white p-8 shadow ring-1 ring-stone-200">
|
||||||
|
<h1 className="text-xl font-semibold text-stone-900">
|
||||||
|
Not signed in
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-stone-600">
|
||||||
|
You should have been redirected to{" "}
|
||||||
|
<a className="text-emerald-700 underline" href="/login">
|
||||||
|
/login
|
||||||
|
</a>
|
||||||
|
. If you can see this, the middleware matcher needs adjusting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = session.user;
|
||||||
|
const expires = session.expires
|
||||||
|
? new Date(session.expires).toLocaleString()
|
||||||
|
: "(no expiry)";
|
||||||
|
|
||||||
|
// The raw session token isn't on the session object in v5 (only the
|
||||||
|
// csrfToken is exposed client-side). We surface what we do have.
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6 py-12">
|
||||||
|
<div className="w-full max-w-xl space-y-6">
|
||||||
|
<header>
|
||||||
|
<h1
|
||||||
|
className="text-3xl font-semibold tracking-tight text-stone-900"
|
||||||
|
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}
|
||||||
|
>
|
||||||
|
Protected example
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-stone-500">
|
||||||
|
You are signed in. This page is guarded by the Auth.js
|
||||||
|
middleware in <code className="text-xs">middleware.ts</code>.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-2xl bg-white p-6 shadow ring-1 ring-stone-200">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-stone-500">
|
||||||
|
Session
|
||||||
|
</h2>
|
||||||
|
<dl className="mt-4 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-stone-500">Name</dt>
|
||||||
|
<dd className="mt-1 font-medium text-stone-900">
|
||||||
|
{user.name ?? "(none)"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-stone-500">Email</dt>
|
||||||
|
<dd className="mt-1 font-medium text-stone-900 break-all">
|
||||||
|
{user.email ?? "(none)"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-stone-500">User id</dt>
|
||||||
|
<dd className="mt-1 font-mono text-xs text-stone-700 break-all">
|
||||||
|
{(user as { id?: string }).id ?? "(none)"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-stone-500">Session expires</dt>
|
||||||
|
<dd className="mt-1 font-medium text-stone-900">{expires}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl bg-white p-6 shadow ring-1 ring-stone-200">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-stone-500">
|
||||||
|
Try it
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-stone-600">
|
||||||
|
Use the form below to sign out, or navigate to{" "}
|
||||||
|
<a className="text-emerald-700 underline" href="/admin">
|
||||||
|
/admin
|
||||||
|
</a>{" "}
|
||||||
|
(the same session is shared).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
"use server";
|
||||||
|
await signOut({ redirectTo: "/login" });
|
||||||
|
}}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-xl bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Fraunces, JetBrains_Mono } from "next/font/google";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||||
|
import SweetCornProductPage from "@/components/storefront/SweetCornProductPage";
|
||||||
|
|
||||||
|
const fraunces = Fraunces({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-display",
|
||||||
|
display: "swap",
|
||||||
|
style: ["normal", "italic"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-mono",
|
||||||
|
display: "swap",
|
||||||
|
weight: ["400", "500", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
// The Tuxedo layout's `template: "%s | Tuxedo Corn"` already appends the
|
||||||
|
// brand — keep the bare title here so the rendered <title> is correct.
|
||||||
|
title: "Fresh Sweet Corn Box – 12 Ears",
|
||||||
|
description:
|
||||||
|
"Our 12-Ear Corn Box is packed with peak-season, super-sweet Olathe Sweet corn picked that morning and rushed to your door. Hand-selected, non-GMO, and 100% sweetness guaranteed.",
|
||||||
|
keywords: [
|
||||||
|
"sweet corn",
|
||||||
|
"Olathe Sweet",
|
||||||
|
"fresh corn box",
|
||||||
|
"12 ears of corn",
|
||||||
|
"farm fresh corn",
|
||||||
|
"Tuxedo Corn",
|
||||||
|
],
|
||||||
|
openGraph: {
|
||||||
|
title: "Fresh Sweet Corn Box – 12 Ears",
|
||||||
|
description:
|
||||||
|
"Peak-season, super-sweet Olathe Sweet corn — picked that morning, rushed to your door. 100% sweetness guaranteed.",
|
||||||
|
type: "website",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "https://images.unsplash.com/photo-1551754655-cd27e38d2076?auto=format&fit=crop&w=1200&q=80",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "Fresh sweet corn box — 12 ears of Olathe Sweet corn",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "Fresh Sweet Corn Box – 12 Ears",
|
||||||
|
description:
|
||||||
|
"Peak-season, super-sweet Olathe Sweet corn. Hand-selected, non-GMO, 100% sweetness guaranteed.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const BRAND_SLUG = "tuxedo";
|
||||||
|
|
||||||
|
type Brand = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Settings = {
|
||||||
|
logo_url?: string | null;
|
||||||
|
logo_url_dark?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SweetCornBoxPage() {
|
||||||
|
// Fetch the brand record so we have a real brand_id to thread through
|
||||||
|
// the cart system. Falls back to a placeholder if Supabase is unreachable
|
||||||
|
// (so the page still renders in dev / disconnected previews).
|
||||||
|
let brandId = "00000000-0000-0000-0000-000000000000";
|
||||||
|
let brandName = "Tuxedo Corn";
|
||||||
|
let logoUrl: string | null = null;
|
||||||
|
let logoUrlDark: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [brandRes, settingsRes] = await Promise.all([
|
||||||
|
supabase.from("brands").select("id, name").eq("slug", BRAND_SLUG).single(),
|
||||||
|
getBrandSettingsPublic(BRAND_SLUG),
|
||||||
|
]);
|
||||||
|
if (brandRes.data) {
|
||||||
|
const b = brandRes.data as Brand;
|
||||||
|
brandId = b.id;
|
||||||
|
brandName = b.name;
|
||||||
|
}
|
||||||
|
if (settingsRes.success && settingsRes.settings) {
|
||||||
|
const s = settingsRes.settings as Settings;
|
||||||
|
logoUrl = s.logo_url ?? null;
|
||||||
|
logoUrlDark = s.logo_url_dark ?? null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore — fall through with defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${fraunces.variable} ${jetbrainsMono.variable} font-sans`}
|
||||||
|
style={{
|
||||||
|
// The product page uses editorial typography: a display serif
|
||||||
|
// (Fraunces) for headlines and a mono (JetBrains Mono) for data
|
||||||
|
// stamps. Body copy falls back to the global SF Pro stack.
|
||||||
|
["--font-display" as never]: fraunces.style.fontFamily,
|
||||||
|
["--font-mono" as never]: jetbrainsMono.style.fontFamily,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SweetCornProductPage
|
||||||
|
brandId={brandId}
|
||||||
|
brandName={brandName}
|
||||||
|
logoUrl={logoUrl}
|
||||||
|
logoUrlDark={logoUrlDark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { createLocation } from "@/actions/locations";
|
||||||
|
import GlassModal from "@/components/admin/GlassModal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
brandId: string;
|
||||||
|
onSuccess?: (locationId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [address, setAddress] = useState("");
|
||||||
|
const [city, setCity] = useState("");
|
||||||
|
const [stateVal, setStateVal] = useState("");
|
||||||
|
const [zip, setZip] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [contactName, setContactName] = useState("");
|
||||||
|
const [contactEmail, setContactEmail] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setName("");
|
||||||
|
setAddress("");
|
||||||
|
setCity("");
|
||||||
|
setStateVal("");
|
||||||
|
setZip("");
|
||||||
|
setPhone("");
|
||||||
|
setContactName("");
|
||||||
|
setContactEmail("");
|
||||||
|
setNotes("");
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (loading) return;
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}, [loading, reset, onClose]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Venue name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await createLocation(brandId, {
|
||||||
|
name: name.trim(),
|
||||||
|
address: address.trim() || null,
|
||||||
|
city: city.trim() || null,
|
||||||
|
state: stateVal.trim().toUpperCase() || null,
|
||||||
|
zip: zip.trim() || null,
|
||||||
|
phone: phone.trim() || null,
|
||||||
|
contact_name: contactName.trim() || null,
|
||||||
|
contact_email: contactEmail.trim() || null,
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
onSuccess?.(result.id);
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(result.error ?? "Failed to create location");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, reset, onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
background: "rgba(0, 0, 0, 0.02)",
|
||||||
|
border: "1px solid rgba(0, 0, 0, 0.06)",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
e.target.style.background = "rgba(16, 185, 129, 0.04)";
|
||||||
|
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
|
||||||
|
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
|
||||||
|
};
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
e.target.style.background = "rgba(0, 0, 0, 0.02)";
|
||||||
|
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
|
||||||
|
e.target.style.boxShadow = "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassModal
|
||||||
|
title="Add Venue"
|
||||||
|
subtitle="Reusable pick-up location. Once saved, you can attach stops to it from the Stops tab."
|
||||||
|
onClose={handleClose}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
|
||||||
|
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Tractor Supply"
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder="13778 E I-25 Frontage Rd"
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-6 gap-4">
|
||||||
|
<div className="col-span-3 space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => setCity(e.target.value)}
|
||||||
|
placeholder="Wellington"
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={stateVal}
|
||||||
|
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
|
||||||
|
placeholder="CO"
|
||||||
|
maxLength={2}
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={zip}
|
||||||
|
onChange={(e) => setZip(e.target.value)}
|
||||||
|
placeholder="80549"
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="(970) 555-1234"
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={contactName}
|
||||||
|
onChange={(e) => setContactName(e.target.value)}
|
||||||
|
placeholder="Store manager"
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={contactEmail}
|
||||||
|
onChange={(e) => setContactEmail(e.target.value)}
|
||||||
|
placeholder="manager@example.com"
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Park on west side. Use side entrance after 9am."
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all resize-none"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
|
||||||
|
style={{
|
||||||
|
background: loading
|
||||||
|
? "rgba(16, 185, 129, 0.4)"
|
||||||
|
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
|
||||||
|
boxShadow: loading
|
||||||
|
? "none"
|
||||||
|
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||||
|
opacity: loading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Creating…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Create Venue"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</GlassModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { createStop } from "@/actions/stops/create-stop";
|
import { createStop } from "@/actions/stops/create-stop";
|
||||||
import GlassModal from "@/components/admin/GlassModal";
|
import GlassModal from "@/components/admin/GlassModal";
|
||||||
|
|
||||||
@@ -21,12 +21,20 @@ type Props = {
|
|||||||
onSuccess?: (stopId: string) => void;
|
onSuccess?: (stopId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Pin icon for the modal header */
|
||||||
|
const PinIcon = () => (
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||||
|
<circle cx="12" cy="10" r="3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, onSuccess }: Props) {
|
export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, onSuccess }: Props) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [city, setCity] = useState("");
|
const [city, setCity] = useState("");
|
||||||
const [state, setState] = useState("");
|
const [stateField, setStateField] = useState("");
|
||||||
const [location, setLocation] = useState("");
|
const [location, setLocation] = useState("");
|
||||||
const [date, setDate] = useState("");
|
const [date, setDate] = useState("");
|
||||||
const [time, setTime] = useState("");
|
const [time, setTime] = useState("");
|
||||||
@@ -36,11 +44,13 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
|||||||
const [cutoffTime, setCutoffTime] = useState("");
|
const [cutoffTime, setCutoffTime] = useState("");
|
||||||
const [status, setStatus] = useState<"draft" | "active">("draft");
|
const [status, setStatus] = useState<"draft" | "active">("draft");
|
||||||
|
|
||||||
|
const cityRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
// Reset form when modal opens with optional duplicate source
|
// Reset form when modal opens with optional duplicate source
|
||||||
setCity(duplicateFrom?.city ?? "");
|
setCity(duplicateFrom?.city ?? "");
|
||||||
setState(duplicateFrom?.state ?? "");
|
setStateField(duplicateFrom?.state ?? "");
|
||||||
setLocation(duplicateFrom?.location ?? "");
|
setLocation(duplicateFrom?.location ?? "");
|
||||||
setDate(duplicateFrom?.date ?? "");
|
setDate(duplicateFrom?.date ?? "");
|
||||||
setTime(duplicateFrom?.time ?? "");
|
setTime(duplicateFrom?.time ?? "");
|
||||||
@@ -49,272 +59,328 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
|||||||
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
|
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
|
||||||
setStatus("draft");
|
setStatus("draft");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
// Auto-focus city field for fast data entry
|
||||||
|
requestAnimationFrame(() => cityRef.current?.focus());
|
||||||
}
|
}
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
}, [isOpen, duplicateFrom]);
|
}, [isOpen, duplicateFrom]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
const handleSubmit = useCallback(
|
||||||
e.preventDefault();
|
async (e: React.FormEvent) => {
|
||||||
setError(null);
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
if (!city.trim() || !state.trim() || !location.trim() || !date) {
|
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
|
||||||
setError("City, state, location, and date are required.");
|
setError("City, state, location, and date are required.");
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await createStop(brandId, {
|
|
||||||
city: city.trim(),
|
|
||||||
state: state.trim(),
|
|
||||||
location: location.trim(),
|
|
||||||
date,
|
|
||||||
time: time || "08:00",
|
|
||||||
address: address || undefined,
|
|
||||||
zip: zip || undefined,
|
|
||||||
cutoff_time: cutoffTime || undefined,
|
|
||||||
active: status === "active",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
onSuccess?.(result.id);
|
|
||||||
onClose();
|
|
||||||
} else {
|
|
||||||
setError(result.error ?? "Failed to create stop");
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
setError("Network error. Please try again.");
|
setLoading(true);
|
||||||
} finally {
|
try {
|
||||||
setLoading(false);
|
const result = await createStop(brandId, {
|
||||||
}
|
city: city.trim(),
|
||||||
}, [brandId, city, state, location, date, time, address, zip, cutoffTime, status, onSuccess, onClose]);
|
state: stateField.trim(),
|
||||||
|
location: location.trim(),
|
||||||
|
date,
|
||||||
|
time: time || "08:00",
|
||||||
|
address: address || undefined,
|
||||||
|
zip: zip || undefined,
|
||||||
|
cutoff_time: cutoffTime || undefined,
|
||||||
|
active: status === "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
onSuccess?.(result.id);
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(result.error ?? "Failed to create stop");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
brandId,
|
||||||
|
city,
|
||||||
|
stateField,
|
||||||
|
location,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
address,
|
||||||
|
zip,
|
||||||
|
cutoffTime,
|
||||||
|
status,
|
||||||
|
onSuccess,
|
||||||
|
onClose,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const isDuplicate = Boolean(duplicateFrom);
|
const isDuplicate = Boolean(duplicateFrom);
|
||||||
const title = isDuplicate ? "Duplicate Stop" : "Add New Stop";
|
const title = isDuplicate ? "Duplicate Stop" : "Add Stop";
|
||||||
const subtitle = isDuplicate && duplicateFrom
|
const eyebrow = isDuplicate && duplicateFrom
|
||||||
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
|
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
|
||||||
: "Create a new tour stop for your route.";
|
: "New stop on the route";
|
||||||
|
const submitLabel = loading
|
||||||
const inputStyle = {
|
? isDuplicate ? "Duplicating…" : "Creating…"
|
||||||
background: 'rgba(0, 0, 0, 0.02)',
|
: isDuplicate
|
||||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
? "Duplicate Stop"
|
||||||
outline: 'none',
|
: status === "active"
|
||||||
};
|
? "Create & Publish"
|
||||||
|
: "Save as Draft";
|
||||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
|
|
||||||
e.target.style.background = 'rgba(16, 185, 129, 0.04)';
|
|
||||||
e.target.style.border = '1px solid rgba(16, 185, 129, 0.5)';
|
|
||||||
e.target.style.boxShadow = '0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
|
|
||||||
e.target.style.background = 'rgba(0, 0, 0, 0.02)';
|
|
||||||
e.target.style.border = '1px solid rgba(0, 0, 0, 0.06)';
|
|
||||||
e.target.style.boxShadow = 'none';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassModal title={title} subtitle={subtitle} onClose={onClose}>
|
<GlassModal
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
title={title}
|
||||||
|
eyebrow={eyebrow}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="max-w-xl"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
|
<div
|
||||||
style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
|
role="alert"
|
||||||
{error}
|
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||||
|
style={{ background: "rgba(220, 38, 38, 0.06)", border: "1px solid rgba(220, 38, 38, 0.15)" }}
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
{/* Row 1 — Where: City + State */}
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-city" className="ha-field-label">
|
||||||
|
<PinIcon />
|
||||||
|
<span>City</span>
|
||||||
|
<span className="ha-field-label-required">*</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
ref={cityRef}
|
||||||
|
id="stop-city"
|
||||||
type="text"
|
type="text"
|
||||||
value={city}
|
value={city}
|
||||||
onChange={(e) => setCity(e.target.value)}
|
onChange={(e) => setCity(e.target.value)}
|
||||||
placeholder="Denver"
|
placeholder="Denver"
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
className="ha-field-input"
|
||||||
style={inputStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required
|
required
|
||||||
|
autoComplete="address-level2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="ha-field">
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
<label htmlFor="stop-state" className="ha-field-label">
|
||||||
|
<span>State</span>
|
||||||
|
<span className="ha-field-label-required">*</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="stop-state"
|
||||||
type="text"
|
type="text"
|
||||||
value={state}
|
value={stateField}
|
||||||
onChange={(e) => setState(e.target.value.toUpperCase())}
|
onChange={(e) => setStateField(e.target.value.toUpperCase())}
|
||||||
placeholder="CO"
|
placeholder="CO"
|
||||||
maxLength={2}
|
maxLength={2}
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
className="ha-field-input ha-field-input-mono text-center"
|
||||||
style={inputStyle}
|
style={{ textTransform: "uppercase" }}
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required
|
required
|
||||||
|
autoComplete="address-level1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
{/* Row 2 — Where at: Location (full) */}
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Location</label>
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-location" className="ha-field-label">
|
||||||
|
<span>Location / Venue</span>
|
||||||
|
<span className="ha-field-label-required">*</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="stop-location"
|
||||||
type="text"
|
type="text"
|
||||||
value={location}
|
value={location}
|
||||||
onChange={(e) => setLocation(e.target.value)}
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
placeholder="Whole Foods Market"
|
placeholder="Whole Foods Market — Highlands"
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
className="ha-field-input"
|
||||||
style={inputStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{/* Row 3 — When: Date + Time (with small clock icon) */}
|
||||||
<div className="space-y-1.5">
|
<div className="grid grid-cols-[1fr_1fr] gap-3">
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Date</label>
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-date" className="ha-field-label">
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
|
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>Pickup Date</span>
|
||||||
|
<span className="ha-field-label-required">*</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="stop-date"
|
||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={date}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
className="ha-field-input ha-field-input-mono"
|
||||||
style={inputStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="ha-field">
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Pickup Time</label>
|
<label htmlFor="stop-time" className="ha-field-label">
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 6v6l4 2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>Pickup Time</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="stop-time"
|
||||||
type="time"
|
type="time"
|
||||||
value={time}
|
value={time}
|
||||||
onChange={(e) => setTime(e.target.value)}
|
onChange={(e) => setTime(e.target.value)}
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
className="ha-field-input ha-field-input-mono"
|
||||||
style={inputStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
{/* Row 4 — Address + ZIP + Cutoff in 3 columns */}
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Address</label>
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-address" className="ha-field-label">
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
|
||||||
|
<path d="M9 22V12h6v10" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>Street Address</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="stop-address"
|
||||||
type="text"
|
type="text"
|
||||||
value={address}
|
value={address}
|
||||||
onChange={(e) => setAddress(e.target.value)}
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
placeholder="123 Main St"
|
placeholder="123 Main St"
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
className="ha-field-input"
|
||||||
style={inputStyle}
|
autoComplete="street-address"
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="ha-field">
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
<label htmlFor="stop-zip" className="ha-field-label">
|
||||||
|
<span>ZIP</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="stop-zip"
|
||||||
type="text"
|
type="text"
|
||||||
value={zip}
|
value={zip}
|
||||||
onChange={(e) => setZip(e.target.value)}
|
onChange={(e) => setZip(e.target.value)}
|
||||||
placeholder="80202"
|
placeholder="80202"
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
maxLength={10}
|
||||||
style={inputStyle}
|
className="ha-field-input ha-field-input-mono"
|
||||||
onFocus={handleFocus}
|
autoComplete="postal-code"
|
||||||
onBlur={handleBlur}
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ha-field">
|
||||||
|
<label htmlFor="stop-cutoff" className="ha-field-label">
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 7v5l3 2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>Cutoff</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stop-cutoff"
|
||||||
|
type="time"
|
||||||
|
value={cutoffTime}
|
||||||
|
onChange={(e) => setCutoffTime(e.target.value)}
|
||||||
|
className="ha-field-input ha-field-input-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
{/* Row 5 — Status: segmented control (compact, replaces two giant buttons) */}
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Cutoff Time</label>
|
<div className="ha-field pt-0.5">
|
||||||
<input
|
<div className="flex items-center justify-between">
|
||||||
type="time"
|
<label className="ha-field-label">
|
||||||
value={cutoffTime}
|
<span>Visibility</span>
|
||||||
onChange={(e) => setCutoffTime(e.target.value)}
|
</label>
|
||||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
|
||||||
style={inputStyle}
|
Draft is hidden from customers
|
||||||
onFocus={handleFocus}
|
</span>
|
||||||
onBlur={handleBlur}
|
</div>
|
||||||
/>
|
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
|
||||||
<p className="mt-1 text-xs text-stone-400 ml-1">Orders close this time the day before pickup</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status toggle */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Status</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={status === "draft"}
|
||||||
onClick={() => setStatus("draft")}
|
onClick={() => setStatus("draft")}
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
|
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
|
||||||
style={{
|
|
||||||
border: status === "draft" ? '1px solid rgba(245, 158, 11, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
|
|
||||||
background: status === "draft" ? 'rgba(245, 158, 11, 0.08)' : 'rgba(0, 0, 0, 0.02)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
|
<span className="ha-segment-dot" />
|
||||||
background: status === "draft" ? 'rgba(245, 158, 11, 0.2)' : 'rgba(0, 0, 0, 0.05)',
|
<span>Save as Draft</span>
|
||||||
color: status === "draft" ? '#b45309' : 'rgba(0, 0, 0, 0.4)',
|
|
||||||
}}>Draft</span>
|
|
||||||
<span className="text-sm font-medium" style={{ color: status === "draft" ? '#92400e' : 'rgba(0, 0, 0, 0.4)' }}>Save as draft</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={status === "active"}
|
||||||
onClick={() => setStatus("active")}
|
onClick={() => setStatus("active")}
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
|
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
|
||||||
style={{
|
|
||||||
border: status === "active" ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
|
|
||||||
background: status === "active" ? 'rgba(16, 185, 129, 0.08)' : 'rgba(0, 0, 0, 0.02)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
|
<span className="ha-segment-dot" />
|
||||||
background: status === "active" ? 'rgba(16, 185, 129, 0.2)' : 'rgba(0, 0, 0, 0.05)',
|
<span>Publish Now</span>
|
||||||
color: status === "active" ? '#047857' : 'rgba(0, 0, 0, 0.4)',
|
|
||||||
}}>Active</span>
|
|
||||||
<span className="text-sm font-medium" style={{ color: status === "active" ? '#065f46' : 'rgba(0, 0, 0, 0.4)' }}>Publish now</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}>
|
<div className="ha-modal-footer">
|
||||||
<button
|
<span className="ha-modal-footer-hint">
|
||||||
type="button"
|
<kbd>Esc</kbd>
|
||||||
onClick={onClose}
|
to close
|
||||||
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100"
|
</span>
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
Cancel
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
onClick={onClose}
|
||||||
type="submit"
|
className="ha-btn-ghost"
|
||||||
disabled={loading}
|
>
|
||||||
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
|
Cancel
|
||||||
style={{
|
</button>
|
||||||
background: loading
|
<button
|
||||||
? 'rgba(16, 185, 129, 0.4)'
|
type="submit"
|
||||||
: 'linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)',
|
disabled={loading}
|
||||||
boxShadow: loading
|
className="ha-btn-primary"
|
||||||
? 'none'
|
>
|
||||||
: '0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)',
|
{loading ? (
|
||||||
opacity: loading ? 0.7 : 1,
|
<>
|
||||||
}}
|
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
>
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||||
{loading ? (
|
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||||
<span className="flex items-center gap-2">
|
</svg>
|
||||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
<span>{submitLabel}</span>
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
</>
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
) : (
|
||||||
</svg>
|
<>
|
||||||
Creating...
|
{status === "active" ? (
|
||||||
</span>
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
) : (
|
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
isDuplicate ? "Duplicate Stop" : "Create Stop"
|
</svg>
|
||||||
)}
|
) : (
|
||||||
</button>
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||||
|
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span>{submitLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</GlassModal>
|
</GlassModal>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
|
import BrandSelector from "./BrandSelector";
|
||||||
|
|
||||||
// Elegant warm sidebar design
|
// Elegant warm sidebar design
|
||||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||||
@@ -217,9 +218,12 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
|||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
userRole?: string | null;
|
userRole?: string | null;
|
||||||
|
brandIds?: string[];
|
||||||
|
activeBrandId?: string | null;
|
||||||
|
brands?: Array<{ id: string; name: string; slug: string; logo_url: string | null }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminSidebar({ userRole }: SidebarProps) {
|
export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands }: SidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
@@ -230,9 +234,15 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
|||||||
|
|
||||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||||
: userRole === "brand_admin" ? "Brand Admin"
|
: userRole === "brand_admin" ? "Brand Admin"
|
||||||
|
: userRole === "multi_brand_admin" ? "Multi-Brand Manager"
|
||||||
: userRole === "store_employee" ? "Store Employee"
|
: userRole === "store_employee" ? "Store Employee"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const isMultiBrandAdmin = userRole === "multi_brand_admin";
|
||||||
|
const showAllBrandsOption = userRole === "platform_admin";
|
||||||
|
const showBrandSelector =
|
||||||
|
brands && (showAllBrandsOption || (brandIds && brandIds.length >= 2));
|
||||||
|
|
||||||
const isActive = useCallback((href: string) => {
|
const isActive = useCallback((href: string) => {
|
||||||
if (href === "/admin") return pathname === "/admin";
|
if (href === "/admin") return pathname === "/admin";
|
||||||
return pathname.startsWith(href);
|
return pathname.startsWith(href);
|
||||||
@@ -394,6 +404,24 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Brand selector (multi-brand admins + platform_admin) */}
|
||||||
|
{showBrandSelector && (
|
||||||
|
<div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
|
||||||
|
<p
|
||||||
|
className="text-[10px] font-semibold uppercase tracking-widest mb-1.5 px-1"
|
||||||
|
style={{ color: "rgba(195, 195, 193, 0.6)" }}
|
||||||
|
>
|
||||||
|
Active Brand
|
||||||
|
</p>
|
||||||
|
<BrandSelector
|
||||||
|
brands={brands!}
|
||||||
|
activeBrandId={activeBrandId ?? null}
|
||||||
|
showAllBrandsOption={showAllBrandsOption}
|
||||||
|
isMultiBrandAdmin={isMultiBrandAdmin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Nav links with keyboard navigation */}
|
{/* Nav links with keyboard navigation */}
|
||||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
|
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
|
||||||
<ul className="space-y-1" role="list">
|
<ul className="space-y-1" role="list">
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { setActiveBrand } from "@/actions/set-active-brand";
|
||||||
|
|
||||||
|
export type BrandSelectorItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
logo_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrandSelectorProps = {
|
||||||
|
brands: BrandSelectorItem[];
|
||||||
|
/** Currently-active brand id. `null` means "All brands" (platform_admin only). */
|
||||||
|
activeBrandId: string | null;
|
||||||
|
/** When true, shows the "All brands" pseudo-option at the top. */
|
||||||
|
showAllBrandsOption: boolean;
|
||||||
|
/** Whether the user has multi-brand access. Controls the "Multi-brand manager" badge. */
|
||||||
|
isMultiBrandAdmin: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand selector dropdown.
|
||||||
|
*
|
||||||
|
* Renders a compact pill showing the active brand (or "All brands" for
|
||||||
|
* platform_admin) and opens a list of accessible brands on click.
|
||||||
|
*
|
||||||
|
* On select: calls `setActiveBrand` server action then `router.refresh()`
|
||||||
|
* so all server components re-read the new cookie and reload their data.
|
||||||
|
* The URL is NOT changed — the cookie is the source of truth.
|
||||||
|
*/
|
||||||
|
export default function BrandSelector({
|
||||||
|
brands,
|
||||||
|
activeBrandId,
|
||||||
|
showAllBrandsOption,
|
||||||
|
isMultiBrandAdmin,
|
||||||
|
}: BrandSelectorProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close on escape
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleKey);
|
||||||
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// No brands + no "All brands" option = don't render
|
||||||
|
if (!showAllBrandsOption && brands.length === 0) return null;
|
||||||
|
if (!showAllBrandsOption && brands.length < 2) return null;
|
||||||
|
|
||||||
|
const activeBrand = brands.find((b) => b.id === activeBrandId);
|
||||||
|
const label =
|
||||||
|
showAllBrandsOption && !activeBrand
|
||||||
|
? "All brands"
|
||||||
|
: activeBrand?.name ?? "Select brand";
|
||||||
|
|
||||||
|
async function selectBrand(brandId: string | null) {
|
||||||
|
setOpen(false);
|
||||||
|
if (brandId === activeBrandId) return;
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
const res = await setActiveBrand(brandId);
|
||||||
|
if (res.success) {
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
// Keep UI usable if the server rejected; log for debugging
|
||||||
|
console.error("setActiveBrand failed:", res.error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
disabled={pending}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg border transition-colors text-left disabled:opacity-60"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||||
|
borderColor: "rgba(208, 203, 180, 0.25)",
|
||||||
|
color: "var(--admin-sidebar-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{showAllBrandsOption && !activeBrand ? "*" : label.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0 text-xs font-medium truncate">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{isMultiBrandAdmin && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-medium px-1.5 py-0.5 rounded-md flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(202, 117, 67, 0.18)",
|
||||||
|
color: "var(--admin-accent)",
|
||||||
|
}}
|
||||||
|
title="Manages multiple brands"
|
||||||
|
>
|
||||||
|
multi
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 flex-shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
className="absolute top-full left-0 right-0 mt-1.5 rounded-xl border shadow-2xl z-50 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--admin-sidebar-bg)",
|
||||||
|
borderColor: "rgba(208, 203, 180, 0.25)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAllBrandsOption && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={!activeBrand}
|
||||||
|
onClick={() => selectBrand(null)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
|
||||||
|
style={{
|
||||||
|
color: !activeBrand ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
|
||||||
|
backgroundColor: !activeBrand ? "rgba(202, 117, 67, 0.10)" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">All brands</span>
|
||||||
|
{!activeBrand && (
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{brands.map((b) => {
|
||||||
|
const isActive = b.id === activeBrandId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => selectBrand(b.id)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
|
||||||
|
style={{
|
||||||
|
color: isActive ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
|
||||||
|
backgroundColor: isActive ? "rgba(202, 117, 67, 0.10)" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{b.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{b.name}</span>
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { updateLocation } from "@/actions/locations";
|
||||||
|
import GlassModal from "@/components/admin/GlassModal";
|
||||||
|
|
||||||
|
export type LocationForEdit = {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
zip: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
contact_name: string | null;
|
||||||
|
contact_email: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
location: LocationForEdit | null;
|
||||||
|
brandId: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EditLocationModal({ isOpen, onClose, location, brandId, onSuccess }: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [address, setAddress] = useState("");
|
||||||
|
const [city, setCity] = useState("");
|
||||||
|
const [stateVal, setStateVal] = useState("");
|
||||||
|
const [zip, setZip] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [contactName, setContactName] = useState("");
|
||||||
|
const [contactEmail, setContactEmail] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
|
if (location && isOpen) {
|
||||||
|
setName(location.name ?? "");
|
||||||
|
setAddress(location.address ?? "");
|
||||||
|
setCity(location.city ?? "");
|
||||||
|
setStateVal(location.state ?? "");
|
||||||
|
setZip(location.zip ?? "");
|
||||||
|
setPhone(location.phone ?? "");
|
||||||
|
setContactName(location.contact_name ?? "");
|
||||||
|
setContactEmail(location.contact_email ?? "");
|
||||||
|
setNotes(location.notes ?? "");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
|
}, [location, isOpen]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (loading) return;
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
}, [loading, onClose]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!location) return;
|
||||||
|
setError(null);
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Venue name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await updateLocation(location.id, brandId, {
|
||||||
|
name: name.trim(),
|
||||||
|
address: address.trim() || null,
|
||||||
|
city: city.trim() || null,
|
||||||
|
state: stateVal.trim().toUpperCase() || null,
|
||||||
|
zip: zip.trim() || null,
|
||||||
|
phone: phone.trim() || null,
|
||||||
|
contact_name: contactName.trim() || null,
|
||||||
|
contact_email: contactEmail.trim() || null,
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
active: location.active,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
onSuccess?.();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(result.error ?? "Failed to update location");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[location, brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
background: "rgba(0, 0, 0, 0.02)",
|
||||||
|
border: "1px solid rgba(0, 0, 0, 0.06)",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
e.target.style.background = "rgba(16, 185, 129, 0.04)";
|
||||||
|
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
|
||||||
|
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
|
||||||
|
};
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
e.target.style.background = "rgba(0, 0, 0, 0.02)";
|
||||||
|
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
|
||||||
|
e.target.style.boxShadow = "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !location) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassModal
|
||||||
|
title="Edit Venue"
|
||||||
|
subtitle="Changes apply to every stop currently linked to this venue."
|
||||||
|
onClose={handleClose}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
|
||||||
|
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-6 gap-4">
|
||||||
|
<div className="col-span-3 space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => setCity(e.target.value)}
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={stateVal}
|
||||||
|
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
|
||||||
|
maxLength={2}
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={zip}
|
||||||
|
onChange={(e) => setZip(e.target.value)}
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={contactName}
|
||||||
|
onChange={(e) => setContactName(e.target.value)}
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={contactEmail}
|
||||||
|
onChange={(e) => setContactEmail(e.target.value)}
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all resize-none"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
|
||||||
|
style={{
|
||||||
|
background: loading
|
||||||
|
? "rgba(16, 185, 129, 0.4)"
|
||||||
|
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
|
||||||
|
boxShadow: loading
|
||||||
|
? "none"
|
||||||
|
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||||
|
opacity: loading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Saving…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Save Changes"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</GlassModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
titleIcon?: React.ReactNode;
|
||||||
|
subtitle?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
maxWidth?: string;
|
||||||
|
variant?: "default" | "elegant";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ElegantModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg", variant = "default" }: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => { document.body.style.overflow = ""; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleEscape);
|
||||||
|
return () => window.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isElegant = variant === "elegant";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col ${isElegant ? "border border-stone-200/60" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-600 to-amber-500 rounded-t-2xl" />
|
||||||
|
|
||||||
|
<div className={`flex items-center justify-between px-6 sm:px-8 py-5 shrink-0 ${isElegant ? "border-b border-stone-100" : ""}`}>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{titleIcon && (
|
||||||
|
<div className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-lg shrink-0 bg-amber-50">
|
||||||
|
{titleIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
{isElegant ? (
|
||||||
|
<h2 className="text-sm font-normal tracking-wide text-stone-500 uppercase" style={{ fontFamily: "Georgia, serif" }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
) : (
|
||||||
|
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 truncate" style={{ letterSpacing: "-0.02em" }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<p className={`mt-0.5 text-sm text-stone-400 hidden sm:block ${isElegant ? "font-normal" : ""}`}>{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2 hover:bg-stone-100 text-stone-500"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative px-6 sm:px-8 py-6 overflow-y-auto flex-1">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,9 +9,22 @@ type Props = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
|
/** Compact mode: tighter padding, smaller title, no accent bar — for dense forms. */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Optional eyebrow text rendered above the title in compact mode. */
|
||||||
|
eyebrow?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg" }: Props) {
|
export default function GlassModal({
|
||||||
|
title,
|
||||||
|
titleIcon,
|
||||||
|
subtitle,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
maxWidth = "max-w-lg",
|
||||||
|
compact = false,
|
||||||
|
eyebrow,
|
||||||
|
}: Props) {
|
||||||
// Lock body scroll
|
// Lock body scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
@@ -33,24 +46,35 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
|
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 md:p-6"
|
||||||
onClick={handleBackdropClick}
|
onClick={handleBackdropClick}
|
||||||
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
|
style={{
|
||||||
|
backgroundColor: "rgba(60, 56, 37, 0.45)",
|
||||||
|
backdropFilter: "blur(2px)",
|
||||||
|
WebkitBackdropFilter: "blur(2px)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Modal card - solid white with shadow for high contrast */}
|
|
||||||
<div
|
<div
|
||||||
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col`}
|
className={`relative w-full ${maxWidth} ${
|
||||||
|
compact
|
||||||
|
? "max-h-[min(640px,calc(100vh-2rem))]"
|
||||||
|
: "max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)]"
|
||||||
|
} rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.18),0_8px_16px_-4px_rgba(0,0,0,0.05)] flex flex-col overflow-hidden`}
|
||||||
>
|
>
|
||||||
{/* Subtle top border accent */}
|
{/* Accent bar — only for non-compact (compact forms have their own internal accent) */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
|
{!compact && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between px-4 sm:px-6 md:px-8 py-4 sm:py-6 shrink-0"
|
className={`flex items-center justify-between shrink-0 ${
|
||||||
|
compact ? "px-5 pt-4 pb-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6"
|
||||||
|
}`}
|
||||||
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
|
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||||
{titleIcon && (
|
{titleIcon && !compact && (
|
||||||
<div
|
<div
|
||||||
className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-xl shrink-0"
|
className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-xl shrink-0"
|
||||||
style={{ backgroundColor: "var(--admin-accent-light)" }}
|
style={{ backgroundColor: "var(--admin-accent-light)" }}
|
||||||
@@ -59,21 +83,39 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
{compact && eyebrow && (
|
||||||
|
<p className="ha-eyebrow mb-0.5 truncate">{eyebrow}</p>
|
||||||
|
)}
|
||||||
<h2
|
<h2
|
||||||
className="text-lg sm:text-xl font-semibold text-[var(--admin-text-primary)] truncate"
|
className={`${
|
||||||
style={{ letterSpacing: "-0.02em" }}
|
compact
|
||||||
|
? "ha-display text-lg truncate"
|
||||||
|
: "text-lg sm:text-xl font-semibold truncate"
|
||||||
|
} text-[var(--admin-text-primary)]`}
|
||||||
|
style={compact ? undefined : { letterSpacing: "-0.02em" }}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{subtitle && (
|
{subtitle && !compact && (
|
||||||
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">{subtitle}</p>
|
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2"
|
className={`flex shrink-0 items-center justify-center rounded-full transition-all duration-150 ml-2 ${
|
||||||
style={{ backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }}
|
compact
|
||||||
|
? "h-7 w-7 text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)]"
|
||||||
|
: "h-8 w-8 sm:h-9 sm:w-9"
|
||||||
|
}`}
|
||||||
|
style={
|
||||||
|
compact
|
||||||
|
? undefined
|
||||||
|
: { backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }
|
||||||
|
}
|
||||||
|
aria-label="Close modal"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -81,8 +123,12 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content - scrollable */}
|
{/* Content */}
|
||||||
<div className="relative px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8 overflow-y-auto flex-1">
|
<div
|
||||||
|
className={`relative overflow-y-auto flex-1 ${
|
||||||
|
compact ? "px-5 pb-5 pt-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"use client";
|
||||||
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
|
|
||||||
|
import { useState, useTransition, useEffect, useMemo } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { deleteLocation } from "@/actions/locations";
|
||||||
|
import AddLocationModal from "@/components/admin/AddLocationModal";
|
||||||
|
import EditLocationModal, { type LocationForEdit } from "@/components/admin/EditLocationModal";
|
||||||
|
import {
|
||||||
|
AdminSearchInput,
|
||||||
|
AdminFilterTabs,
|
||||||
|
AdminButton,
|
||||||
|
AdminIconButton,
|
||||||
|
useToast,
|
||||||
|
Skeleton,
|
||||||
|
} from "@/components/admin/design-system";
|
||||||
|
|
||||||
|
export type AdminLocation = {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
zip: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
contact_name: string | null;
|
||||||
|
contact_email: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
active: boolean;
|
||||||
|
deleted_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
slug: string | null;
|
||||||
|
stop_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
locations: AdminLocation[];
|
||||||
|
brandId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusFilter = "all" | "active" | "inactive";
|
||||||
|
|
||||||
|
export default function LocationsTab({ locations, brandId }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { success: showSuccess, error: showError } = useToast();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<LocationForEdit | null>(null);
|
||||||
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const t = setTimeout(() => setIsLoading(false), 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [page, statusFilter, search]);
|
||||||
|
|
||||||
|
const counts = useMemo(
|
||||||
|
() => ({
|
||||||
|
all: locations.length,
|
||||||
|
active: locations.filter((l) => l.active).length,
|
||||||
|
inactive: locations.filter((l) => !l.active).length,
|
||||||
|
}),
|
||||||
|
[locations]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return locations.filter((l) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!q ||
|
||||||
|
l.name.toLowerCase().includes(q) ||
|
||||||
|
(l.address ?? "").toLowerCase().includes(q) ||
|
||||||
|
(l.city ?? "").toLowerCase().includes(q) ||
|
||||||
|
(l.contact_name ?? "").toLowerCase().includes(q);
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter === "all" ||
|
||||||
|
(statusFilter === "active" && l.active) ||
|
||||||
|
(statusFilter === "inactive" && !l.active);
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
}, [locations, search, statusFilter]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ value: "all", label: "All", count: counts.all },
|
||||||
|
{ value: "active", label: "Active", count: counts.active },
|
||||||
|
{ value: "inactive", label: "Inactive", count: counts.inactive },
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleAdded() {
|
||||||
|
setShowAdd(false);
|
||||||
|
showSuccess("Venue created");
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdited() {
|
||||||
|
setEditing(null);
|
||||||
|
showSuccess("Venue updated");
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(loc: AdminLocation) {
|
||||||
|
if (!confirm(`Delete venue "${loc.name}"? This cannot be undone.`)) return;
|
||||||
|
setPendingDeleteId(loc.id);
|
||||||
|
setDeleteError(null);
|
||||||
|
const res = await deleteLocation(loc.id, brandId);
|
||||||
|
setPendingDeleteId(null);
|
||||||
|
if (res.success) {
|
||||||
|
showSuccess("Venue deleted");
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} else {
|
||||||
|
setDeleteError(res.error ?? "Delete failed");
|
||||||
|
showError("Delete failed", res.error ?? "Unknown error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Filter bar */}
|
||||||
|
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
|
||||||
|
<AdminSearchInput
|
||||||
|
placeholder="Search venues by name, city, or contact…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
setSearch("");
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
showClear
|
||||||
|
className="flex-1 min-w-48 max-w-72"
|
||||||
|
/>
|
||||||
|
<AdminFilterTabs
|
||||||
|
activeTab={statusFilter}
|
||||||
|
onTabChange={(value) => {
|
||||||
|
setStatusFilter(value as StatusFilter);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
tabs={tabs}
|
||||||
|
size="sm"
|
||||||
|
showCounts
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">
|
||||||
|
{filtered.length} venue{filtered.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<AdminIconButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
label="Previous page"
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0 || isLoading}
|
||||||
|
className="!rounded-lg border border-[var(--admin-border)]"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</AdminIconButton>
|
||||||
|
<span className="text-xs text-[var(--admin-text-muted)] px-1">
|
||||||
|
{page + 1}/{totalPages}
|
||||||
|
</span>
|
||||||
|
<AdminIconButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
label="Next page"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1 || isLoading}
|
||||||
|
className="!rounded-lg border border-[var(--admin-border)]"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</AdminIconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AdminButton
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAdd(true)}
|
||||||
|
icon={
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add Venue
|
||||||
|
</AdminButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete error */}
|
||||||
|
{deleteError && (
|
||||||
|
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
|
||||||
|
{deleteError}{" "}
|
||||||
|
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-4 font-semibold">Venue</th>
|
||||||
|
<th className="px-5 py-4 font-semibold">Address</th>
|
||||||
|
<th className="px-5 py-4 font-semibold">Contact</th>
|
||||||
|
<th className="px-5 py-4 font-semibold text-center">Stops</th>
|
||||||
|
<th className="px-5 py-4 font-semibold">Status</th>
|
||||||
|
<th className="px-5 py-4 font-semibold" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||||
|
<td className="px-5 py-4"><Skeleton variant="text" className="w-48 h-4" /></td>
|
||||||
|
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||||
|
<td className="px-5 py-4 text-center"><Skeleton variant="rect" className="w-8 h-5 mx-auto rounded-full" /></td>
|
||||||
|
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
|
||||||
|
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-5 py-12 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-[var(--admin-text-muted)]">
|
||||||
|
<svg className="h-10 w-10 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm">
|
||||||
|
{search || statusFilter !== "all"
|
||||||
|
? "No venues match your filters."
|
||||||
|
: "No venues yet. Add your first venue to get started."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginated.map((loc) => (
|
||||||
|
<tr key={loc.id} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="font-semibold text-[var(--admin-text-primary)]">{loc.name}</div>
|
||||||
|
{(loc.city || loc.state) && (
|
||||||
|
<div className="text-xs text-[var(--admin-text-muted)]">
|
||||||
|
{[loc.city, loc.state].filter(Boolean).join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
{loc.address ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-[var(--admin-text-secondary)]">{loc.address}</div>
|
||||||
|
{loc.zip && (
|
||||||
|
<div className="text-xs text-[var(--admin-text-muted)]">{loc.zip}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--admin-text-muted)]">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
{loc.contact_name || loc.phone || loc.contact_email ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{loc.contact_name && (
|
||||||
|
<div className="text-[var(--admin-text-secondary)]">{loc.contact_name}</div>
|
||||||
|
)}
|
||||||
|
{loc.phone && (
|
||||||
|
<div className="text-xs text-[var(--admin-text-muted)]">{loc.phone}</div>
|
||||||
|
)}
|
||||||
|
{loc.contact_email && (
|
||||||
|
<div className="text-xs text-[var(--admin-text-muted)] truncate max-w-[200px]">
|
||||||
|
{loc.contact_email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--admin-text-muted)]">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-center">
|
||||||
|
{loc.stop_count > 0 ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center min-w-[28px] px-2 py-0.5 rounded-full text-xs font-semibold"
|
||||||
|
style={{
|
||||||
|
background: "rgba(16, 185, 129, 0.12)",
|
||||||
|
color: "#047857",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loc.stop_count}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--admin-text-muted)] text-xs">0</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
{loc.active ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||||
|
style={{ background: "rgba(16, 185, 129, 0.12)", color: "#047857" }}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#10b981" }} />
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||||
|
style={{ background: "rgba(120, 113, 108, 0.12)", color: "#57534e" }}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#a8a29e" }} />
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(loc as LocationForEdit)}
|
||||||
|
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||||
|
title="Edit venue"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(loc)}
|
||||||
|
disabled={pendingDeleteId === loc.id}
|
||||||
|
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50"
|
||||||
|
title="Delete venue"
|
||||||
|
>
|
||||||
|
{pendingDeleteId === loc.id ? (
|
||||||
|
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a2 2 0 012-2h2a2 2 0 012 2v3" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<AddLocationModal
|
||||||
|
isOpen={showAdd}
|
||||||
|
onClose={() => setShowAdd(false)}
|
||||||
|
brandId={brandId}
|
||||||
|
onSuccess={handleAdded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<EditLocationModal
|
||||||
|
isOpen={!!editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
location={editing}
|
||||||
|
brandId={brandId}
|
||||||
|
onSuccess={handleEdited}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export type ProductFormModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
mode: "add" | "edit";
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (values: ProductFormValues) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
/** Defaults / initial values (edit mode passes the current product) */
|
||||||
|
initial?: Partial<ProductFormValues>;
|
||||||
|
/** List of selectable brands (only shown to platform admins) */
|
||||||
|
brands?: { id: string; name: string }[];
|
||||||
|
/** When true the brand picker is hidden and the admin's brand is used */
|
||||||
|
lockBrand?: boolean;
|
||||||
|
/** Hard-locked brand id (for brand_admin / store_employee) */
|
||||||
|
lockedBrandId?: string;
|
||||||
|
/** Pre-uploaded image URL (edit mode) */
|
||||||
|
initialImageUrl?: string | null;
|
||||||
|
/** Server action: upload a file and return the public URL */
|
||||||
|
onUploadImage: (file: File) => Promise<{ success: boolean; imageUrl?: string; error?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductFormValues = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
type: "pickup" | "wholesale" | "subscription";
|
||||||
|
brand_id: string;
|
||||||
|
active: boolean;
|
||||||
|
is_taxable: boolean;
|
||||||
|
image_url: string | null;
|
||||||
|
available_from?: string | null;
|
||||||
|
available_until?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: Array<{
|
||||||
|
value: ProductFormValues["type"];
|
||||||
|
label: string;
|
||||||
|
italic: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: "pickup",
|
||||||
|
label: "Pickup",
|
||||||
|
italic: "Farm & stops",
|
||||||
|
icon: (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 9l1.5-4h15L21 9" />
|
||||||
|
<path d="M3 9v11a1 1 0 001 1h16a1 1 0 001-1V9" />
|
||||||
|
<path d="M3 9h18" />
|
||||||
|
<path d="M9 14h6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "wholesale",
|
||||||
|
label: "Wholesale",
|
||||||
|
italic: "B2B portal",
|
||||||
|
icon: (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 7h18l-2 12H5L3 7z" />
|
||||||
|
<path d="M8 7V5a4 4 0 018 0v2" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "subscription",
|
||||||
|
label: "Subscription",
|
||||||
|
italic: "Recurring",
|
||||||
|
icon: (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 12a9 9 0 11-3-6.7" />
|
||||||
|
<path d="M21 4v5h-5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ProductFormModal({
|
||||||
|
open,
|
||||||
|
mode,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
initial,
|
||||||
|
brands = [],
|
||||||
|
lockBrand = false,
|
||||||
|
lockedBrandId = "",
|
||||||
|
initialImageUrl = null,
|
||||||
|
onUploadImage,
|
||||||
|
}: ProductFormModalProps) {
|
||||||
|
// Form state
|
||||||
|
const [name, setName] = useState(initial?.name ?? "");
|
||||||
|
const [description, setDescription] = useState(initial?.description ?? "");
|
||||||
|
const [price, setPrice] = useState(initial?.price ?? "");
|
||||||
|
const [type, setType] = useState<ProductFormValues["type"]>(initial?.type ?? "pickup");
|
||||||
|
const [brandId, setBrandId] = useState(
|
||||||
|
initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? ""
|
||||||
|
);
|
||||||
|
const [active, setActive] = useState(initial?.active ?? true);
|
||||||
|
const [isTaxable, setIsTaxable] = useState(initial?.is_taxable ?? true);
|
||||||
|
const [availableFrom, setAvailableFrom] = useState(initial?.available_from ? initial.available_from.split('T')[0] : "");
|
||||||
|
const [availableUntil, setAvailableUntil] = useState(initial?.available_until ? initial.available_until.split('T')[0] : "");
|
||||||
|
|
||||||
|
// Image state
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(initialImageUrl);
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(initialImageUrl);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
const [isDrag, setIsDrag] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Submit state
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset state when opening with new initial values
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setName(initial?.name ?? "");
|
||||||
|
setDescription(initial?.description ?? "");
|
||||||
|
setPrice(initial?.price ?? "");
|
||||||
|
setType((initial?.type as ProductFormValues["type"]) ?? "pickup");
|
||||||
|
setBrandId(initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? "");
|
||||||
|
setActive(initial?.active ?? true);
|
||||||
|
setIsTaxable(initial?.is_taxable ?? true);
|
||||||
|
setAvailableFrom(initial?.available_from ? initial.available_from.split('T')[0] : "");
|
||||||
|
setAvailableUntil(initial?.available_until ? initial.available_until.split('T')[0] : "");
|
||||||
|
setImagePreview(initialImageUrl);
|
||||||
|
setImageUrl(initialImageUrl);
|
||||||
|
setUploading(false);
|
||||||
|
setUploadError(null);
|
||||||
|
setError(null);
|
||||||
|
setSaving(false);
|
||||||
|
setIsDrag(false);
|
||||||
|
}, [open, initial, initialImageUrl, lockedBrandId, brands]);
|
||||||
|
|
||||||
|
// Body scroll lock + escape key
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const original = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = original;
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
const handleFile = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
setUploadError("PNG, JPEG, or WebP only.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
setUploadError("File must be under 5MB.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadError(null);
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
// Local preview first
|
||||||
|
const localUrl = URL.createObjectURL(file);
|
||||||
|
setImagePreview(localUrl);
|
||||||
|
|
||||||
|
const result = await onUploadImage(file);
|
||||||
|
setUploading(false);
|
||||||
|
|
||||||
|
if (result.success && result.imageUrl) {
|
||||||
|
setImageUrl(result.imageUrl);
|
||||||
|
setImagePreview(result.imageUrl);
|
||||||
|
URL.revokeObjectURL(localUrl);
|
||||||
|
} else {
|
||||||
|
setUploadError(result.error ?? "Upload failed.");
|
||||||
|
setImagePreview(imageUrl); // revert
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onUploadImage, imageUrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Product name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!price || isNaN(parseFloat(price)) || parseFloat(price) < 0) {
|
||||||
|
setError("Valid price is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lockBrand && !brandId) {
|
||||||
|
setError("Please select a brand.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
const res = await onSubmit({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
price,
|
||||||
|
type,
|
||||||
|
brand_id: lockBrand ? lockedBrandId : brandId,
|
||||||
|
active,
|
||||||
|
is_taxable: isTaxable,
|
||||||
|
image_url: imageUrl,
|
||||||
|
available_from: availableFrom ? new Date(availableFrom).toISOString() : null,
|
||||||
|
available_until: availableUntil ? new Date(availableUntil).toISOString() : null,
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
setError(res.error ?? "Something went wrong.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted || !open) return null;
|
||||||
|
|
||||||
|
const showBrandPicker = !lockBrand;
|
||||||
|
const lockedBrandName = brands.find((b) => b.id === lockedBrandId)?.name ?? lockedBrandId;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[80] flex items-center justify-center p-3 sm:p-6 atelier-backdrop"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(28, 25, 23, 0.55)",
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
WebkitBackdropFilter: "blur(8px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={mode === "add" ? "Add product" : "Edit product"}
|
||||||
|
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl shadow-[0_30px_80px_-20px_rgba(28,25,23,0.45)] flex flex-col overflow-hidden border border-stone-200/60"
|
||||||
|
>
|
||||||
|
{/* grain overlay */}
|
||||||
|
<div className="atelier-grain" aria-hidden />
|
||||||
|
|
||||||
|
{/* HEADER */}
|
||||||
|
<div className="relative shrink-0 px-6 sm:px-10 pt-6 sm:pt-8 pb-5 sm:pb-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2.5 mb-3">
|
||||||
|
<span className="atelier-section-num">
|
||||||
|
{mode === "add" ? "Nouveau" : "Mise à jour"} · MMXXVI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="atelier-title text-3xl sm:text-4xl md:text-5xl">
|
||||||
|
{mode === "add" ? (
|
||||||
|
<>
|
||||||
|
Add a <span className="atelier-italic">product</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Edit <span className="atelier-italic">{initial?.name || "product"}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="atelier-italic mt-2 text-sm sm:text-[15px] max-w-md leading-relaxed">
|
||||||
|
{mode === "add"
|
||||||
|
? "Compose a new entry for the harvest catalog — every detail, like a recipe."
|
||||||
|
: "Refine the details of this entry. The catalog is a living record."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="shrink-0 flex h-9 w-9 items-center justify-center rounded-full transition-all duration-200 hover:bg-stone-900 hover:text-white text-stone-500"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative shrink-0 px-6 sm:px-10">
|
||||||
|
<hr className="atelier-rule" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BODY — two columns */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="relative flex-1 overflow-y-auto"
|
||||||
|
onDragEnter={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDrag(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] gap-0">
|
||||||
|
{/* LEFT — Image hero */}
|
||||||
|
<div className="relative px-6 sm:px-10 pt-6 sm:pt-8 pb-6 lg:border-r border-stone-200/60 lg:pr-8">
|
||||||
|
<div className="atelier-section-num mb-4">01 · Media</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDrag(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
if (e.currentTarget === e.target) setIsDrag(false);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDrag(false);
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
}}
|
||||||
|
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||||
|
className={[
|
||||||
|
"atelier-drop",
|
||||||
|
isDrag ? "is-drag" : "",
|
||||||
|
uploadError ? "is-error" : "",
|
||||||
|
imagePreview ? "has-image" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{/* status pill */}
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"atelier-status-pill",
|
||||||
|
imagePreview ? "" : "is-empty",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<span className="atelier-dot" />
|
||||||
|
{imagePreview
|
||||||
|
? uploading
|
||||||
|
? "Uploading"
|
||||||
|
: "Image set"
|
||||||
|
: "No image yet"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploading ? (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="h-6 w-6 rounded-full border-2 border-stone-300 border-t-[#224E2F] animate-spin" />
|
||||||
|
<p className="atelier-drop-title">Pouring into the cellar…</p>
|
||||||
|
</div>
|
||||||
|
) : imagePreview ? (
|
||||||
|
<div className="absolute inset-0 p-3 sm:p-4">
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
<Image
|
||||||
|
src={imagePreview}
|
||||||
|
alt="Product preview"
|
||||||
|
fill
|
||||||
|
style={{ objectFit: "contain" }}
|
||||||
|
className="rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setImagePreview(null);
|
||||||
|
setImageUrl(null);
|
||||||
|
}}
|
||||||
|
className="absolute bottom-4 left-1/2 -translate-x-1/2 inline-flex items-center gap-1.5 rounded-full bg-stone-900/85 backdrop-blur-sm px-3 py-1.5 text-[11px] font-mono uppercase tracking-wider text-white hover:bg-stone-900 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ornamental drop zone content */}
|
||||||
|
<svg
|
||||||
|
className="h-10 w-10 text-[#224E2F]/70"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 3v13" />
|
||||||
|
<path d="M7 8l5-5 5 5" />
|
||||||
|
<path d="M4 16v3a2 2 0 002 2h12a2 2 0 002-2v-3" />
|
||||||
|
<circle cx="12" cy="20" r="0.8" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<p className="atelier-drop-eyebrow">Drag & 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useTransition } from "react";
|
import { useState, useCallback, useTransition, useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { createProduct } from "@/actions/products/create-product";
|
import { createProduct } from "@/actions/products/create-product";
|
||||||
import { updateProduct } from "@/actions/products/update-product";
|
import { updateProduct } from "@/actions/products/update-product";
|
||||||
import { deleteProduct } from "@/actions/products";
|
import { deleteProduct } from "@/actions/products";
|
||||||
import { uploadProductImage } from "@/actions/products/upload-image";
|
import { uploadProductImage } from "@/actions/products/upload-image";
|
||||||
import GlassModal from "@/components/admin/GlassModal";
|
import ProductFormModal, { type ProductFormValues } from "@/components/admin/ProductFormModal";
|
||||||
import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, useToast, Skeleton } from "@/components/admin/design-system";
|
import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, useToast, Skeleton } from "@/components/admin/design-system";
|
||||||
|
|
||||||
type Product = {
|
type Product = {
|
||||||
@@ -20,16 +21,8 @@ type Product = {
|
|||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
brand_id: string;
|
brand_id: string;
|
||||||
is_taxable: boolean;
|
is_taxable: boolean;
|
||||||
};
|
available_from?: string | null;
|
||||||
|
available_until?: string | null;
|
||||||
type FormData = {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
price: string;
|
|
||||||
type: string;
|
|
||||||
active: boolean;
|
|
||||||
image_url: string;
|
|
||||||
is_taxable: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewMode = "table" | "cards";
|
type ViewMode = "table" | "cards";
|
||||||
@@ -74,7 +67,17 @@ const PackageIconHeader = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function ProductsClient({ products, brandId }: { products: Product[]; brandId?: string }) {
|
export default function ProductsClient({
|
||||||
|
products,
|
||||||
|
brandId,
|
||||||
|
brands = [],
|
||||||
|
isPlatformAdmin = false,
|
||||||
|
}: {
|
||||||
|
products: Product[];
|
||||||
|
brandId?: string;
|
||||||
|
brands?: { id: string; name: string }[];
|
||||||
|
isPlatformAdmin?: boolean;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { success: showSuccess, error: showError } = useToast();
|
const { success: showSuccess, error: showError } = useToast();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
@@ -83,28 +86,10 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
|||||||
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||||
const [formData, setFormData] = useState<FormData>({
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
price: "",
|
|
||||||
type: "pickup",
|
|
||||||
active: true,
|
|
||||||
image_url: "",
|
|
||||||
is_taxable: true,
|
|
||||||
});
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Image upload states
|
|
||||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
||||||
const [pendingImageUrl, setPendingImageUrl] = useState<string | null>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const filtered = products.filter((p) => {
|
const filtered = products.filter((p) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
!search ||
|
!search ||
|
||||||
@@ -147,155 +132,104 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileSelect(file: File) {
|
const handleUploadImage = useCallback(
|
||||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
async (file: File): Promise<{ success: boolean; imageUrl?: string; error?: string }> => {
|
||||||
if (!validTypes.includes(file.type)) {
|
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||||
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
|
if (!validTypes.includes(file.type)) {
|
||||||
return;
|
return { success: false, error: "Only PNG, JPEG, and WebP images are allowed." };
|
||||||
}
|
}
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
setUploadError("Image must be under 5MB.");
|
return { success: false, error: "Image must be under 5MB." };
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setUploadError(null);
|
const resizedBuffer = await resizeImage(file, 1200);
|
||||||
setUploading(true);
|
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
||||||
|
|
||||||
const resizedBuffer = await resizeImage(file, 1200);
|
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
|
||||||
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
const result = await uploadProductImage(productIdForUpload, resizedFile);
|
||||||
|
if (result.success) {
|
||||||
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
|
return { success: true, imageUrl: result.imageUrl };
|
||||||
const result = await uploadProductImage(productIdForUpload, resizedFile);
|
}
|
||||||
|
return { success: false, error: result.error };
|
||||||
setUploading(false);
|
},
|
||||||
|
[editingProduct]
|
||||||
if (result.success) {
|
);
|
||||||
setPendingImageUrl(result.imageUrl);
|
|
||||||
setImagePreview(result.imageUrl);
|
|
||||||
setFormData({ ...formData, image_url: result.imageUrl });
|
|
||||||
} else {
|
|
||||||
setUploadError(result.error ?? "Upload failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openAddModal = useCallback(() => {
|
const openAddModal = useCallback(() => {
|
||||||
setEditingProduct(null);
|
setEditingProduct(null);
|
||||||
setFormData({
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
price: "",
|
|
||||||
type: "pickup",
|
|
||||||
active: true,
|
|
||||||
image_url: "",
|
|
||||||
is_taxable: true,
|
|
||||||
});
|
|
||||||
setImagePreview(null);
|
|
||||||
setPendingImageUrl(null);
|
|
||||||
setUploadError(null);
|
|
||||||
setError(null);
|
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openEditModal = useCallback((product: Product) => {
|
const openEditModal = useCallback((product: Product) => {
|
||||||
setEditingProduct(product);
|
setEditingProduct(product);
|
||||||
setFormData({
|
|
||||||
name: product.name,
|
|
||||||
description: product.description || "",
|
|
||||||
price: String(product.price),
|
|
||||||
type: product.type,
|
|
||||||
active: product.active,
|
|
||||||
image_url: product.image_url || "",
|
|
||||||
is_taxable: product.is_taxable,
|
|
||||||
});
|
|
||||||
setImagePreview(product.image_url || null);
|
|
||||||
setPendingImageUrl(null);
|
|
||||||
setUploadError(null);
|
|
||||||
setError(null);
|
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingProduct(null);
|
setEditingProduct(null);
|
||||||
setImagePreview(null);
|
|
||||||
setPendingImageUrl(null);
|
|
||||||
setUploadError(null);
|
|
||||||
setError(null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleModalSubmit = useCallback(
|
||||||
e.preventDefault();
|
async (values: ProductFormValues): Promise<{ success: boolean; error?: string }> => {
|
||||||
setError(null);
|
setIsLoading(true);
|
||||||
setSaving(true);
|
try {
|
||||||
setIsLoading(true);
|
const price = parseFloat(values.price);
|
||||||
|
if (isNaN(price) || price < 0) {
|
||||||
|
return { success: false, error: "Please enter a valid price" };
|
||||||
|
}
|
||||||
|
if (!values.name.trim()) {
|
||||||
|
return { success: false, error: "Product name is required" };
|
||||||
|
}
|
||||||
|
|
||||||
const price = parseFloat(formData.price);
|
// Platform admins pick a brand in the modal; everyone else is locked.
|
||||||
if (isNaN(price) || price < 0) {
|
const effectiveBrandId = isPlatformAdmin ? values.brand_id : brandId;
|
||||||
setError("Please enter a valid price");
|
if (!effectiveBrandId) {
|
||||||
setSaving(false);
|
return { success: false, error: "Please choose a brand for this product." };
|
||||||
setIsLoading(false);
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.name.trim()) {
|
let result;
|
||||||
setError("Product name is required");
|
if (editingProduct) {
|
||||||
setSaving(false);
|
result = await updateProduct(editingProduct.id, effectiveBrandId, {
|
||||||
setIsLoading(false);
|
name: values.name.trim(),
|
||||||
return;
|
description: values.description.trim(),
|
||||||
}
|
price,
|
||||||
|
type: values.type,
|
||||||
|
active: values.active,
|
||||||
|
image_url: values.image_url,
|
||||||
|
is_taxable: values.is_taxable,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = await createProduct(effectiveBrandId, {
|
||||||
|
name: values.name.trim(),
|
||||||
|
description: values.description.trim(),
|
||||||
|
price,
|
||||||
|
type: values.type,
|
||||||
|
active: values.active,
|
||||||
|
image_url: values.image_url,
|
||||||
|
is_taxable: values.is_taxable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
if (!result.success) {
|
||||||
if (!brandId) {
|
showError("Failed to save product", result.error ?? "Please try again");
|
||||||
setError("Brand ID is required");
|
return { success: false, error: result.error };
|
||||||
setSaving(false);
|
}
|
||||||
|
|
||||||
|
showSuccess(
|
||||||
|
editingProduct ? "Product updated" : "Product created",
|
||||||
|
`${values.name} has been saved`
|
||||||
|
);
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Network error. Please try again." };
|
||||||
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
let result;
|
[editingProduct, brandId, isPlatformAdmin, router, showError, showSuccess]
|
||||||
const imageUrl = pendingImageUrl || formData.image_url || null;
|
);
|
||||||
|
|
||||||
if (editingProduct) {
|
|
||||||
result = await updateProduct(editingProduct.id, brandId, {
|
|
||||||
name: formData.name.trim(),
|
|
||||||
description: formData.description.trim(),
|
|
||||||
price,
|
|
||||||
type: formData.type,
|
|
||||||
active: formData.active,
|
|
||||||
image_url: imageUrl,
|
|
||||||
is_taxable: formData.is_taxable,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
result = await createProduct(brandId, {
|
|
||||||
name: formData.name.trim(),
|
|
||||||
description: formData.description.trim(),
|
|
||||||
price,
|
|
||||||
type: formData.type,
|
|
||||||
active: formData.active,
|
|
||||||
image_url: imageUrl,
|
|
||||||
is_taxable: formData.is_taxable,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error ?? "Failed to save product");
|
|
||||||
showError("Failed to save product", result.error ?? "Please try again");
|
|
||||||
setSaving(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showSuccess(editingProduct ? "Product updated" : "Product created", `${formData.name} has been saved`);
|
|
||||||
closeModal();
|
|
||||||
setIsLoading(false);
|
|
||||||
startTransition(() => router.refresh());
|
|
||||||
} catch {
|
|
||||||
setError("Network error. Please try again.");
|
|
||||||
showError("Network error", "Please check your connection and try again");
|
|
||||||
setSaving(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (productId: string) => {
|
const handleDelete = async (productId: string) => {
|
||||||
setDeletingId(productId);
|
setDeletingId(productId);
|
||||||
@@ -399,203 +333,38 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal — Atelier des Récoltes (editorial product form) */}
|
||||||
{showModal && (
|
<ProductFormModal
|
||||||
<GlassModal
|
open={showModal}
|
||||||
title={editingProduct ? "Edit Product" : "Add Product"}
|
mode={editingProduct ? "edit" : "add"}
|
||||||
subtitle={editingProduct ? `Editing ${editingProduct.name}` : "Create a new product"}
|
onClose={closeModal}
|
||||||
onClose={closeModal}
|
onSubmit={handleModalSubmit}
|
||||||
>
|
onUploadImage={handleUploadImage}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
brands={brands}
|
||||||
{error && (
|
lockBrand={!isPlatformAdmin}
|
||||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600 flex items-start gap-3">
|
lockedBrandId={brandId ?? ""}
|
||||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
initial={
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
editingProduct
|
||||||
</svg>
|
? {
|
||||||
{error}
|
name: editingProduct.name,
|
||||||
</div>
|
description: editingProduct.description || "",
|
||||||
)}
|
price: String(editingProduct.price),
|
||||||
|
type: (editingProduct.type as "pickup" | "wholesale" | "subscription") ?? "pickup",
|
||||||
<div>
|
brand_id: editingProduct.brand_id,
|
||||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
active: editingProduct.active,
|
||||||
Name <span className="text-red-500">*</span>
|
is_taxable: editingProduct.is_taxable,
|
||||||
</label>
|
available_from: editingProduct.available_from ?? null,
|
||||||
<input
|
available_until: editingProduct.available_until ?? null,
|
||||||
type="text"
|
}
|
||||||
value={formData.name}
|
: undefined
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
}
|
||||||
placeholder="Product name"
|
initialImageUrl={editingProduct?.image_url ?? null}
|
||||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] transition-colors ${
|
/>
|
||||||
error && !formData.name.trim()
|
|
||||||
? "border-red-400 bg-red-50"
|
|
||||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
|
||||||
}`}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Description</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
placeholder="Product description"
|
|
||||||
rows={2}
|
|
||||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
|
||||||
Price <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={formData.price}
|
|
||||||
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
|
|
||||||
placeholder="0.00"
|
|
||||||
className={`w-full rounded-xl border pl-8 pr-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
|
||||||
error && (!formData.price || parseFloat(formData.price) < 0)
|
|
||||||
? "border-red-400 bg-red-50"
|
|
||||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
|
||||||
}`}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Type</label>
|
|
||||||
<select
|
|
||||||
value={formData.type}
|
|
||||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
|
||||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
|
||||||
>
|
|
||||||
<option value="pickup">Pickup</option>
|
|
||||||
<option value="wholesale">Wholesale</option>
|
|
||||||
<option value="subscription">Subscription</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Image Upload */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Product Image</label>
|
|
||||||
<p className="text-[10px] text-stone-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
onDragOver={(e) => e.preventDefault()}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const file = e.dataTransfer.files[0];
|
|
||||||
if (file) handleFileSelect(file);
|
|
||||||
}}
|
|
||||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
|
||||||
className={`
|
|
||||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
|
||||||
${uploadError ? "border-red-400" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)] hover:bg-[var(--admin-accent)]/5"}
|
|
||||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{uploading ? (
|
|
||||||
<>
|
|
||||||
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
|
|
||||||
<span className="text-sm text-stone-500">Uploading...</span>
|
|
||||||
</>
|
|
||||||
) : imagePreview ? (
|
|
||||||
<div className="relative">
|
|
||||||
<div className="relative h-32 sm:h-40 w-auto">
|
|
||||||
<Image src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<span className="block text-center text-xs text-stone-500 mt-2">Click or drop to replace</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{Icons.upload("h-8 w-8 text-stone-400")}
|
|
||||||
<span className="text-sm text-stone-500">Drag & drop or click to upload</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/webp"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) handleFileSelect(file);
|
|
||||||
e.target.value = "";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{uploadError && (
|
|
||||||
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(imagePreview || formData.image_url) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setImagePreview(null);
|
|
||||||
setPendingImageUrl(null);
|
|
||||||
setFormData({ ...formData, image_url: "" });
|
|
||||||
}}
|
|
||||||
className="mt-2 text-xs text-red-500 hover:underline"
|
|
||||||
>
|
|
||||||
Remove image
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.active}
|
|
||||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
|
||||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-stone-700">Active</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.is_taxable}
|
|
||||||
onChange={(e) => setFormData({ ...formData, is_taxable: e.target.checked })}
|
|
||||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-stone-700">Taxable</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-stone-100">
|
|
||||||
<AdminButton
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={closeModal}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</AdminButton>
|
|
||||||
<AdminButton
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
isLoading={saving}
|
|
||||||
disabled={!formData.name.trim() || !formData.price}
|
|
||||||
>
|
|
||||||
{editingProduct ? "Save Changes" : "Create Product"}
|
|
||||||
</AdminButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</GlassModal>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Table View ─────────────────────────────────────────────────────────────────
|
// ── Table View ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TableView({
|
function TableView({
|
||||||
@@ -615,8 +384,51 @@ function TableView({
|
|||||||
onDeleteCancel: () => void;
|
onDeleteCancel: () => void;
|
||||||
deletingId: string | null;
|
deletingId: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
// Track the position of the open row's three-dots button so the popup can be
|
||||||
|
// rendered via portal at body level (escapes any overflow:hidden ancestors
|
||||||
|
// and any table-row stacking-context quirks).
|
||||||
|
const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deleteConfirm) {
|
||||||
|
setMenuPos(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = buttonRefs.current[deleteConfirm];
|
||||||
|
if (btn) {
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
|
||||||
|
}
|
||||||
|
}, [deleteConfirm]);
|
||||||
|
|
||||||
|
// Reposition on scroll/resize so the popup stays anchored to its button.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deleteConfirm) return;
|
||||||
|
const updatePos = () => {
|
||||||
|
const btn = buttonRefs.current[deleteConfirm];
|
||||||
|
if (btn) {
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", updatePos, true);
|
||||||
|
window.addEventListener("resize", updatePos);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", updatePos, true);
|
||||||
|
window.removeEventListener("resize", updatePos);
|
||||||
|
};
|
||||||
|
}, [deleteConfirm]);
|
||||||
|
|
||||||
|
const openProduct = deleteConfirm ? products.find((p) => p.id === deleteConfirm) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-white">
|
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-white">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-stone-50">
|
<thead className="bg-stone-50">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -700,44 +512,15 @@ function TableView({
|
|||||||
Edit
|
Edit
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
<button
|
<button
|
||||||
|
ref={(el) => { buttonRefs.current[product.id] = el; }}
|
||||||
onClick={() => onDelete(product.id)}
|
onClick={() => onDelete(product.id)}
|
||||||
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
|
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
aria-label="Product actions"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{deleteConfirm === product.id && (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 z-30" onClick={onDeleteCancel} />
|
|
||||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
|
||||||
<p className="text-sm font-semibold text-stone-900">
|
|
||||||
Delete "{product.name}"?
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-stone-500">
|
|
||||||
This will remove the product. If attached to orders, it will be hidden.
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onDeleteCancel}
|
|
||||||
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<AdminButton
|
|
||||||
onClick={() => onDeleteConfirm(product.id)}
|
|
||||||
disabled={deletingId === product.id}
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{deletingId === product.id ? "..." : "Delete"}
|
|
||||||
</AdminButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -745,6 +528,50 @@ function TableView({
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{/* Delete confirmation popup — rendered via portal at body level with
|
||||||
|
position: fixed so it sits above all table rows and escapes the
|
||||||
|
overflow-visible container. */}
|
||||||
|
{mounted && deleteConfirm && openProduct && menuPos &&
|
||||||
|
createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60]"
|
||||||
|
onClick={onDeleteCancel}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Confirm delete"
|
||||||
|
className="fixed z-[70] w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4"
|
||||||
|
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold text-stone-900">
|
||||||
|
Delete "{openProduct.name}"?
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-stone-500">
|
||||||
|
This will remove the product. If attached to orders, it will be hidden.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onDeleteCancel}
|
||||||
|
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<AdminButton
|
||||||
|
onClick={() => onDeleteConfirm(openProduct.id)}
|
||||||
|
disabled={deletingId === openProduct.id}
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{deletingId === openProduct.id ? "..." : "Delete"}
|
||||||
|
</AdminButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
type Stat = {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
emphasis?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
stats: Stat[];
|
||||||
|
/** Right-aligned slot (e.g. an inline "Next stop" pill or refresh action) */
|
||||||
|
right?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatsStrip({ stats, right }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<span key={i} className="flex items-baseline gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`font-bold tabular-nums ${s.emphasis ? "text-emerald-700" : "text-[var(--admin-text-primary)]"}`}
|
||||||
|
>
|
||||||
|
{s.value}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--admin-text-muted)]">{s.label}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{right && <div className="ml-auto">{right}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"use client";
|
||||||
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
|
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import GlassModal from "@/components/admin/GlassModal";
|
||||||
|
import StopEditForm from "@/components/admin/StopEditForm";
|
||||||
|
import StopProductAssignment from "@/components/admin/StopProductAssignment";
|
||||||
|
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
|
||||||
|
import { getStopDetails } from "@/actions/stops/get-stop-details";
|
||||||
|
import { useToast } from "@/components/admin/design-system";
|
||||||
|
import type { StopDetail, AssignedProduct } from "@/actions/stops/get-stop-details";
|
||||||
|
|
||||||
|
type Tab = "details" | "products" | "message";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
stopId: string;
|
||||||
|
/** Optional: when the user clicks Duplicate from inside the modal. */
|
||||||
|
onDuplicate?: (stopId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { success: showSuccess } = useToast();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [stop, setStop] = useState<StopDetail | null>(null);
|
||||||
|
const [allProducts, setAllProducts] = useState<{ id: string; name: string; type: string; price: number }[]>([]);
|
||||||
|
const [assignedProducts, setAssignedProducts] = useState<AssignedProduct[]>([]);
|
||||||
|
const [brands, setBrands] = useState<{ id: string; name: string; slug: string }[]>([]);
|
||||||
|
const [callerUid, setCallerUid] = useState<string>("");
|
||||||
|
const [tab, setTab] = useState<Tab>("details");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
getStopDetails(stopId)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!res.success) {
|
||||||
|
setLoadError(res.error);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStop(res.stop);
|
||||||
|
setAllProducts(res.allProducts);
|
||||||
|
setAssignedProducts(res.assignedProducts);
|
||||||
|
setBrands(res.brands);
|
||||||
|
setCallerUid(res.callerUid);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoadError(err?.message ?? "Failed to load stop");
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [stopId]);
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
getStopDetails(stopId).then((res) => {
|
||||||
|
if (!res.success) return;
|
||||||
|
setStop(res.stop);
|
||||||
|
setAllProducts(res.allProducts);
|
||||||
|
setAssignedProducts(res.assignedProducts);
|
||||||
|
setBrands(res.brands);
|
||||||
|
setCallerUid(res.callerUid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditSaved() {
|
||||||
|
refresh();
|
||||||
|
showSuccess("Stop updated", "Changes have been saved");
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitle = stop?.brands
|
||||||
|
? (Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands.name) ?? undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassModal
|
||||||
|
title={loading ? "Loading…" : stop ? `${stop.city}, ${stop.state}` : "Stop"}
|
||||||
|
subtitle={subtitle ?? "Stop details"}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="max-w-3xl"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-4 w-1/3 animate-pulse rounded bg-stone-200" />
|
||||||
|
<div className="h-4 w-2/3 animate-pulse rounded bg-stone-200" />
|
||||||
|
<div className="h-4 w-1/2 animate-pulse rounded bg-stone-200" />
|
||||||
|
</div>
|
||||||
|
) : loadError ? (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
) : stop ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 rounded-xl border border-[var(--admin-border)] bg-stone-50 p-1"
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
|
<TabButton active={tab === "details"} onClick={() => setTab("details")}>
|
||||||
|
Details
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={tab === "products"} onClick={() => setTab("products")}>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
Products
|
||||||
|
{assignedProducts.length > 0 && (
|
||||||
|
<span className="rounded-full bg-[var(--admin-accent)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--admin-accent)]">
|
||||||
|
{assignedProducts.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={tab === "message"} onClick={() => setTab("message")}>
|
||||||
|
Message
|
||||||
|
</TabButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "details" && (
|
||||||
|
<DetailsPanel
|
||||||
|
stop={stop}
|
||||||
|
brands={brands}
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onSaved={handleEditSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "products" && (
|
||||||
|
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||||
|
Assigned Products
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||||
|
Manage which products are available at this stop.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<StopProductAssignment
|
||||||
|
stopId={stop.id}
|
||||||
|
allProducts={allProducts}
|
||||||
|
assignedProducts={assignedProducts}
|
||||||
|
callerUid={callerUid}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "message" && (
|
||||||
|
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||||
|
Message Customers
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||||
|
Send updates to customers with pending pickups at this stop.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</GlassModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all ${
|
||||||
|
active
|
||||||
|
? "bg-white text-[var(--admin-accent)] shadow-sm border border-[var(--admin-border)]"
|
||||||
|
: "text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailsPanel({
|
||||||
|
stop,
|
||||||
|
brands,
|
||||||
|
onDuplicate,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
stop: StopDetail;
|
||||||
|
brands: { id: string; name: string; slug: string }[];
|
||||||
|
onDuplicate?: (stopId: string) => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">
|
||||||
|
{stop.city}, {stop.state}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">{stop.location}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${
|
||||||
|
stop.active
|
||||||
|
? "bg-emerald-100 text-emerald-700"
|
||||||
|
: "bg-stone-200 text-stone-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{stop.active ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Date</dt>
|
||||||
|
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.date}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Time</dt>
|
||||||
|
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.time}</dd>
|
||||||
|
</div>
|
||||||
|
{stop.address && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Address</dt>
|
||||||
|
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
|
||||||
|
{stop.address}
|
||||||
|
{stop.zip ? `, ${stop.zip}` : ""}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stop.cutoff_time && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Cutoff</dt>
|
||||||
|
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
|
||||||
|
{new Date(stop.cutoff_time).toLocaleString()}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{onDuplicate ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDuplicate(stop.id)}
|
||||||
|
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
|
||||||
|
>
|
||||||
|
Duplicate Stop
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||||
|
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
|
||||||
|
>
|
||||||
|
Duplicate Stop
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Edit Stop</h3>
|
||||||
|
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||||
|
Update stop details, location, and availability.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<StopEditForm
|
||||||
|
stop={{
|
||||||
|
id: stop.id,
|
||||||
|
city: stop.city,
|
||||||
|
state: stop.state,
|
||||||
|
date: stop.date,
|
||||||
|
time: stop.time,
|
||||||
|
location: stop.location,
|
||||||
|
slug: stop.slug,
|
||||||
|
active: stop.active,
|
||||||
|
brand_id: stop.brand_id,
|
||||||
|
address: stop.address,
|
||||||
|
zip: stop.zip,
|
||||||
|
cutoff_time: stop.cutoff_time,
|
||||||
|
}}
|
||||||
|
brands={brands}
|
||||||
|
onSaved={onSaved}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,9 +27,11 @@ type StopEditFormProps = {
|
|||||||
cutoff_time?: string | null;
|
cutoff_time?: string | null;
|
||||||
};
|
};
|
||||||
brands: Brand[];
|
brands: Brand[];
|
||||||
|
/** Optional callback fired after a successful save. */
|
||||||
|
onSaved?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StopEditForm({ stop, brands }: StopEditFormProps) {
|
export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { success: showSuccess, error: showError } = useToast();
|
const { success: showSuccess, error: showError } = useToast();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -99,6 +101,7 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) {
|
|||||||
showSuccess("Stop updated", `${city}, ${state} has been saved`);
|
showSuccess("Stop updated", `${city}, ${state} has been saved`);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
onSaved?.();
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import { logAuditEvent } from "@/actions/audit";
|
import { logAuditEvent } from "@/actions/audit";
|
||||||
|
|
||||||
type Product = {
|
type Product = {
|
||||||
@@ -8,12 +9,13 @@ type Product = {
|
|||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
image_url?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AssignedProduct = {
|
type AssignedProduct = {
|
||||||
id: string;
|
id: string;
|
||||||
product_id: string;
|
product_id: string;
|
||||||
products: { name: string; type: string; price: number } | null;
|
products: { name: string; type: string; price: number; image_url?: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StopProductAssignmentProps = {
|
type StopProductAssignmentProps = {
|
||||||
@@ -23,6 +25,20 @@ type StopProductAssignmentProps = {
|
|||||||
callerUid: string;
|
callerUid: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Filter = "all" | "available" | "assigned";
|
||||||
|
|
||||||
|
/* Helpers — monospace price + initial-letter avatar */
|
||||||
|
const formatPrice = (n: number) =>
|
||||||
|
`$${Number(n ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
|
||||||
|
const initialOf = (name: string) => (name?.trim()?.charAt(0) ?? "·").toUpperCase();
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
pickup: "Pickup",
|
||||||
|
wholesale: "Wholesale",
|
||||||
|
subscription: "Sub",
|
||||||
|
};
|
||||||
|
|
||||||
export default function StopProductAssignment({
|
export default function StopProductAssignment({
|
||||||
stopId,
|
stopId,
|
||||||
allProducts,
|
allProducts,
|
||||||
@@ -30,208 +46,397 @@ export default function StopProductAssignment({
|
|||||||
callerUid,
|
callerUid,
|
||||||
}: StopProductAssignmentProps) {
|
}: StopProductAssignmentProps) {
|
||||||
const [products, setProducts] = useState(assignedProducts);
|
const [products, setProducts] = useState(assignedProducts);
|
||||||
const [selected, setSelected] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [assigning, setAssigning] = useState(false);
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
const [removing, setRemoving] = useState<string | null>(null);
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||||
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const assignedIds = new Set(products.map((p) => p.product_id));
|
const assignedIds = useMemo(
|
||||||
const availableProducts = allProducts.filter((p) => !assignedIds.has(p.id));
|
() => new Set(products.map((p) => p.product_id)),
|
||||||
|
[products]
|
||||||
|
);
|
||||||
|
|
||||||
async function handleAssign(e: React.FormEvent) {
|
// Counters for the filter chips
|
||||||
e.preventDefault();
|
const counts = useMemo(() => {
|
||||||
if (!selected) return;
|
const assignedCount = products.length;
|
||||||
|
const availableCount = allProducts.filter((p) => !assignedIds.has(p.id)).length;
|
||||||
|
return {
|
||||||
|
all: allProducts.length,
|
||||||
|
available: availableCount,
|
||||||
|
assigned: assignedCount,
|
||||||
|
};
|
||||||
|
}, [allProducts, products, assignedIds]);
|
||||||
|
|
||||||
setAssigning(true);
|
// Apply search + filter
|
||||||
|
const visibleProducts = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return allProducts.filter((p) => {
|
||||||
|
const isAssigned = assignedIds.has(p.id);
|
||||||
|
if (filter === "available" && isAssigned) return false;
|
||||||
|
if (filter === "assigned" && !isAssigned) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return p.name.toLowerCase().includes(q) || p.type.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}, [allProducts, search, filter, assignedIds]);
|
||||||
|
|
||||||
|
async function assign(productId: string) {
|
||||||
|
if (!productId || assignedIds.has(productId) || pendingId) return;
|
||||||
|
|
||||||
|
const product = allProducts.find((p) => p.id === productId);
|
||||||
|
if (!product) return;
|
||||||
|
|
||||||
|
setPendingId(productId);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// First run diagnostic to understand the actual state
|
try {
|
||||||
const diagRes = await fetch(
|
const res = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/debug_stop_product_assignment`,
|
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_stop_id: stopId,
|
p_stop_id: stopId,
|
||||||
p_product_id: selected,
|
p_product_id: productId,
|
||||||
p_caller_uid: callerUid,
|
p_caller_uid: callerUid,
|
||||||
}),
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.success === false) {
|
||||||
|
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
||||||
|
const errDetail = data?.details ?? data?.hint ?? data?.code ?? "";
|
||||||
|
setError(`Failed to assign: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
|
||||||
|
setPendingId(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
const diag = await diagRes.json();
|
|
||||||
|
|
||||||
// Now attempt the actual assignment
|
// Optimistic insert: build the entry from the product we already have
|
||||||
const res = await fetch(
|
// locally, keyed by the row id returned from the RPC. Use functional
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
|
// setState so concurrent calls compose correctly.
|
||||||
{
|
setProducts((prev) => {
|
||||||
method: "POST",
|
if (prev.some((p) => p.product_id === productId)) return prev;
|
||||||
headers: {
|
return [
|
||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
...prev,
|
||||||
"Content-Type": "application/json",
|
{
|
||||||
},
|
id: data.id,
|
||||||
body: JSON.stringify({
|
product_id: productId,
|
||||||
p_stop_id: stopId,
|
products: {
|
||||||
p_product_id: selected,
|
name: product.name,
|
||||||
p_caller_uid: callerUid,
|
type: product.type,
|
||||||
}),
|
price: product.price,
|
||||||
}
|
image_url: product.image_url ?? null,
|
||||||
);
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
setPendingId(null);
|
||||||
|
|
||||||
const data = await res.json();
|
logAuditEvent({
|
||||||
|
table_name: "product_stops",
|
||||||
if (!res.ok || data.success === false) {
|
record_id: data.id,
|
||||||
const diagInfo = diag && typeof diag === 'object' && 'reason' in diag
|
action: "INSERT",
|
||||||
? `[${(diag as any).reason}] (admin_found=${(diag as any).admin_found}, role=${(diag as any).admin_role}, admin_brand=${(diag as any).admin_brand_id}, stop_brand=${(diag as any).stop_brand_id}, product_brand=${(diag as any).product_brand_id})`
|
old_data: null,
|
||||||
: '';
|
new_data: { stop_id: stopId, product_id: productId },
|
||||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
brand_id: null,
|
||||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
|
});
|
||||||
const fullError = diagInfo
|
} catch {
|
||||||
? `Failed to assign: ${errMsg} ${diagInfo}${errDetail ? ' | ' + errDetail : ''}`
|
setError("Network error while assigning product.");
|
||||||
: `Failed to assign: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`;
|
setPendingId(null);
|
||||||
setError(fullError);
|
|
||||||
setAssigning(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh product list from get_stop_products RPC
|
|
||||||
const refreshRes = await fetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_stop_products`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ p_stop_id: stopId }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const refreshData = await refreshRes.json();
|
|
||||||
setProducts(refreshData.products ?? []);
|
|
||||||
setSelected("");
|
|
||||||
setAssigning(false);
|
|
||||||
|
|
||||||
logAuditEvent({
|
|
||||||
table_name: "product_stops",
|
|
||||||
record_id: data.id,
|
|
||||||
action: "INSERT",
|
|
||||||
old_data: null,
|
|
||||||
new_data: { stop_id: stopId, product_id: selected },
|
|
||||||
brand_id: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemove(productId: string) {
|
async function remove(productId: string) {
|
||||||
|
if (removingId) return;
|
||||||
const entry = products.find((p) => p.product_id === productId);
|
const entry = products.find((p) => p.product_id === productId);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|
||||||
setRemoving(productId);
|
setRemovingId(productId);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await fetch(
|
try {
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
|
const res = await fetch(
|
||||||
{
|
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
|
||||||
method: "POST",
|
{
|
||||||
headers: {
|
method: "POST",
|
||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
headers: {
|
||||||
"Content-Type": "application/json",
|
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
body: JSON.stringify({
|
},
|
||||||
p_stop_id: stopId,
|
body: JSON.stringify({
|
||||||
p_product_id: productId,
|
p_stop_id: stopId,
|
||||||
p_caller_uid: callerUid,
|
p_product_id: productId,
|
||||||
}),
|
p_caller_uid: callerUid,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.success === false) {
|
||||||
|
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
||||||
|
const errDetail = data?.details ?? data?.hint ?? data?.code ?? "";
|
||||||
|
setError(`Failed to remove: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
|
||||||
|
setRemovingId(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
const data = await res.json();
|
setProducts((prev) => prev.filter((p) => p.product_id !== productId));
|
||||||
|
setRemovingId(null);
|
||||||
|
|
||||||
if (!res.ok || data.success === false) {
|
logAuditEvent({
|
||||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
table_name: "product_stops",
|
||||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
|
record_id: entry.id,
|
||||||
setError(`Failed to remove: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`);
|
action: "DELETE",
|
||||||
setRemoving(null);
|
old_data: { stop_id: stopId, product_id: productId },
|
||||||
return;
|
new_data: null,
|
||||||
|
brand_id: null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setError("Network error while removing product.");
|
||||||
|
setRemovingId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setProducts(products.filter((p) => p.product_id !== productId));
|
|
||||||
setRemoving(null);
|
|
||||||
|
|
||||||
logAuditEvent({
|
|
||||||
table_name: "product_stops",
|
|
||||||
record_id: entry.id,
|
|
||||||
action: "DELETE",
|
|
||||||
old_data: { stop_id: stopId, product_id: productId },
|
|
||||||
new_data: null,
|
|
||||||
brand_id: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggle(productId: string) {
|
||||||
|
if (assignedIds.has(productId)) {
|
||||||
|
remove(productId);
|
||||||
|
} else {
|
||||||
|
assign(productId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
if (!products.length || removingId || pendingId) return;
|
||||||
|
// Snapshot ids at click time, then remove each one sequentially so the
|
||||||
|
// server sees one request at a time.
|
||||||
|
const idsToRemove = products.map((p) => p.product_id);
|
||||||
|
(async () => {
|
||||||
|
for (const id of idsToRemove) {
|
||||||
|
// Stop if a per-item failure already surfaced an error
|
||||||
|
if (error) break;
|
||||||
|
await remove(id);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard: pressing / focuses search
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = document.getElementById("stop-product-search") as HTMLInputElement | null;
|
||||||
|
el?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasAssigned = products.length > 0;
|
||||||
|
const hasProducts = allProducts.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="ha-picker">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
<div
|
||||||
{error}
|
role="alert"
|
||||||
|
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||||
|
style={{
|
||||||
|
background: "rgba(220, 38, 38, 0.06)",
|
||||||
|
border: "1px solid rgba(220, 38, 38, 0.15)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-mono text-[11px]">{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{products.length > 0 ? (
|
{/* Header: editorial title + search */}
|
||||||
<div className="space-y-2">
|
<div className="ha-picker-header">
|
||||||
<p className="text-sm font-medium text-zinc-300">
|
<div className="ha-picker-title">
|
||||||
Assigned Products ({products.length})
|
<span className="ha-picker-title-num font-display">
|
||||||
|
{String(products.length).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className="ha-picker-title-label">
|
||||||
|
{products.length === 1 ? "Product on this stop" : "Products on this stop"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ha-picker-search">
|
||||||
|
<span className="ha-picker-search-icon" aria-hidden="true">
|
||||||
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id="stop-product-search"
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search products…"
|
||||||
|
className="ha-picker-search-input"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter chips */}
|
||||||
|
<div className="ha-picker-filterbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter("all")}
|
||||||
|
className={`ha-picker-chip ${filter === "all" ? "ha-picker-chip--active" : ""}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
<span className="ha-picker-chip-count">{counts.all}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter("available")}
|
||||||
|
className={`ha-picker-chip ${filter === "available" ? "ha-picker-chip--active" : ""}`}
|
||||||
|
>
|
||||||
|
Available
|
||||||
|
<span className="ha-picker-chip-count">{counts.available}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter("assigned")}
|
||||||
|
className={`ha-picker-chip ${filter === "assigned" ? "ha-picker-chip--active" : ""}`}
|
||||||
|
>
|
||||||
|
On this stop
|
||||||
|
<span className="ha-picker-chip-count">{counts.assigned}</span>
|
||||||
|
</button>
|
||||||
|
<span className="ha-modal-footer-hint ml-auto">
|
||||||
|
Press <kbd>/</kbd> to search
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product grid */}
|
||||||
|
{!hasProducts ? (
|
||||||
|
<div className="ha-picker-empty">
|
||||||
|
<p className="ha-picker-empty-mark">No products yet</p>
|
||||||
|
<p className="ha-picker-empty-text">
|
||||||
|
Create products in the catalog first, then come back here to assign them to this stop.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : visibleProducts.length === 0 ? (
|
||||||
|
<div className="ha-picker-empty">
|
||||||
|
<p className="ha-picker-empty-mark">
|
||||||
|
{filter === "assigned" ? "Nothing assigned" : "No matches"}
|
||||||
|
</p>
|
||||||
|
<p className="ha-picker-empty-text">
|
||||||
|
{search.trim()
|
||||||
|
? `No products match "${search.trim()}". Try a different term.`
|
||||||
|
: filter === "assigned"
|
||||||
|
? "This stop has no products assigned yet. Click a card in the All tab to add one."
|
||||||
|
: "All products are already assigned to this stop."}
|
||||||
</p>
|
</p>
|
||||||
{products.map((ap) => (
|
|
||||||
<div
|
|
||||||
key={ap.id}
|
|
||||||
className="flex items-center justify-between rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-zinc-100">
|
|
||||||
{ap.products?.name ?? "Unknown"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
{ap.products?.type} ·{" "}
|
|
||||||
${Number(ap.products?.price ?? 0).toFixed(2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemove(ap.product_id)}
|
|
||||||
disabled={removing === ap.product_id}
|
|
||||||
className="shrink-0 rounded-lg px-3 py-1 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{removing === ap.product_id ? "..." : "Remove"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-zinc-500">No products assigned yet.</p>
|
<div className="ha-picker-grid">
|
||||||
|
{visibleProducts.map((product) => {
|
||||||
|
const isAssigned = assignedIds.has(product.id);
|
||||||
|
const isBusy = pendingId === product.id || removingId === product.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={product.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => !isBusy && toggle(product.id)}
|
||||||
|
disabled={isBusy}
|
||||||
|
aria-pressed={isAssigned}
|
||||||
|
className={`ha-product-card ${isAssigned ? "ha-product-card--assigned" : ""} ${
|
||||||
|
isBusy ? "ha-product-card--disabled" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Image / Initial */}
|
||||||
|
<span className="ha-product-card-image">
|
||||||
|
{product.image_url ? (
|
||||||
|
<Image
|
||||||
|
src={product.image_url}
|
||||||
|
alt={product.name}
|
||||||
|
fill
|
||||||
|
sizes="48px"
|
||||||
|
style={{ objectFit: "cover" }}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initialOf(product.name)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<span className="ha-product-card-body">
|
||||||
|
<span className="ha-product-card-name">{product.name}</span>
|
||||||
|
<span className="ha-product-card-meta">
|
||||||
|
<span className="ha-product-card-type">{TYPE_LABEL[product.type] ?? product.type}</span>
|
||||||
|
<span className="ha-product-card-price">{formatPrice(product.price)}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status icon */}
|
||||||
|
{isBusy ? (
|
||||||
|
<span className="ha-product-card-plus" aria-hidden="true">
|
||||||
|
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||||
|
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
) : isAssigned ? (
|
||||||
|
<>
|
||||||
|
<span className="ha-product-card-check" aria-hidden="true">
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3}>
|
||||||
|
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="ha-product-card-remove" aria-hidden="true">
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path d="M6 6l12 12M6 18 18 6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="ha-product-card-plus" aria-hidden="true">
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{availableProducts.length > 0 && (
|
{/* Summary footer when assigned items exist */}
|
||||||
<form onSubmit={handleAssign} className="flex gap-3">
|
{hasAssigned && (
|
||||||
<select
|
<div className="ha-picker-summary">
|
||||||
value={selected}
|
<span className="ha-picker-summary-left">
|
||||||
onChange={(e) => setSelected(e.target.value)}
|
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
>
|
</svg>
|
||||||
<option value="">Select a product...</option>
|
<span>
|
||||||
{availableProducts.map((p) => (
|
<span className="ha-picker-summary-count">{products.length}</span>{" "}
|
||||||
<option key={p.id} value={p.id}>
|
{products.length === 1 ? "product is" : "products are"} live at this stop
|
||||||
{p.name} ({p.type})
|
</span>
|
||||||
</option>
|
</span>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={!selected || assigning}
|
onClick={clearAll}
|
||||||
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
|
disabled={!!removingId}
|
||||||
|
className="ha-picker-summary-clear"
|
||||||
>
|
>
|
||||||
{assigning ? "..." : "Assign"}
|
Remove all
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
|
|
||||||
import React, { useState, useTransition, useEffect } from "react";
|
import React, { useState, useTransition, useEffect, useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { publishStop } from "@/actions/stops";
|
import { publishStop } from "@/actions/stops";
|
||||||
import { AdminSearchInput, AdminFilterTabs, AdminButton, AdminIconButton, useToast, Skeleton } from "@/components/admin/design-system";
|
import {
|
||||||
|
AdminSearchInput,
|
||||||
|
AdminFilterTabs,
|
||||||
|
AdminButton,
|
||||||
|
AdminIconButton,
|
||||||
|
useToast,
|
||||||
|
Skeleton,
|
||||||
|
} from "@/components/admin/design-system";
|
||||||
|
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
|
||||||
|
import AddStopModal from "@/components/admin/AddStopModal";
|
||||||
|
import StopDetailModal from "@/components/admin/StopDetailModal";
|
||||||
|
|
||||||
type Stop = {
|
type Stop = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,14 +28,27 @@ type Stop = {
|
|||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
brand_id: string;
|
brand_id: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
brands: { name: string } | { name: string }[];
|
address?: string | null;
|
||||||
|
brands: { name: string } | { name: string }[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
stops: Stop[];
|
stops: Stop[];
|
||||||
|
/**
|
||||||
|
* Hide the internal search/filter bar at the top of the table. Use when
|
||||||
|
* the parent component already renders shared filters (e.g. StopsViewClient).
|
||||||
|
*/
|
||||||
|
hideInternalFilterBar?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StopTableClient({ stops }: Props) {
|
const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = {
|
||||||
|
all: "all",
|
||||||
|
active: "active",
|
||||||
|
inactive: "inactive",
|
||||||
|
draft: "draft",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { success: showSuccess, error: showError } = useToast();
|
const { success: showSuccess, error: showError } = useToast();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
@@ -36,57 +59,66 @@ export default function StopTableClient({ stops }: Props) {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
|
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
|
||||||
const [bulkPublishing, setBulkPublishing] = useState(false);
|
const [bulkPublishing, setBulkPublishing] = useState(false);
|
||||||
|
const [showImport, setShowImport] = useState(false);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [openStopId, setOpenStopId] = useState<string | null>(null);
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
// Simulate loading when filters change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const timer = setTimeout(() => setIsLoading(false), 300);
|
const timer = setTimeout(() => setIsLoading(false), 220);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [page, statusFilter, search]);
|
}, [page, statusFilter, search]);
|
||||||
|
|
||||||
const filtered = stops.filter((s) => {
|
const filtered = useMemo(() => {
|
||||||
const matchesSearch =
|
const q = search.trim().toLowerCase();
|
||||||
!search ||
|
return stops.filter((s) => {
|
||||||
s.city.toLowerCase().includes(search.toLowerCase()) ||
|
const matchesSearch =
|
||||||
s.location.toLowerCase().includes(search.toLowerCase());
|
!q ||
|
||||||
const matchesStatus =
|
s.city.toLowerCase().includes(q) ||
|
||||||
statusFilter === "all" ||
|
s.location.toLowerCase().includes(q);
|
||||||
(statusFilter === "active" && s.active && s.status !== "draft") ||
|
const matchesStatus =
|
||||||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
|
statusFilter === "all" ||
|
||||||
(statusFilter === "draft" && s.status === "draft");
|
(statusFilter === "active" && s.active && s.status !== "draft") ||
|
||||||
return matchesSearch && matchesStatus;
|
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
|
||||||
});
|
(statusFilter === "draft" && s.status === "draft");
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
}, [stops, search, statusFilter]);
|
||||||
|
|
||||||
const statusCounts = {
|
const statusCounts = useMemo(
|
||||||
all: stops.length,
|
() => ({
|
||||||
active: stops.filter(s => s.active && s.status !== "draft").length,
|
all: stops.length,
|
||||||
inactive: stops.filter(s => !s.active && s.status !== "draft").length,
|
active: stops.filter((s) => s.active && s.status !== "draft").length,
|
||||||
draft: stops.filter(s => s.status === "draft").length,
|
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
|
||||||
};
|
draft: stops.filter((s) => s.status === "draft").length,
|
||||||
|
}),
|
||||||
|
[stops]
|
||||||
|
);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = useMemo(
|
||||||
{ value: "all", label: "All", count: statusCounts.all },
|
() => [
|
||||||
{ value: "active", label: "Active", count: statusCounts.active },
|
{ value: "all", label: "All", count: statusCounts.all },
|
||||||
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
|
{ value: "active", label: "Active", count: statusCounts.active },
|
||||||
{ value: "draft", label: "Draft", count: statusCounts.draft },
|
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
|
||||||
];
|
{ value: "draft", label: "Draft", count: statusCounts.draft },
|
||||||
|
],
|
||||||
|
[statusCounts]
|
||||||
|
);
|
||||||
|
|
||||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||||
const draftStops = stops.filter(s => s.status === "draft" && s.active);
|
|
||||||
|
|
||||||
// Bulk selection
|
|
||||||
const toggleSelectAll = () => {
|
const toggleSelectAll = () => {
|
||||||
if (selectedStops.size === paginatedStops.length) {
|
if (selectedStops.size === paginatedStops.length) {
|
||||||
setSelectedStops(new Set());
|
setSelectedStops(new Set());
|
||||||
} else {
|
} else {
|
||||||
setSelectedStops(new Set(paginatedStops.map(s => s.id)));
|
setSelectedStops(new Set(paginatedStops.map((s) => s.id)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleStopSelection = (stopId: string) => {
|
const toggleStopSelection = (stopId: string) => {
|
||||||
setSelectedStops(prev => {
|
setSelectedStops((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(stopId)) {
|
if (next.has(stopId)) {
|
||||||
next.delete(stopId);
|
next.delete(stopId);
|
||||||
@@ -99,13 +131,11 @@ export default function StopTableClient({ stops }: Props) {
|
|||||||
|
|
||||||
async function handleBulkPublish() {
|
async function handleBulkPublish() {
|
||||||
if (selectedStops.size === 0) return;
|
if (selectedStops.size === 0) return;
|
||||||
|
|
||||||
setBulkPublishing(true);
|
setBulkPublishing(true);
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
|
||||||
for (const stopId of selectedStops) {
|
for (const stopId of selectedStops) {
|
||||||
const stop = stops.find(s => s.id === stopId);
|
const stop = stops.find((s) => s.id === stopId);
|
||||||
if (stop && stop.status === "draft") {
|
if (stop && stop.status === "draft") {
|
||||||
const result = await publishStop(stopId, stop.brand_id);
|
const result = await publishStop(stopId, stop.brand_id);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -115,83 +145,125 @@ export default function StopTableClient({ stops }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setBulkPublishing(false);
|
setBulkPublishing(false);
|
||||||
setSelectedStops(new Set());
|
setSelectedStops(new Set());
|
||||||
|
|
||||||
if (failCount === 0) {
|
if (failCount === 0) {
|
||||||
showSuccess(`${successCount} stop${successCount !== 1 ? 's' : ''} published`);
|
showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
|
||||||
} else {
|
} else {
|
||||||
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
|
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleted() {
|
function handleDeleted() {
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
startTransition(() => {
|
startTransition(() => router.refresh());
|
||||||
router.refresh();
|
}
|
||||||
});
|
|
||||||
|
function refresh() {
|
||||||
|
startTransition(() => router.refresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
|
{!hideInternalFilterBar && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)]">
|
||||||
<AdminSearchInput
|
<AdminSearchInput
|
||||||
placeholder="Search stops..."
|
placeholder="Search by city or venue…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
onChange={(e) => {
|
||||||
onClear={() => { setSearch(""); setPage(0); }}
|
setSearch(e.target.value);
|
||||||
showClear={true}
|
setPage(0);
|
||||||
className="flex-1 min-w-48 max-w-64"
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
setSearch("");
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
showClear
|
||||||
|
className="flex-1 min-w-[180px] max-w-[280px]"
|
||||||
/>
|
/>
|
||||||
<AdminFilterTabs
|
<AdminFilterTabs
|
||||||
activeTab={statusFilter}
|
activeTab={statusFilter}
|
||||||
onTabChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(0); }}
|
onTabChange={(value) => {
|
||||||
|
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
size="sm"
|
size="sm"
|
||||||
showCounts={true}
|
showCounts
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">{filtered.length} stops</span>
|
<span className="text-xs text-[var(--admin-text-muted)] ml-auto tabular-nums">
|
||||||
|
{filtered.length} stop{filtered.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<AdminIconButton
|
<AdminIconButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
label="Previous page"
|
label="Previous page"
|
||||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
disabled={page === 0 || isLoading}
|
disabled={page === 0 || isLoading}
|
||||||
className="!rounded-lg border border-[var(--admin-border)]"
|
className="!rounded-lg border border-[var(--admin-border)]"
|
||||||
>
|
>
|
||||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
</AdminIconButton>
|
</AdminIconButton>
|
||||||
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
|
<span className="text-xs text-[var(--admin-text-muted)] px-1 tabular-nums">
|
||||||
|
{page + 1}/{totalPages}
|
||||||
|
</span>
|
||||||
<AdminIconButton
|
<AdminIconButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
label="Next page"
|
label="Next page"
|
||||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
disabled={page >= totalPages - 1 || isLoading}
|
disabled={page >= totalPages - 1 || isLoading}
|
||||||
className="!rounded-lg border border-[var(--admin-border)]"
|
className="!rounded-lg border border-[var(--admin-border)]"
|
||||||
>
|
>
|
||||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
</AdminIconButton>
|
</AdminIconButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<AdminButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowImport(true)}
|
||||||
|
icon={
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Upload Schedule
|
||||||
|
</AdminButton>
|
||||||
|
<AdminButton
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAdd(true)}
|
||||||
|
icon={
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add Stop
|
||||||
|
</AdminButton>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bulk actions bar */}
|
{/* Bulk actions bar */}
|
||||||
{selectedStops.size > 0 && (
|
{selectedStops.size > 0 && (
|
||||||
<div className="mx-5 my-3 flex items-center justify-between rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-4 py-3">
|
<div className="mx-4 my-3 flex items-center justify-between rounded-xl border border-emerald-200 bg-emerald-50/60 px-4 py-2.5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-semibold text-[var(--admin-accent-text)]">
|
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
|
||||||
{selectedStops.size} stop{selectedStops.size !== 1 ? 's' : ''} selected
|
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedStops(new Set())}
|
onClick={() => setSelectedStops(new Set())}
|
||||||
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]"
|
className="text-xs text-stone-500 hover:text-stone-700"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
@@ -209,7 +281,7 @@ export default function StopTableClient({ stops }: Props) {
|
|||||||
|
|
||||||
{/* Delete error */}
|
{/* Delete error */}
|
||||||
{deleteError && (
|
{deleteError && (
|
||||||
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
|
<div className="mx-4 my-3 rounded-lg border border-red-500/30 bg-red-50 px-4 py-2.5 text-sm text-red-700">
|
||||||
{deleteError}{" "}
|
{deleteError}{" "}
|
||||||
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
|
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
|
||||||
Dismiss
|
Dismiss
|
||||||
@@ -219,45 +291,59 @@ export default function StopTableClient({ stops }: Props) {
|
|||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
|
<thead className="bg-[var(--admin-bg-subtle)]/40 text-[var(--admin-text-muted)] text-xs uppercase tracking-wide">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-10 px-5 py-4">
|
<th className="w-10 px-4 py-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0}
|
checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0}
|
||||||
onChange={toggleSelectAll}
|
onChange={toggleSelectAll}
|
||||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
||||||
|
aria-label="Select all"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-5 py-4 font-semibold">City</th>
|
<th className="px-4 py-3 font-semibold">When</th>
|
||||||
<th className="px-5 py-4 font-semibold">Location</th>
|
<th className="px-4 py-3 font-semibold">Where</th>
|
||||||
<th className="px-5 py-4 font-semibold">Date</th>
|
<th className="px-4 py-3 font-semibold">Venue</th>
|
||||||
<th className="px-5 py-4 font-semibold">Time</th>
|
<th className="px-4 py-3 font-semibold">Status</th>
|
||||||
<th className="px-5 py-4 font-semibold">Brand</th>
|
<th className="w-12 px-4 py-3" />
|
||||||
<th className="px-5 py-4 font-semibold">Status</th>
|
|
||||||
<th className="px-5 py-4 font-semibold" />
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
Array.from({ length: 8 }).map((_, i) => (
|
Array.from({ length: 8 }).map((_, i) => (
|
||||||
<tr key={i} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
<tr key={i}>
|
||||||
<td className="px-5 py-4"><Skeleton variant="rect" className="h-5 w-5" /></td>
|
<td className="px-4 py-3.5"><Skeleton variant="rect" className="h-4 w-4" /></td>
|
||||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-24 h-4" /></td>
|
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-24 h-4" /></td>
|
||||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
|
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
|
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-40 h-4" /></td>
|
||||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
|
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-16 h-5 rounded-full" /></td>
|
||||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-20 h-4" /></td>
|
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-6 h-6" /></td>
|
||||||
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
|
|
||||||
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
|
<td colSpan={6} className="px-4 py-14 text-center">
|
||||||
{search || statusFilter !== "all"
|
<div className="flex flex-col items-center gap-3 text-[var(--admin-text-muted)]">
|
||||||
? "No stops match your search."
|
<div className="h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
|
||||||
: "No stops found. Create one to get started."}
|
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-stone-700">
|
||||||
|
{search || statusFilter !== "all"
|
||||||
|
? "No stops match your filters"
|
||||||
|
: "No stops yet"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-stone-500">
|
||||||
|
{search || statusFilter !== "all"
|
||||||
|
? "Try a different search or clear the filters above."
|
||||||
|
: "Use the buttons above to upload a schedule or add your first stop."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@@ -268,52 +354,101 @@ export default function StopTableClient({ stops }: Props) {
|
|||||||
onDeleted={handleDeleted}
|
onDeleted={handleDeleted}
|
||||||
onDeleteError={setDeleteError}
|
onDeleteError={setDeleteError}
|
||||||
isSelected={selectedStops.has(stop.id)}
|
isSelected={selectedStops.has(stop.id)}
|
||||||
onToggleSelect={() => toggleStopSelection(stop.id)}
|
onToggleSelect={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleStopSelection(stop.id);
|
||||||
|
}}
|
||||||
|
onOpen={() => setOpenStopId(stop.id)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{showImport && (
|
||||||
|
<ScheduleImportModal
|
||||||
|
brandId={stops[0]?.brand_id ?? ""}
|
||||||
|
onClose={() => setShowImport(false)}
|
||||||
|
onComplete={refresh}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddStopModal
|
||||||
|
isOpen={showAdd}
|
||||||
|
onClose={() => setShowAdd(false)}
|
||||||
|
brandId={stops[0]?.brand_id ?? ""}
|
||||||
|
onSuccess={refresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{openStopId && (
|
||||||
|
<StopDetailModal
|
||||||
|
stopId={openStopId}
|
||||||
|
onClose={() => setOpenStopId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): { date: string; weekday: string } {
|
||||||
|
// Stop date is stored as YYYY-MM-DD; parse without TZ shift
|
||||||
|
const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10));
|
||||||
|
if (!y || !m || !d) return { date: iso, weekday: "" };
|
||||||
|
const dt = new Date(y, m - 1, d);
|
||||||
|
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
|
||||||
|
const month = dt.toLocaleDateString("en-US", { month: "short" });
|
||||||
|
return { date: `${month} ${d}`, weekday };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(t: string): string {
|
||||||
|
// Stored as HH:MM (24h). Display as 10:00 AM.
|
||||||
|
if (!t) return "";
|
||||||
|
const [hStr, mStr] = t.split(":");
|
||||||
|
const h = parseInt(hStr, 10);
|
||||||
|
if (Number.isNaN(h)) return t;
|
||||||
|
const period = h >= 12 ? "PM" : "AM";
|
||||||
|
const hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||||
|
return `${hh}:${mStr} ${period}`;
|
||||||
|
}
|
||||||
|
|
||||||
function StopRowBase({
|
function StopRowBase({
|
||||||
stop,
|
stop,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
onDeleteError,
|
onDeleteError,
|
||||||
isSelected,
|
isSelected,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
stop: Stop;
|
stop: Stop;
|
||||||
onDeleted: () => void;
|
onDeleted: () => void;
|
||||||
onDeleteError: (msg: string) => void;
|
onDeleteError: (msg: string) => void;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onToggleSelect: () => void;
|
onToggleSelect: (e: React.MouseEvent) => void;
|
||||||
|
onOpen: () => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
|
||||||
const { success: showSuccess, error: showError } = useToast();
|
const { success: showSuccess, error: showError } = useToast();
|
||||||
const [, startTransition] = useTransition();
|
|
||||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handlePublish(stopId: string) {
|
const { date, weekday } = formatDate(stop.date);
|
||||||
setPublishingId(stopId);
|
const time = formatTime(stop.time);
|
||||||
setOpenMenu(null);
|
|
||||||
const result = await publishStop(stopId, stop.brand_id);
|
async function handlePublish() {
|
||||||
|
setPublishingId(stop.id);
|
||||||
|
const result = await publishStop(stop.id, stop.brand_id);
|
||||||
setPublishingId(null);
|
setPublishingId(null);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess("Stop published", "The stop is now visible on the storefront");
|
showSuccess("Stop published", "The stop is now visible on the storefront");
|
||||||
startTransition(() => router.refresh());
|
onDeleted();
|
||||||
} else {
|
} else {
|
||||||
showError("Failed to publish", result.error ?? "Please try again");
|
showError("Failed to publish", result.error ?? "Please try again");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(stopId: string) {
|
async function handleDelete() {
|
||||||
setDeletingId(stopId);
|
setDeletingId(stop.id);
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
|
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
|
||||||
{
|
{
|
||||||
@@ -322,7 +457,7 @@ function StopRowBase({
|
|||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: stop.brand_id }),
|
body: JSON.stringify({ p_stop_id: stop.id, p_brand_id: stop.brand_id }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -338,69 +473,88 @@ function StopRowBase({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={`hover:bg-[var(--admin-bg-subtle)] transition-colors relative ${isSelected ? 'bg-[var(--admin-accent-light)]/30' : ''}`}>
|
<tr
|
||||||
<td className="px-5 py-4">
|
onClick={onOpen}
|
||||||
|
className={`group cursor-pointer transition-colors ${isSelected ? "bg-emerald-50/60" : "hover:bg-[var(--admin-bg-subtle)]/60"}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={onToggleSelect}
|
onChange={() => {}}
|
||||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
onClick={onToggleSelect}
|
||||||
|
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
||||||
|
aria-label={`Select stop in ${stop.city}`}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4">
|
|
||||||
<Link
|
<td className="px-4 py-3.5">
|
||||||
href={`/admin/stops/${stop.id}`}
|
<div className="flex flex-col leading-tight">
|
||||||
className="font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] transition-colors"
|
<span className="font-semibold text-[var(--admin-text-primary)] tabular-nums">{date}</span>
|
||||||
>
|
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">
|
||||||
{stop.city}, {stop.state}
|
{weekday} {time && `· ${time}`}
|
||||||
</Link>
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">{stop.location}</td>
|
<td className="px-4 py-3.5">
|
||||||
|
<div className="flex flex-col leading-tight">
|
||||||
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.date}</td>
|
<span className="font-semibold text-[var(--admin-text-primary)]">{stop.city}</span>
|
||||||
|
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">{stop.state}</span>
|
||||||
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.time}</td>
|
</div>
|
||||||
|
|
||||||
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">
|
|
||||||
{Array.isArray(stop.brands)
|
|
||||||
? stop.brands[0]?.name
|
|
||||||
: stop.brands?.name}
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-5 py-4">
|
<td className="px-4 py-3.5">
|
||||||
|
<div className="flex flex-col leading-tight">
|
||||||
|
<span className="text-[var(--admin-text-secondary)]">{stop.location}</span>
|
||||||
|
{stop.address && (
|
||||||
|
<span className="text-[11px] text-[var(--admin-text-muted)] truncate max-w-[260px]">
|
||||||
|
{stop.address}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ${
|
||||||
stop.status === "draft"
|
stop.status === "draft"
|
||||||
? "bg-amber-100 text-amber-700 border border-amber-200"
|
? "bg-amber-50 text-amber-700 border border-amber-200"
|
||||||
: stop.active
|
: stop.active
|
||||||
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]"
|
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||||
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
|
: "bg-stone-100 text-stone-500 border border-stone-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${
|
||||||
|
stop.status === "draft"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: stop.active
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: "bg-stone-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
|
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-5 py-4 text-right">
|
<td className="px-4 py-3.5 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="relative inline-flex items-center justify-end gap-2">
|
<div className="relative inline-flex items-center justify-end gap-1">
|
||||||
<Link
|
|
||||||
href={`/admin/stops/${stop.id}`}
|
|
||||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
<AdminIconButton
|
<AdminIconButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
label="More options"
|
label="More options"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
setOpenMenu(openMenu === stop.id ? null : stop.id);
|
setOpenMenu(openMenu === stop.id ? null : stop.id);
|
||||||
}}
|
}}
|
||||||
|
className="opacity-60 group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
|
<circle cx="10" cy="4" r="1.5" />
|
||||||
|
<circle cx="10" cy="10" r="1.5" />
|
||||||
|
<circle cx="10" cy="16" r="1.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</AdminIconButton>
|
</AdminIconButton>
|
||||||
|
|
||||||
@@ -408,36 +562,42 @@ function StopRowBase({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-10"
|
className="fixed inset-0 z-10"
|
||||||
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenMenu(null);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
|
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
|
||||||
{stop.status === "draft" && (
|
{stop.status === "draft" && (
|
||||||
<AdminButton
|
<button
|
||||||
variant="ghost"
|
onClick={(e) => {
|
||||||
size="sm"
|
e.stopPropagation();
|
||||||
fullWidth
|
handlePublish();
|
||||||
onClick={() => handlePublish(stop.id)}
|
}}
|
||||||
disabled={publishingId === stop.id}
|
disabled={publishingId === stop.id}
|
||||||
className="!justify-start !text-[var(--admin-accent)] hover:!bg-[var(--admin-accent-light)]"
|
className="w-full text-left px-4 py-2.5 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{publishingId === stop.id ? "Publishing..." : "Publish"}
|
{publishingId === stop.id ? "Publishing…" : "Publish"}
|
||||||
</AdminButton>
|
</button>
|
||||||
)}
|
)}
|
||||||
<a
|
<Link
|
||||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex items-center px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||||
>
|
>
|
||||||
Duplicate
|
Duplicate
|
||||||
</a>
|
</Link>
|
||||||
<AdminButton
|
<button
|
||||||
variant="ghost"
|
onClick={(e) => {
|
||||||
size="sm"
|
e.stopPropagation();
|
||||||
fullWidth
|
setOpenMenu(null);
|
||||||
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
|
setConfirmDelete(stop.id);
|
||||||
className="!justify-start !text-[var(--admin-danger)] hover:!bg-[var(--admin-danger)]/10"
|
}}
|
||||||
|
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</AdminButton>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -446,7 +606,11 @@ function StopRowBase({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-30"
|
className="fixed inset-0 z-30"
|
||||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirmDelete(null);
|
||||||
|
setOpenMenu(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
||||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||||
@@ -455,22 +619,29 @@ function StopRowBase({
|
|||||||
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">
|
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">
|
||||||
This will remove the stop. If it has active orders, you must resolve those first.
|
This will remove the stop. If it has active orders, you must resolve those first.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2 justify-end">
|
||||||
<AdminButton
|
<AdminButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirmDelete(null);
|
||||||
|
setOpenMenu(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
<AdminButton
|
<AdminButton
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(stop.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete();
|
||||||
|
}}
|
||||||
disabled={deletingId === stop.id}
|
disabled={deletingId === stop.id}
|
||||||
isLoading={deletingId === stop.id}
|
isLoading={deletingId === stop.id}
|
||||||
>
|
>
|
||||||
{deletingId === stop.id ? "..." : "Delete"}
|
{deletingId === stop.id ? "Deleting…" : "Delete"}
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,754 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect, useRef, useCallback, useTransition } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { publishStop } from "@/actions/stops";
|
||||||
|
import { useToast } from "@/components/admin/design-system";
|
||||||
|
import type { StopForView } from "./StopsViewClient";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
stops: StopForView[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ================================================================== */
|
||||||
|
/* Date helpers — work with YYYY-MM-DD strings to avoid TZ drift */
|
||||||
|
/* ================================================================== */
|
||||||
|
|
||||||
|
function ymd(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYmd(s: string): Date | null {
|
||||||
|
// Treat YYYY-MM-DD as a local date (no UTC shift)
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||||
|
if (!m) return null;
|
||||||
|
const [, y, mo, d] = m;
|
||||||
|
return new Date(Number(y), Number(mo) - 1, Number(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayYmd(): string {
|
||||||
|
return ymd(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December",
|
||||||
|
];
|
||||||
|
|
||||||
|
const MONTH_NAMES_ITALIC = [
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
|
||||||
|
function buildMonthGrid(month: Date): Date[] {
|
||||||
|
// Start at the Sunday on or before the 1st of the month
|
||||||
|
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||||
|
const startDayOfWeek = first.getDay(); // 0 = Sun
|
||||||
|
const start = new Date(first);
|
||||||
|
start.setDate(1 - startDayOfWeek);
|
||||||
|
|
||||||
|
// 6 weeks = 42 cells
|
||||||
|
const cells: Date[] = [];
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const d = new Date(start);
|
||||||
|
d.setDate(start.getDate() + i);
|
||||||
|
cells.push(d);
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusOf(stop: StopForView): "active" | "draft" | "inactive" {
|
||||||
|
if (stop.status === "draft") return "draft";
|
||||||
|
if (stop.active) return "active";
|
||||||
|
return "inactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s: "active" | "draft" | "inactive"): string {
|
||||||
|
return s === "draft" ? "Draft" : s === "active" ? "Active" : "Inactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandName(s: StopForView): string {
|
||||||
|
return Array.isArray(s.brands) ? s.brands[0]?.name ?? "—" : s.brands?.name ?? "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime12(t: string | null | undefined): string {
|
||||||
|
if (!t) return "—";
|
||||||
|
// t is "HH:MM" or "HH:MM:SS"
|
||||||
|
const [hStr = "0", mStr = "00"] = t.split(":");
|
||||||
|
const h = Number(hStr);
|
||||||
|
const ampm = h >= 12 ? "pm" : "am";
|
||||||
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||||
|
return `${h12}:${mStr} ${ampm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeCompact(t: string | null | undefined): string {
|
||||||
|
if (!t) return "—";
|
||||||
|
const [hStr = "0", mStr = "00"] = t.split(":");
|
||||||
|
const h = Number(hStr);
|
||||||
|
const ampm = h >= 12 ? "p" : "a";
|
||||||
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||||
|
return `${h12}:${mStr}${ampm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLong(d: Date): string {
|
||||||
|
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
|
||||||
|
const month = MONTH_NAMES[d.getMonth()];
|
||||||
|
return `${weekday}, ${month} ${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLongSpelled(d: Date): string {
|
||||||
|
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
|
||||||
|
const month = MONTH_NAMES[d.getMonth()];
|
||||||
|
return `${weekday}, ${month} ${d.getDate()}, ${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================== */
|
||||||
|
/* Component */
|
||||||
|
/* ================================================================== */
|
||||||
|
|
||||||
|
const MAX_EVENTS_PER_CELL = 3;
|
||||||
|
|
||||||
|
export default function StopsCalendarClient({ stops }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { success: showSuccess, error: showError } = useToast();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [viewMonth, setViewMonth] = useState(() => {
|
||||||
|
const today = new Date();
|
||||||
|
return new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
});
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
const [activePopover, setActivePopover] = useState<{
|
||||||
|
stopId: string;
|
||||||
|
cellRect: DOMRect;
|
||||||
|
} | null>(null);
|
||||||
|
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Bucket stops by YYYY-MM-DD
|
||||||
|
const stopsByDate = useMemo(() => {
|
||||||
|
const map = new Map<string, StopForView[]>();
|
||||||
|
for (const s of stops) {
|
||||||
|
if (!s.date) continue;
|
||||||
|
const list = map.get(s.date) ?? [];
|
||||||
|
list.push(s);
|
||||||
|
map.set(s.date, list);
|
||||||
|
}
|
||||||
|
// Sort each day by time
|
||||||
|
for (const list of map.values()) {
|
||||||
|
list.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [stops]);
|
||||||
|
|
||||||
|
const todayStr = useMemo(() => todayYmd(), []);
|
||||||
|
|
||||||
|
const monthCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
|
||||||
|
|
||||||
|
// Earliest/latest stop dates — used to enable/disable nav
|
||||||
|
const dateRange = useMemo(() => {
|
||||||
|
if (stops.length === 0) return null;
|
||||||
|
const dates = stops.map((s) => s.date).filter(Boolean).sort();
|
||||||
|
return { min: dates[0], max: dates[dates.length - 1] };
|
||||||
|
}, [stops]);
|
||||||
|
|
||||||
|
const isFirstMonth = useMemo(() => {
|
||||||
|
if (!dateRange?.min) return false;
|
||||||
|
const minDate = parseYmd(dateRange.min);
|
||||||
|
if (!minDate) return false;
|
||||||
|
return viewMonth.getFullYear() === minDate.getFullYear() && viewMonth.getMonth() === minDate.getMonth();
|
||||||
|
}, [dateRange, viewMonth]);
|
||||||
|
|
||||||
|
const isLastMonth = useMemo(() => {
|
||||||
|
if (!dateRange?.max) return false;
|
||||||
|
const maxDate = parseYmd(dateRange.max);
|
||||||
|
if (!maxDate) return false;
|
||||||
|
return viewMonth.getFullYear() === maxDate.getFullYear() && viewMonth.getMonth() === maxDate.getMonth();
|
||||||
|
}, [dateRange, viewMonth]);
|
||||||
|
|
||||||
|
function goPrevMonth() {
|
||||||
|
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1));
|
||||||
|
}
|
||||||
|
function goNextMonth() {
|
||||||
|
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1));
|
||||||
|
}
|
||||||
|
function goToday() {
|
||||||
|
const today = new Date();
|
||||||
|
setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||||
|
setSelectedDate(todayYmd());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close popover on outside click / Escape
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activePopover) return;
|
||||||
|
function onDown(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest("[data-event-popover]") || target.closest("[data-event-chip]")) return;
|
||||||
|
setActivePopover(null);
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setActivePopover(null);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [activePopover]);
|
||||||
|
|
||||||
|
// Close drawer on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDate) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setSelectedDate(null);
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
// Lock body scroll when drawer is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDate) {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
const openStop = useCallback((stop: StopForView, anchorRect: DOMRect) => {
|
||||||
|
setActivePopover({ stopId: stop.id, cellRect: anchorRect });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePublish = useCallback(
|
||||||
|
async (stop: StopForView) => {
|
||||||
|
setActivePopover(null);
|
||||||
|
setPublishingId(stop.id);
|
||||||
|
const result = await publishStop(stop.id, stop.brand_id);
|
||||||
|
setPublishingId(null);
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess("Stop published", `${stop.city}, ${stop.state} is now visible to customers`);
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} else {
|
||||||
|
showError("Failed to publish", result.error ?? "Please try again");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router, showSuccess, showError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeStop = useMemo(
|
||||||
|
() => (activePopover ? stops.find((s) => s.id === activePopover.stopId) ?? null : null),
|
||||||
|
[activePopover, stops]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedDayStops = useMemo(() => {
|
||||||
|
if (!selectedDate) return [];
|
||||||
|
return stopsByDate.get(selectedDate) ?? [];
|
||||||
|
}, [selectedDate, stopsByDate]);
|
||||||
|
|
||||||
|
const selectedDayDate = useMemo(() => (selectedDate ? parseYmd(selectedDate) : null), [selectedDate]);
|
||||||
|
|
||||||
|
// Compute popover position with viewport edge detection
|
||||||
|
const popoverStyle = useMemo<React.CSSProperties | null>(() => {
|
||||||
|
if (!activePopover) return null;
|
||||||
|
const POPOVER_W = 288; // 18rem
|
||||||
|
const MARGIN = 8;
|
||||||
|
const rect = activePopover.cellRect;
|
||||||
|
const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
|
||||||
|
// Default: place below the chip, left-aligned
|
||||||
|
let left = rect.left + rect.width / 2 - POPOVER_W / 2;
|
||||||
|
const top = rect.bottom + MARGIN;
|
||||||
|
if (left + POPOVER_W > vw - 8) left = vw - POPOVER_W - 8;
|
||||||
|
if (left < 8) left = 8;
|
||||||
|
return { left, top };
|
||||||
|
}, [activePopover]);
|
||||||
|
|
||||||
|
const popoverArrowStyle = useMemo<React.CSSProperties | null>(() => {
|
||||||
|
if (!activePopover || !popoverStyle) return null;
|
||||||
|
const rect = activePopover.cellRect;
|
||||||
|
const arrowLeft = rect.left + rect.width / 2 - (popoverStyle.left as number) - 5;
|
||||||
|
return { left: Math.max(8, Math.min(arrowLeft, 270)) };
|
||||||
|
}, [activePopover, popoverStyle]);
|
||||||
|
|
||||||
|
// === Render ===
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ha-calendar">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="ha-calendar-header">
|
||||||
|
<div className="ha-calendar-title-block">
|
||||||
|
<span className="ha-calendar-eyebrow">
|
||||||
|
Tour Almanac · {stops.length} {stops.length === 1 ? "stop" : "stops"} on file
|
||||||
|
</span>
|
||||||
|
<h2 className="ha-calendar-title">
|
||||||
|
<span className="ha-calendar-title-italic">{MONTH_NAMES_ITALIC[viewMonth.getMonth()]}</span>
|
||||||
|
<span className="ha-calendar-title-year">{viewMonth.getFullYear()}</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="ha-calendar-nav">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ha-calendar-nav-btn"
|
||||||
|
onClick={goPrevMonth}
|
||||||
|
disabled={isFirstMonth}
|
||||||
|
aria-label="Previous month"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ha-calendar-today" onClick={goToday}>
|
||||||
|
<span className="ha-calendar-today-dot" style={{ background: "currentColor" }} />
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ha-calendar-nav-btn"
|
||||||
|
onClick={goNextMonth}
|
||||||
|
disabled={isLastMonth}
|
||||||
|
aria-label="Next month"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DOW header */}
|
||||||
|
<div className="ha-calendar-dow" role="row">
|
||||||
|
{DOW_LABELS.map((label) => (
|
||||||
|
<div key={label} className="ha-calendar-dow-cell" role="columnheader">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="ha-calendar-grid" role="grid">
|
||||||
|
{monthCells.map((d) => {
|
||||||
|
const key = ymd(d);
|
||||||
|
const dayStops = stopsByDate.get(key) ?? [];
|
||||||
|
const isOut = d.getMonth() !== viewMonth.getMonth();
|
||||||
|
const isToday = key === todayStr;
|
||||||
|
const hasStops = dayStops.length > 0;
|
||||||
|
const dow = d.getDay();
|
||||||
|
const isWeekend = dow === 0 || dow === 6;
|
||||||
|
const isSelected = selectedDate === key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
role="gridcell"
|
||||||
|
tabIndex={hasStops ? 0 : -1}
|
||||||
|
onClick={() => hasStops && setSelectedDate(key)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (hasStops && (e.key === "Enter" || e.key === " ")) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedDate(key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
"ha-calendar-cell",
|
||||||
|
isOut ? "ha-calendar-cell--out" : "",
|
||||||
|
isWeekend ? "ha-calendar-cell--weekend" : "",
|
||||||
|
hasStops ? "ha-calendar-cell--has-stops" : "",
|
||||||
|
isToday ? "ha-calendar-cell--today" : "",
|
||||||
|
isSelected ? "ha-calendar-cell--selected" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
aria-label={hasStops ? `${formatDateLong(d)} — ${dayStops.length} stop${dayStops.length !== 1 ? "s" : ""}` : formatDateLong(d)}
|
||||||
|
>
|
||||||
|
<div className="ha-calendar-daynum">
|
||||||
|
<span>{d.getDate()}</span>
|
||||||
|
{isToday && <span className="ha-calendar-today-dot" />}
|
||||||
|
</div>
|
||||||
|
<div className="ha-calendar-events">
|
||||||
|
{dayStops.slice(0, MAX_EVENTS_PER_CELL).map((stop) => (
|
||||||
|
<EventChip
|
||||||
|
key={stop.id}
|
||||||
|
stop={stop}
|
||||||
|
isPublishing={publishingId === stop.id}
|
||||||
|
onOpen={(rect) => openStop(stop, rect)}
|
||||||
|
isActive={activePopover?.stopId === stop.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{dayStops.length > MAX_EVENTS_PER_CELL && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ha-calendar-event-more"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedDate(key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+{dayStops.length - MAX_EVENTS_PER_CELL} more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="ha-calendar-legend">
|
||||||
|
<span className="ha-calendar-legend-item">
|
||||||
|
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-accent)" }} />
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
<span className="ha-calendar-legend-item">
|
||||||
|
<span className="ha-calendar-legend-swatch" style={{ background: "#f59e0b" }} />
|
||||||
|
Draft
|
||||||
|
</span>
|
||||||
|
<span className="ha-calendar-legend-item">
|
||||||
|
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-text-muted)" }} />
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: "auto" }} className="hidden sm:inline">
|
||||||
|
Tip — click any day with stops to see the full route · click an event for details
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event popover */}
|
||||||
|
{activeStop && activePopover && popoverStyle && (
|
||||||
|
<EventPopover
|
||||||
|
stop={activeStop}
|
||||||
|
isPublishing={publishingId === activeStop.id}
|
||||||
|
onPublish={() => handlePublish(activeStop)}
|
||||||
|
onOpenRoute={() => {
|
||||||
|
setActivePopover(null);
|
||||||
|
setSelectedDate(activeStop.date);
|
||||||
|
}}
|
||||||
|
style={popoverStyle}
|
||||||
|
arrowStyle={popoverArrowStyle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day-route drawer */}
|
||||||
|
{selectedDate && selectedDayDate && (
|
||||||
|
<DayRouteDrawer
|
||||||
|
date={selectedDayDate}
|
||||||
|
dateStr={selectedDate}
|
||||||
|
stops={selectedDayStops}
|
||||||
|
onClose={() => setSelectedDate(null)}
|
||||||
|
onPublish={handlePublish}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================== */
|
||||||
|
/* EventChip — single stop on a day cell */
|
||||||
|
/* ================================================================== */
|
||||||
|
|
||||||
|
function EventChip({
|
||||||
|
stop,
|
||||||
|
onOpen,
|
||||||
|
isActive,
|
||||||
|
isPublishing,
|
||||||
|
}: {
|
||||||
|
stop: StopForView;
|
||||||
|
onOpen: (rect: DOMRect) => void;
|
||||||
|
isActive: boolean;
|
||||||
|
isPublishing: boolean;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
const status = statusOf(stop);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
data-event-chip
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isPublishing && ref.current) onOpen(ref.current.getBoundingClientRect());
|
||||||
|
}}
|
||||||
|
className={`ha-calendar-event ha-calendar-event--${status} ${isActive ? "!bg-[var(--admin-accent)]/20" : ""}`}
|
||||||
|
title={`${stop.city}, ${stop.state} — ${stop.location}`}
|
||||||
|
>
|
||||||
|
<span className="ha-calendar-event-time">{formatTimeCompact(stop.time)}</span>
|
||||||
|
<span className="ha-calendar-event-text">{stop.city}, {stop.state}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================== */
|
||||||
|
/* EventPopover — floating detail card */
|
||||||
|
/* ================================================================== */
|
||||||
|
|
||||||
|
function EventPopover({
|
||||||
|
stop,
|
||||||
|
isPublishing,
|
||||||
|
onPublish,
|
||||||
|
onOpenRoute,
|
||||||
|
style,
|
||||||
|
arrowStyle,
|
||||||
|
}: {
|
||||||
|
stop: StopForView;
|
||||||
|
isPublishing: boolean;
|
||||||
|
onPublish: () => void;
|
||||||
|
onOpenRoute: () => void;
|
||||||
|
style: React.CSSProperties;
|
||||||
|
arrowStyle: React.CSSProperties | null;
|
||||||
|
}) {
|
||||||
|
const status = statusOf(stop);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-event-popover
|
||||||
|
className="ha-event-popover"
|
||||||
|
style={style}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{arrowStyle && <div className="ha-event-popover-arrow" style={{ ...arrowStyle, top: -5 }} />}
|
||||||
|
<div className="ha-event-popover-eyebrow">
|
||||||
|
<span
|
||||||
|
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
status === "active" ? "var(--admin-accent)" : status === "draft" ? "#f59e0b" : "var(--admin-text-muted)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{statusLabel(status)} · {brandName(stop)}
|
||||||
|
</div>
|
||||||
|
<h3 className="ha-event-popover-title">
|
||||||
|
{stop.city}, {stop.state}
|
||||||
|
</h3>
|
||||||
|
<p className="ha-event-popover-location">{stop.location}</p>
|
||||||
|
|
||||||
|
<div className="ha-event-popover-grid">
|
||||||
|
<div className="ha-event-popover-field">
|
||||||
|
<span className="ha-event-popover-field-label">Pickup</span>
|
||||||
|
<span className="ha-event-popover-field-value">{formatTime12(stop.time)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ha-event-popover-field">
|
||||||
|
<span className="ha-event-popover-field-label">Cutoff</span>
|
||||||
|
<span className="ha-event-popover-field-value">{stop.cutoff_time ? formatTime12(stop.cutoff_time) : "—"}</span>
|
||||||
|
</div>
|
||||||
|
{stop.address && (
|
||||||
|
<div className="ha-event-popover-field" style={{ gridColumn: "1 / -1" }}>
|
||||||
|
<span className="ha-event-popover-field-label">Address</span>
|
||||||
|
<span className="ha-event-popover-field-value" style={{ fontFamily: "var(--font-geist), sans-serif", fontSize: "0.75rem" }}>
|
||||||
|
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ha-event-popover-actions">
|
||||||
|
{status === "draft" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPublish}
|
||||||
|
disabled={isPublishing}
|
||||||
|
className="ha-event-popover-btn ha-event-popover-btn--primary"
|
||||||
|
>
|
||||||
|
{isPublishing ? "Publishing…" : "Publish"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenRoute}
|
||||||
|
className="ha-event-popover-btn"
|
||||||
|
>
|
||||||
|
View route
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={`/admin/stops/${stop.id}`}
|
||||||
|
className="ha-event-popover-btn ha-event-popover-btn--primary"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================== */
|
||||||
|
/* DayRouteDrawer — full day's route on the side */
|
||||||
|
/* ================================================================== */
|
||||||
|
|
||||||
|
function DayRouteDrawer({
|
||||||
|
date,
|
||||||
|
dateStr,
|
||||||
|
stops,
|
||||||
|
onClose,
|
||||||
|
onPublish,
|
||||||
|
}: {
|
||||||
|
date: Date;
|
||||||
|
dateStr: string;
|
||||||
|
stops: StopForView[];
|
||||||
|
onClose: () => void;
|
||||||
|
onPublish: (stop: StopForView) => void;
|
||||||
|
}) {
|
||||||
|
const sorted = useMemo(() => [...stops].sort((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const active = sorted.filter((s) => s.status !== "draft" && s.active).length;
|
||||||
|
const draft = sorted.filter((s) => s.status === "draft").length;
|
||||||
|
const total = sorted.length;
|
||||||
|
return { active, draft, total };
|
||||||
|
}, [sorted]);
|
||||||
|
const brands = useMemo(() => Array.from(new Set(sorted.map(brandName))), [sorted]);
|
||||||
|
const routeNumber = useMemo(() => {
|
||||||
|
// Editorial "Route 03" — count of stops with status badge
|
||||||
|
return String(sorted.length).padStart(2, "0");
|
||||||
|
}, [sorted.length]);
|
||||||
|
|
||||||
|
const earliest = sorted[0]?.time;
|
||||||
|
const latest = sorted[sorted.length - 1]?.time;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ha-drawer-backdrop" onClick={onClose} aria-hidden="true" />
|
||||||
|
<aside className="ha-drawer" role="dialog" aria-label={`Route for ${formatDateLongSpelled(date)}`}>
|
||||||
|
<header className="ha-drawer-header">
|
||||||
|
<div>
|
||||||
|
<div className="ha-drawer-eyebrow">Route {routeNumber} · {dateStr}</div>
|
||||||
|
<h2 className="ha-drawer-title">{formatDateLongSpelled(date)}</h2>
|
||||||
|
<p className="ha-drawer-subtitle">
|
||||||
|
{sorted.length} {sorted.length === 1 ? "stop" : "stops"} on the route
|
||||||
|
{earliest && latest && sorted.length > 1 && (
|
||||||
|
<> · {formatTime12(earliest)} – {formatTime12(latest)}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="ha-drawer-close" aria-label="Close">
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="ha-drawer-body">
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<div className="ha-drawer-empty">
|
||||||
|
<p className="ha-drawer-empty-mark">No route on this day</p>
|
||||||
|
<p className="ha-drawer-empty-text">
|
||||||
|
No stops are scheduled for {formatDateLongSpelled(date)}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="ha-route-summary">
|
||||||
|
<div className="ha-route-summary-cell">
|
||||||
|
<span className="ha-route-summary-num">{counts.total}</span>
|
||||||
|
<span className="ha-route-summary-label">Stops</span>
|
||||||
|
</div>
|
||||||
|
<div className="ha-route-summary-cell">
|
||||||
|
<span className="ha-route-summary-num">{counts.active}</span>
|
||||||
|
<span className="ha-route-summary-label">Live</span>
|
||||||
|
</div>
|
||||||
|
<div className="ha-route-summary-cell">
|
||||||
|
<span className="ha-route-summary-num">{brands.length}</span>
|
||||||
|
<span className="ha-route-summary-label">Brands</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Route spine */}
|
||||||
|
<div className="ha-route-list">
|
||||||
|
{sorted.map((stop, idx) => {
|
||||||
|
const status = statusOf(stop);
|
||||||
|
return (
|
||||||
|
<article key={stop.id} className="ha-route-stop">
|
||||||
|
<div className="ha-route-stop-spine">
|
||||||
|
<div className={`ha-route-stop-marker ha-route-stop-marker--${status}`}>
|
||||||
|
{String(idx + 1).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
<div className="ha-route-stop-line" />
|
||||||
|
</div>
|
||||||
|
<div className="ha-route-stop-content">
|
||||||
|
<div className="ha-route-stop-time">{formatTime12(stop.time)}</div>
|
||||||
|
<h3 className="ha-route-stop-name">
|
||||||
|
{stop.city}, {stop.state}
|
||||||
|
</h3>
|
||||||
|
<p className="ha-route-stop-location">{stop.location}</p>
|
||||||
|
<div className="ha-route-stop-meta">
|
||||||
|
<span className="ha-route-stop-meta-pill">{brandName(stop)}</span>
|
||||||
|
<span
|
||||||
|
className="ha-route-stop-meta-pill"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
status === "active"
|
||||||
|
? "var(--admin-accent-light)"
|
||||||
|
: status === "draft"
|
||||||
|
? "rgba(245, 158, 11, 0.1)"
|
||||||
|
: "var(--admin-bg-subtle)",
|
||||||
|
color:
|
||||||
|
status === "active"
|
||||||
|
? "var(--admin-accent-text)"
|
||||||
|
: status === "draft"
|
||||||
|
? "#92400e"
|
||||||
|
: "var(--admin-text-secondary)",
|
||||||
|
borderColor:
|
||||||
|
status === "active"
|
||||||
|
? "var(--admin-accent)"
|
||||||
|
: status === "draft"
|
||||||
|
? "rgba(245, 158, 11, 0.3)"
|
||||||
|
: "var(--admin-border-light)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusLabel(status)}
|
||||||
|
</span>
|
||||||
|
{stop.cutoff_time && (
|
||||||
|
<span className="ha-route-stop-meta-pill">
|
||||||
|
Cutoff {formatTime12(stop.cutoff_time)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{stop.address && (
|
||||||
|
<span className="ha-route-stop-meta-pill">
|
||||||
|
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ha-route-stop-actions">
|
||||||
|
{status === "draft" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPublish(stop)}
|
||||||
|
className="ha-route-stop-action ha-route-stop-action--primary"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/admin/stops/${stop.id}`}
|
||||||
|
className="ha-route-stop-action ha-route-stop-action--primary"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||||
|
className="ha-route-stop-action"
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,13 +8,17 @@ import { AdminButton } from "@/components/admin/design-system";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
brandId: string;
|
brandId: string;
|
||||||
|
/** Hide on the Locations tab — actions belong to the Stops tab only. */
|
||||||
|
tab?: "stops" | "locations";
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StopsHeaderActions({ brandId }: Props) {
|
export default function StopsHeaderActions({ brandId, tab = "stops" }: Props) {
|
||||||
const [showImport, setShowImport] = useState(false);
|
const [showImport, setShowImport] = useState(false);
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (tab !== "stops") return null;
|
||||||
|
|
||||||
function handleImportComplete(count: number) {
|
function handleImportComplete(count: number) {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"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 />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,873 @@
|
|||||||
|
"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 it’s not the sweetest corn you’ve ever had, we’ll 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. That’s why your box is a little heavier
|
||||||
|
than you expect — and a whole lot sweeter than any corn you’ve 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">
|
||||||
|
Buyer’s 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. You’ll 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 isn’t the sweetest I’ve ever had?",
|
||||||
|
a: "We replace it. Send a photo to hello@tuxedocorn.com and we’ll 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.",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -20,6 +20,8 @@ type StopInfo = {
|
|||||||
brand_id: string;
|
brand_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type { StopInfo };
|
||||||
|
|
||||||
type CartContextType = {
|
type CartContextType = {
|
||||||
cart: CartItem[];
|
cart: CartItem[];
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
@@ -30,6 +32,12 @@ type CartContextType = {
|
|||||||
cartRestored: boolean; // true when server cart was loaded (for toast)
|
cartRestored: boolean; // true when server cart was loaded (for toast)
|
||||||
setSelectedStop: (stop: StopInfo | null) => void;
|
setSelectedStop: (stop: StopInfo | null) => void;
|
||||||
addToCart: (item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship") => 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;
|
increaseQuantity: (id: string) => void;
|
||||||
decreaseQuantity: (id: string) => void;
|
decreaseQuantity: (id: string) => void;
|
||||||
removeFromCart: (id: string) => void;
|
removeFromCart: (id: string) => void;
|
||||||
@@ -201,6 +209,27 @@ 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) {
|
function increaseQuantity(id: string) {
|
||||||
setCart((prev) =>
|
setCart((prev) =>
|
||||||
prev.map((item) => (item.id === id ? { ...item, quantity: item.quantity + 1 } : item))
|
prev.map((item) => (item.id === id ? { ...item, quantity: item.quantity + 1 } : item))
|
||||||
@@ -291,6 +320,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
|||||||
cartRestored: showRestoredToast,
|
cartRestored: showRestoredToast,
|
||||||
setSelectedStop: setSelectedStop,
|
setSelectedStop: setSelectedStop,
|
||||||
addToCart,
|
addToCart,
|
||||||
|
buyNow,
|
||||||
increaseQuantity,
|
increaseQuantity,
|
||||||
decreaseQuantity,
|
decreaseQuantity,
|
||||||
removeFromCart,
|
removeFromCart,
|
||||||
@@ -306,7 +336,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCart() {
|
export function useCart(): CartContextType {
|
||||||
const context = useContext(CartContext);
|
const context = useContext(CartContext);
|
||||||
if (!context) throw new Error("useCart must be used inside CartProvider");
|
if (!context) throw new Error("useCart must be used inside CartProvider");
|
||||||
return context;
|
return context;
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
// Shared AdminUser type — safe to import from both server and client components
|
// 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 = {
|
export type AdminUser = {
|
||||||
id?: string;
|
id?: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
brand_id: string | null;
|
brand_id: string | null;
|
||||||
role: "platform_admin" | "brand_admin" | "store_employee" | "staff";
|
brand_ids: string[];
|
||||||
|
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
|
||||||
|
active: boolean;
|
||||||
can_manage_products: boolean;
|
can_manage_products: boolean;
|
||||||
can_manage_stops: boolean;
|
can_manage_stops: boolean;
|
||||||
can_manage_orders: boolean;
|
can_manage_orders: boolean;
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import type { AdminUser } from "./admin-permissions-types";
|
||||||
|
export type { AdminUser } from "./admin-permissions-types";
|
||||||
|
|
||||||
export type AdminUser = {
|
/**
|
||||||
id: string;
|
* Returns the current admin user, or `null` if not authenticated.
|
||||||
user_id: string;
|
*
|
||||||
brand_id: string | null;
|
* Resolution order:
|
||||||
role: string;
|
* 1. Mock data mode (NEXT_PUBLIC_USE_MOCK_DATA=true) → platform_admin dev.
|
||||||
active: boolean;
|
* 2. `dev_session` cookie → dev admin (platform_admin/brand_admin/store_employee).
|
||||||
can_manage_products: boolean;
|
* 3. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids.
|
||||||
can_manage_stops: boolean;
|
*
|
||||||
can_manage_orders: boolean;
|
* `brand_id` is the active brand; `brand_ids` is the full membership list.
|
||||||
can_manage_pickup: boolean;
|
* For dev sessions without a real DB, `brand_ids` is populated by:
|
||||||
can_manage_messages: boolean;
|
* - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table)
|
||||||
can_manage_refunds: boolean;
|
* - store_employee: `[<first real brand>]` if a brand exists, else `[]`
|
||||||
can_manage_users: boolean;
|
* - brand_admin: `[]` (legacy dev; we don't have a way to scope brand in dev)
|
||||||
can_manage_water_log: boolean;
|
*/
|
||||||
can_manage_reports: boolean;
|
|
||||||
can_manage_settings: boolean;
|
|
||||||
must_change_password: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getAdminUser(): Promise<AdminUser | null> {
|
export async function getAdminUser(): Promise<AdminUser | null> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
@@ -76,7 +73,7 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const inserted = await res.json().catch(() => null);
|
const inserted = await res.json().catch(() => null);
|
||||||
if (inserted && inserted.length > 0) {
|
if (inserted && inserted.length > 0) {
|
||||||
return buildAdminUser(inserted[0] as Record<string, unknown>);
|
return buildAdminUser(inserted[0] as Record<string, unknown>, []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -88,11 +85,56 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
|||||||
const admin = adminUsers[0] as Record<string, unknown>;
|
const admin = adminUsers[0] as Record<string, unknown>;
|
||||||
if (!admin.active) return null;
|
if (!admin.active) return null;
|
||||||
|
|
||||||
return buildAdminUser(admin);
|
// 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 [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDevAdmin(role: string): AdminUser {
|
function buildDevAdmin(role: string): AdminUser {
|
||||||
const base = { id: "dev", user_id: "dev", brand_id: null, role, active: true, must_change_password: false };
|
// 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,
|
||||||
|
};
|
||||||
if (role === "store_employee") {
|
if (role === "store_employee") {
|
||||||
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
|
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,
|
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
|
||||||
@@ -103,10 +145,28 @@ function buildDevAdmin(role: string): AdminUser {
|
|||||||
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true };
|
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAdminUser(r: Record<string, unknown>): AdminUser {
|
function buildAdminUser(r: Record<string, unknown>, brandIds: string[]): AdminUser {
|
||||||
const role = r.role as string;
|
// The DB column is TEXT (per CLAUDE.md) so the runtime value is a string.
|
||||||
const base = { id: r.id as string, user_id: r.user_id as string, brand_id: r.brand_id as string | null,
|
// We narrow it to the known union here. If the DB has an unknown role
|
||||||
role, active: r.active as boolean, must_change_password: Boolean(r.must_change_password) };
|
// (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),
|
||||||
|
};
|
||||||
if (role === "platform_admin") {
|
if (role === "platform_admin") {
|
||||||
return { ...base, can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
|
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,
|
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
|
||||||
|
|||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Browser-only Stripe.js loader.
|
||||||
|
*
|
||||||
|
* Reads `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and returns a cached
|
||||||
|
* `loadStripe` promise. Never call this from a server component — it
|
||||||
|
* pulls in `@stripe/stripe-js` which depends on `window`.
|
||||||
|
*
|
||||||
|
* The published key is safe to ship to the browser; the secret key
|
||||||
|
* never leaves the server (see `src/actions/billing/retail-checkout.ts`).
|
||||||
|
*/
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||||
|
|
||||||
|
let stripePromise: Promise<Stripe | null> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached Stripe.js promise, creating it on the first call.
|
||||||
|
* Returns `null` if the publishable key is not configured — callers
|
||||||
|
* should fall back to the hosted Stripe Checkout flow in that case.
|
||||||
|
*/
|
||||||
|
export function getStripe(): Promise<Stripe | null> | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
||||||
|
if (!key) return null;
|
||||||
|
if (!stripePromise) {
|
||||||
|
stripePromise = loadStripe(key);
|
||||||
|
}
|
||||||
|
return stripePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronous check — useful for deciding whether to render Stripe Elements. */
|
||||||
|
export function hasStripePublishableKey(): boolean {
|
||||||
|
return Boolean(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
|
||||||
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
// Supabase Auth Middleware - keeps existing auth working
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
|
|
||||||
// Public routes that don't require authentication
|
|
||||||
const publicRoutes = [
|
|
||||||
"/",
|
|
||||||
"/login",
|
|
||||||
"/login2",
|
|
||||||
"/register",
|
|
||||||
"/forgot-password",
|
|
||||||
"/reset-password",
|
|
||||||
"/pricing",
|
|
||||||
"/terms-and-conditions",
|
|
||||||
"/privacy-policy",
|
|
||||||
"/contact",
|
|
||||||
"/api/health",
|
|
||||||
"/api/stripe/webhook",
|
|
||||||
"/api/resend/webhook",
|
|
||||||
// Brand storefronts are public
|
|
||||||
"/tuxedo",
|
|
||||||
"/tuxedo/*",
|
|
||||||
"/indian-river-direct",
|
|
||||||
"/indian-river-direct/*",
|
|
||||||
"/cart",
|
|
||||||
"/cart/*",
|
|
||||||
"/checkout",
|
|
||||||
"/checkout/*",
|
|
||||||
// Error pages
|
|
||||||
"/error",
|
|
||||||
"/not-found",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Admin routes that require auth
|
|
||||||
const adminRoutes = ["/admin", "/water/admin"];
|
|
||||||
|
|
||||||
// Wholesale routes
|
|
||||||
const wholesaleRoutes = ["/wholesale"];
|
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
|
||||||
const { pathname } = request.nextUrl;
|
|
||||||
|
|
||||||
// Check if route is public
|
|
||||||
const isPublicRoute = publicRoutes.some(
|
|
||||||
(route) => pathname === route || pathname.startsWith(route.replace("/*", ""))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isPublicRoute) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for auth cookie (Supabase session)
|
|
||||||
const hasAuthCookie =
|
|
||||||
request.cookies.get("rc_auth_uid")?.value ||
|
|
||||||
request.cookies.get("rc_uid")?.value ||
|
|
||||||
request.cookies.get("dev_session")?.value;
|
|
||||||
|
|
||||||
if (!hasAuthCookie) {
|
|
||||||
// Redirect to login
|
|
||||||
const loginUrl = new URL("/login", request.url);
|
|
||||||
loginUrl.searchParams.set("redirect", pathname);
|
|
||||||
return NextResponse.redirect(loginUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for admin routes (may need additional role checking)
|
|
||||||
const isAdminRoute = adminRoutes.some((route) => pathname.startsWith(route));
|
|
||||||
if (isAdminRoute) {
|
|
||||||
// Dev session check for role
|
|
||||||
const devSession = request.cookies.get("dev_session")?.value;
|
|
||||||
if (devSession === "store_employee") {
|
|
||||||
// Store employees have limited admin access
|
|
||||||
// More granular checks happen in the page components
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add security headers to all responses
|
|
||||||
const response = NextResponse.next();
|
|
||||||
|
|
||||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
||||||
response.headers.set("X-Frame-Options", "DENY");
|
|
||||||
response.headers.set("X-XSS-Protection", "1; mode=block");
|
|
||||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
// Skip Next.js internals and all files in the _next directory
|
|
||||||
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { authConfig } from "@/auth.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16).
|
||||||
|
* This is the single source of truth for route protection. The legacy
|
||||||
|
* `src/middleware.ts` has been deleted (Next.js only runs one).
|
||||||
|
*
|
||||||
|
* Why an `auth` wrapper instead of a hand-rolled `NextResponse.next()`?
|
||||||
|
* 1. Auth.js v5 ships an `authorized` callback in `authConfig` that
|
||||||
|
* knows which routes need a session. We reuse it here at the edge.
|
||||||
|
* 2. It auto-populates `request.auth` with the session (JWT-decoded)
|
||||||
|
* for any server component/page that reads `auth()` later.
|
||||||
|
*
|
||||||
|
* Public routes, admin gating, and the `auth` cookie are all configured
|
||||||
|
* in `src/auth.config.ts`.
|
||||||
|
*/
|
||||||
|
const { auth } = NextAuth(authConfig);
|
||||||
|
|
||||||
|
export default auth;
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Run on /admin and the protected example, plus /login so the
|
||||||
|
// `authorized` callback can bounce already-signed-in users away from it.
|
||||||
|
matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"],
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user