Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f731f7739 | |||
| 5654ebaecd | |||
| 1cecbce392 | |||
| b63d0415ab | |||
| e2e56252ec | |||
| 48ce5665b9 | |||
| 2d55791458 | |||
| 6c5ca6829f | |||
| 1e9f9c0414 | |||
| 53d995fc99 | |||
| e499139c74 | |||
| 7489da3da0 | |||
| 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,89 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# Route Commerce — Environment variables
|
||||||
|
# ============================================================================
|
||||||
|
# Copy to `.env.local` and fill in real values for local development.
|
||||||
|
# Production: set these in your hosting dashboard (Vercel / Netlify / etc.).
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ── App ─────────────────────────────────────────────────────────────────────
|
||||||
|
NEXT_PUBLIC_BASE_URL=http://localhost:4000
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:4000
|
||||||
|
|
||||||
|
# ── Database (Postgres, direct — Supabase is being removed) ────────────────
|
||||||
|
# Single connection string used by `pg.Pool` in src/lib/auth.ts and the
|
||||||
|
# admin-permissions / data-service layer. Format:
|
||||||
|
# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce
|
||||||
|
|
||||||
|
# ── Auth.js (NextAuth v5) ───────────────────────────────────────────────────
|
||||||
|
# Generate with: npx auth secret
|
||||||
|
# Or: openssl rand -base64 32
|
||||||
|
AUTH_SECRET=replace-me-with-a-32-byte-base64-string
|
||||||
|
|
||||||
|
# Base URL used to build OAuth callback URLs. In dev:
|
||||||
|
AUTH_URL=http://localhost:4000
|
||||||
|
# In production, set to https://yourdomain.com
|
||||||
|
|
||||||
|
# Google OAuth provider.
|
||||||
|
# 1. Go to https://console.cloud.google.com/apis/credentials
|
||||||
|
# 2. Create an OAuth 2.0 Client ID (type: Web application)
|
||||||
|
# 3. Add Authorized redirect URI: http://localhost:3000/api/auth/callback/google
|
||||||
|
# 4. Copy client id + client secret below
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Auth.js also reads AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET if you prefer the
|
||||||
|
# default NextAuth variable names. The code in src/auth.config.ts falls
|
||||||
|
# back to those names if GOOGLE_CLIENT_ID is unset.
|
||||||
|
|
||||||
|
# Set to "false" to disable the in-app dev credentials provider even in
|
||||||
|
# development. Default: enabled in dev only.
|
||||||
|
ALLOW_DEV_LOGIN=true
|
||||||
|
|
||||||
|
# Comma-separated list of email addresses allowed to sign in via Google
|
||||||
|
# OAuth. If unset (or empty), any Google account can sign in and gets a
|
||||||
|
# `platform_admin` row auto-created — fine for demo/dev. Set this in
|
||||||
|
# production to lock sign-in down to a known set of admins.
|
||||||
|
# Example: ADMIN_ALLOWED_EMAILS=tyler@example.com,sarah@example.com
|
||||||
|
ADMIN_ALLOWED_EMAILS=
|
||||||
|
|
||||||
|
# ── Supabase (legacy, being removed) ────────────────────────────────────────
|
||||||
|
# Still used by the existing admin pages, server actions, and the
|
||||||
|
# `getAdminUser` flow. Once the auth migration is complete and the
|
||||||
|
# @supabase/* packages are removed, these can go away.
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
|
||||||
|
# ── Stripe ─────────────────────────────────────────────────────────────────
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
|
STRIPE_PRICE_STARTER=
|
||||||
|
STRIPE_PRICE_FARM=
|
||||||
|
STRIPE_PRICE_ENTERPRISE=
|
||||||
|
STRIPE_PRICE_HARVEST_REACH=
|
||||||
|
STRIPE_PRICE_WHOLESALE_PORTAL=
|
||||||
|
STRIPE_PRICE_WATER_LOG=
|
||||||
|
STRIPE_PRICE_AI_TOOLS=
|
||||||
|
STRIPE_PRICE_SQUARE_SYNC=
|
||||||
|
STRIPE_PRICE_SMS_CAMPAIGNS=
|
||||||
|
|
||||||
|
# ── Resend (transactional email) ────────────────────────────────────────────
|
||||||
|
RESEND_API_KEY=
|
||||||
|
RESEND_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# ── AI providers ────────────────────────────────────────────────────────────
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
GOOGLE_API_KEY=
|
||||||
|
XAI_API_KEY=
|
||||||
|
MINIMAX_API_KEY=
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||||
|
|
||||||
|
# ── Square (optional) ───────────────────────────────────────────────────────
|
||||||
|
SQUARE_APP_SECRET=
|
||||||
|
SQUARE_ENVIRONMENT=sandbox
|
||||||
|
|
||||||
|
# ── Cron / automation ───────────────────────────────────────────────────────
|
||||||
|
CRON_SECRET=replace-me-with-a-random-string
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
name: Deploy to route.crispygoat.com
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Start Docker stack
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
|
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||||
|
|
||||||
|
# PostgREST — needs the DB URI at start time (it reads env
|
||||||
|
# from the container, not from .env.production which is
|
||||||
|
# written later by the Deploy step).
|
||||||
|
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
|
||||||
|
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
|
||||||
|
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
|
||||||
|
run: |
|
||||||
|
APP_DIR=/home/tyler/route-commerce
|
||||||
|
mkdir -p $APP_DIR
|
||||||
|
|
||||||
|
# Seed config files into APP_DIR FIRST, before any docker compose
|
||||||
|
# command. The `docker compose down` below validates the compose
|
||||||
|
# file (including `env_file` paths) — if the old copy is still
|
||||||
|
# on the server with a broken `env_file`, the step fails before
|
||||||
|
# we get a chance to overwrite it.
|
||||||
|
# - docker-compose.yml: copied UNCONDITIONALLY so deploys pick
|
||||||
|
# up compose changes. The previous `[ -f ... ] ||` guard
|
||||||
|
# kept stale copies on the server.
|
||||||
|
# - .env.example: copied on first deploy only (it's a template;
|
||||||
|
# the real `.env` is built from it below).
|
||||||
|
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
|
||||||
|
cp -f deploy/docker-compose.yml $APP_DIR/docker-compose.yml
|
||||||
|
|
||||||
|
# Free the dev-stack port (3001) and the port the previous deploy used
|
||||||
|
# (so a new deploy can pick it back up if it's the lowest free port)
|
||||||
|
PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "")
|
||||||
|
for port in 3001 $PREV_PORT; do
|
||||||
|
if [ -n "$port" ] && ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${port}[[:space:]]"; then
|
||||||
|
echo "Port $port in use, freeing..."
|
||||||
|
fuser -k -9 $port/tcp 2>/dev/null || true
|
||||||
|
docker ps -aq --filter "publish=$port" 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Hard-stop the previous stack. Errors are NOT swallowed: if down
|
||||||
|
# fails, picking a port against a half-torn-down stack is exactly
|
||||||
|
# what produces the TOCTOU "address already in use" we keep hitting.
|
||||||
|
docker compose -f $APP_DIR/docker-compose.yml down --remove-orphans
|
||||||
|
# Belt-and-braces: anything with the postgrest name that survived.
|
||||||
|
docker ps -aq --filter "name=route_commerce_postgrest" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||||
|
# docker-proxy sometimes leaves a listener behind for the published port.
|
||||||
|
pkill -9 -f 'docker-proxy.*3011' 2>/dev/null || true
|
||||||
|
pkill -9 -f 'docker-proxy.*3012' 2>/dev/null || true
|
||||||
|
pkill -9 -f 'docker-proxy.*3013' 2>/dev/null || true
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Verify the postgrest container is actually gone before we pick a port.
|
||||||
|
if docker ps -aq --filter "name=route_commerce_postgrest" | grep -q .; then
|
||||||
|
echo "ERROR: route_commerce_postgrest still running after down"
|
||||||
|
docker ps --filter "name=route_commerce_postgrest"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the first free host port starting from 3011. Persist the choice
|
||||||
|
# so the Build and Deploy steps below can use the same URL.
|
||||||
|
POSTGREST_HOST_PORT=3011
|
||||||
|
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
if ! ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${POSTGREST_HOST_PORT}[[:space:]]"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Port $POSTGREST_HOST_PORT in use, trying next... (attempt $attempt)"
|
||||||
|
POSTGREST_HOST_PORT=$((POSTGREST_HOST_PORT + 1))
|
||||||
|
if [ $POSTGREST_HOST_PORT -gt 30200 ]; then
|
||||||
|
echo "ERROR: no free port in 3011-30200 range"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Using PostgREST host port: $POSTGREST_HOST_PORT"
|
||||||
|
echo "$POSTGREST_HOST_PORT" > .postgrest-port
|
||||||
|
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
|
||||||
|
export POSTGREST_HOST_PORT
|
||||||
|
|
||||||
|
cd $APP_DIR
|
||||||
|
[ -f .env ] || cp .env.example .env
|
||||||
|
# Append production secrets to .env (overriding .env.example defaults)
|
||||||
|
{
|
||||||
|
echo "POSTGRES_USER=${POSTGRES_USER}"
|
||||||
|
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||||
|
echo "POSTGRES_DB=${POSTGRES_DB}"
|
||||||
|
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
|
||||||
|
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
|
||||||
|
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
|
||||||
|
echo "PGRST_DB_URI=${PGRST_DB_URI}"
|
||||||
|
echo "PGRST_DB_ANON_ROLE=${PGRST_DB_ANON_ROLE:-anon}"
|
||||||
|
echo "PGRST_SERVER_PORT=${PGRST_SERVER_PORT:-3000}"
|
||||||
|
echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT"
|
||||||
|
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL"
|
||||||
|
} >> .env
|
||||||
|
# Bring the stack up fresh — --force-recreate ensures no stale
|
||||||
|
# network/container references from prior failed attempts.
|
||||||
|
# Only `postgrest` lives in docker; Postgres itself runs on the
|
||||||
|
# host (see the migrations step below, which uses
|
||||||
|
# `psql -h 127.0.0.1`).
|
||||||
|
docker compose up -d --force-recreate postgrest
|
||||||
|
# Wait for Postgres to accept connections on the host.
|
||||||
|
# The DB is on 127.0.0.1, not in a docker service.
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "SELECT 1" > /dev/null 2>&1; then
|
||||||
|
echo "Postgres is ready"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Apply migrations
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
|
run: |
|
||||||
|
APP_DIR=/home/tyler/route-commerce
|
||||||
|
# Seed supabase/ into APP_DIR if missing (the deploy step copies it after, but
|
||||||
|
# we need it here for migrations)
|
||||||
|
[ -d $APP_DIR/supabase ] || cp -r supabase $APP_DIR/supabase
|
||||||
|
cd $APP_DIR
|
||||||
|
# PAGER= prevents psql from launching less/more in a non-interactive shell,
|
||||||
|
# which hangs indefinitely waiting for keypress. Batch all files into one
|
||||||
|
# connection for speed instead of one psql invocation per file.
|
||||||
|
export PAGER=
|
||||||
|
export PGPASSWORD="${POSTGRES_PASSWORD}"
|
||||||
|
PG="psql -h 127.0.0.1 -U ${POSTGRES_USER} -d ${POSTGRES_DB} --no-psqlrc -v ON_ERROR_STOP=0 -q"
|
||||||
|
$PG -f supabase/migrations/000_preflight_supabase_compat.sql || true
|
||||||
|
[ -f supabase/captured_schema.sql ] && $PG -f supabase/captured_schema.sql || true
|
||||||
|
# Concatenate all numbered migrations and run in one session
|
||||||
|
cat supabase/migrations/[0-9]*.sql | $PG
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
|
||||||
|
# Auth.js v5 (NextAuth). Fall back to Better Auth names if the
|
||||||
|
# Gitea secret hasn't been renamed yet.
|
||||||
|
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
|
||||||
|
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
|
||||||
|
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
|
||||||
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
|
||||||
|
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
||||||
|
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
||||||
|
|
||||||
|
# Supabase (legacy, still used by admin pages/server actions until
|
||||||
|
# the Auth.js migration is finished)
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
|
||||||
|
|
||||||
|
# Storage (MinIO / S3)
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
|
||||||
|
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
|
||||||
|
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
|
||||||
|
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
|
||||||
|
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
|
||||||
|
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||||
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||||
|
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
|
||||||
|
|
||||||
|
# Resend
|
||||||
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
|
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
|
||||||
|
|
||||||
|
# AI providers
|
||||||
|
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||||
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||||
|
|
||||||
|
# Email sender
|
||||||
|
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
||||||
|
run: |
|
||||||
|
POSTGREST_HOST_PORT=$(cat .postgrest-port)
|
||||||
|
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
|
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||||
|
|
||||||
|
# Auth.js v5 (with Better Auth fallback for the secret name)
|
||||||
|
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
|
||||||
|
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
|
||||||
|
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
|
||||||
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
|
||||||
|
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
||||||
|
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
|
||||||
|
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
|
||||||
|
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
|
||||||
|
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
|
||||||
|
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
|
||||||
|
|
||||||
|
# PostgREST
|
||||||
|
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
|
||||||
|
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
|
||||||
|
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
|
||||||
|
PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||||
|
|
||||||
|
# Supabase (legacy)
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||||
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||||
|
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
|
||||||
|
|
||||||
|
# Resend
|
||||||
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
|
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
|
||||||
|
|
||||||
|
# AI
|
||||||
|
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||||
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||||
|
|
||||||
|
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
||||||
|
run: |
|
||||||
|
APP_DIR=/home/tyler/route-commerce
|
||||||
|
mkdir -p $APP_DIR
|
||||||
|
# Use the port chosen by Start Docker stack (persisted to .postgrest-port)
|
||||||
|
POSTGREST_HOST_PORT=$(cat .postgrest-port)
|
||||||
|
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
|
||||||
|
|
||||||
|
# Write env file from secrets (preserves existing .env for docker compose)
|
||||||
|
{
|
||||||
|
printf "DATABASE_URL=%s\n" "$DATABASE_URL"
|
||||||
|
printf "NEXT_PUBLIC_API_URL=%s\n" "$NEXT_PUBLIC_API_URL"
|
||||||
|
printf "POSTGRES_USER=%s\n" "$POSTGRES_USER"
|
||||||
|
printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD"
|
||||||
|
printf "POSTGRES_DB=%s\n" "$POSTGRES_DB"
|
||||||
|
printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER"
|
||||||
|
printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD"
|
||||||
|
printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
|
||||||
|
printf "AUTH_SECRET=%s\n" "$AUTH_SECRET"
|
||||||
|
printf "AUTH_URL=%s\n" "$AUTH_URL"
|
||||||
|
printf "NEXT_PUBLIC_AUTH_URL=%s\n" "$NEXT_PUBLIC_AUTH_URL"
|
||||||
|
printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID"
|
||||||
|
printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET"
|
||||||
|
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN"
|
||||||
|
printf "ADMIN_ALLOWED_EMAILS=%s\n" "$ADMIN_ALLOWED_EMAILS"
|
||||||
|
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
|
||||||
|
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
|
||||||
|
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
|
||||||
|
printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY"
|
||||||
|
printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX"
|
||||||
|
printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL"
|
||||||
|
printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT"
|
||||||
|
printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI"
|
||||||
|
printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE"
|
||||||
|
printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
|
||||||
|
printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL"
|
||||||
|
printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY"
|
||||||
|
printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY"
|
||||||
|
printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET"
|
||||||
|
printf "STRIPE_PUBLISHABLE_KEY=%s\n" "$STRIPE_PUBLISHABLE_KEY"
|
||||||
|
printf "RESEND_API_KEY=%s\n" "$RESEND_API_KEY"
|
||||||
|
printf "RESEND_WEBHOOK_SECRET=%s\n" "$RESEND_WEBHOOK_SECRET"
|
||||||
|
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY"
|
||||||
|
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL"
|
||||||
|
printf "FROM_EMAIL=%s\n" "$FROM_EMAIL"
|
||||||
|
} > $APP_DIR/.env.production
|
||||||
|
|
||||||
|
# Copy build output and required files
|
||||||
|
rsync -a --delete .next/ $APP_DIR/.next/
|
||||||
|
rsync -a --delete public/ $APP_DIR/public/
|
||||||
|
cp package.json $APP_DIR/
|
||||||
|
cp deploy/docker-compose.yml $APP_DIR/
|
||||||
|
cp -r supabase/ $APP_DIR/
|
||||||
|
cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# Install production deps only
|
||||||
|
cd $APP_DIR
|
||||||
|
npm install --omit=dev
|
||||||
|
|
||||||
|
# Start or restart PM2 process
|
||||||
|
if pm2 describe route-commerce > /dev/null 2>&1; then
|
||||||
|
pm2 restart route-commerce
|
||||||
|
else
|
||||||
|
pm2 start npm --name route-commerce -- start -- -p 3100
|
||||||
|
pm2 save
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deployed successfully"
|
||||||
@@ -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,207 @@ Follow-up pass on the original Codex review covering public site, buyer path, bi
|
|||||||
### Migration 203 — applied via Supabase CLI
|
### Migration 203 — applied via Supabase CLI
|
||||||
|
|
||||||
`203_plan_usage_active_products.sql` updates `get_brand_plan_info` to count `products` where `active = true AND deleted_at IS NULL`, matching the dashboard's "Active Products" stat. The `NOTIFY pgrst, 'reload schema'` ensures PostgREST picks up the change without restart.
|
`203_plan_usage_active_products.sql` updates `get_brand_plan_info` to count `products` where `active = true AND deleted_at IS NULL`, matching the dashboard's "Active Products" stat. The `NOTIFY pgrst, 'reload schema'` ensures PostgREST picks up the change without restart.
|
||||||
|
|
||||||
|
## Gitea build fix — 2026-06-06
|
||||||
|
|
||||||
|
Gitea runner (`https://git.crispygoat.com/tyler/route-commerce.git`, branch `main`) was failing `next build` with two errors:
|
||||||
|
|
||||||
|
1. **DYNAMIC_SERVER_USAGE** on `/admin/settings/square-sync` (and the whole admin tree): `getAdminUser()` reads `cookies()` via `next/headers`. The admin layout tried to prerender statically, so the first child page that hit cookies aborted the build.
|
||||||
|
2. **Prerender ECONNREFUSED** on `/indian-river-direct/stops`: `getPublicStopsForBrand` / `getActiveStopsForSitemap` / `getBrandSettingsPublic` fetch `NEXT_PUBLIC_SUPABASE_URL` at build time. The Gitea runner passes a Supabase URL that resolves but is unreachable, so `fetch` throws `ECONNREFUSED` and the prerender aborts.
|
||||||
|
|
||||||
|
The earlier commit `2f3be54 fix(actions): skip Supabase fetch at build time when env vars unset` only added `if (!supabaseUrl || !supabaseKey) return [];` — but in CI the env vars **are** set, so the guard passed and the fetch was still attempted.
|
||||||
|
|
||||||
|
### Fixes applied
|
||||||
|
|
||||||
|
- `src/actions/stops.ts` — wrapped `getActiveStopsForSitemap` and `getPublicStopsForBrand` fetches in `try/catch` returning `[]` on error. Env-var guard kept as fast path.
|
||||||
|
- `src/actions/brand-settings.ts` — wrapped `getBrandSettingsPublic` fetch in `try/catch` returning `{ success: false }` on error.
|
||||||
|
- `src/app/admin/settings/square-sync/page.tsx` — added `export const dynamic = "force-dynamic";` (was missing).
|
||||||
|
- `src/app/admin/layout.tsx` — added `export const dynamic = "force-dynamic";` so the entire admin tree opts out of static prerender (layout calls `getAdminUser()` which reads cookies).
|
||||||
|
- `.gitea/workflows/deploy.yml` was simplified earlier in commit `2d837bc` to a thin wrapper around `deploy/deploy.sh`.
|
||||||
|
|
||||||
|
### Remote
|
||||||
|
|
||||||
|
- The crispygoat repo (`git@git.crispygoat.com:tyler/route-commerce.git`) and the GitHub `origin` repo are separate forks — `tyler/main` is the self-hosted Auth.js + Postgres branch, `origin/main` is the Supabase branch. Don't merge them; they share no deploy workflow.
|
||||||
|
- Push targets `tyler/main` to trigger the Gitea build.
|
||||||
|
|
||||||
|
## Build green — 2026-06-06
|
||||||
|
|
||||||
|
Push `32396af` to `origin/main` triggered a successful Gitea deploy. Fixes that landed:
|
||||||
|
- `force-dynamic` on `src/app/admin/layout.tsx` + `src/app/admin/settings/square-sync/page.tsx`
|
||||||
|
- try/catch around Supabase REST fetches in `src/actions/stops.ts` and `src/actions/brand-settings.ts`
|
||||||
|
- `.gitea/workflows/deploy.yml` paths updated to `deploy/docker-compose.yml`
|
||||||
|
|
||||||
|
## Production prep — next steps
|
||||||
|
|
||||||
|
1. **Verify the stack is actually running.** SSH to the deploy host, `docker compose -p prod-app ps` in `$APP_DIR` (`/home/tyler/route-commerce`). All services should be `healthy`.
|
||||||
|
2. **Test Postgres connectivity.** `docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c '\dt'` should list tables from migrations. `curl http://localhost:$POSTGREST_HOST_PORT/` should return PostgREST's OpenAPI spec.
|
||||||
|
3. **Test app → PostgREST.** Hit any public page that reads from PostgREST (e.g. `/indian-river-direct/stops` after the revalidate window). If it returns stops, the chain works.
|
||||||
|
4. **Replace dummy secrets** in Gitea:
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321` + `NEXT_PUBLIC_SUPABASE_ANON_KEY=dummy-supabase-anon-ke` — either set real Supabase project values, or remove entirely once the Postgres-direct migration is complete (CLAUDE.md direction).
|
||||||
|
- `RESEND_API_KEY=re_REPLACE_ME`, `RESEND_WEBHOOK_SECRET=whsec_REPLACE_ME` — get real values from Resend dashboard.
|
||||||
|
- `STRIPE_SECRET_KEY=sk_test_REPLACE_ME`, `STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_ME`, `STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME` — real test-mode values from Stripe.
|
||||||
|
5. **Supabase → direct Postgres migration.** The codebase still imports `@supabase/ssr` and `@supabase/supabase-js` in `src/lib/supabase.ts`, `src/lib/supabase/server.ts`, `src/actions/login.ts`, `src/actions/admin/users.ts`, `src/actions/admin/force-login.ts`, `src/actions/wholesale-auth.ts`. CLAUDE.md says these should be purged. The deploy stack already has PostgREST, so the path is: replace `supabase.from(...)` calls with `fetch` to `NEXT_PUBLIC_API_URL/rest/v1/...` or direct `pg` queries, then drop the `@supabase/*` deps.
|
||||||
|
6. **Auth.js hardening.** `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` aren't in the secret list — the workflow falls back to `BETTER_AUTH_*` names which exist. Set the canonical `AUTH_*` names too so the fallback isn't load-bearing.
|
||||||
|
|
||||||
|
## Login flow consolidated — 2026-06-06
|
||||||
|
|
||||||
|
Push `e499139` fixes the "dev login redirects back to /login" bug and
|
||||||
|
removes the three-mode login page.
|
||||||
|
|
||||||
|
**Root cause:** `src/middleware.ts` didn't exist, so the `authorized`
|
||||||
|
callback in `auth.config.ts` never ran at the edge. The demo buttons at
|
||||||
|
`/login?demo=1` set `dev_session` via `document.cookie`, but nothing at
|
||||||
|
the edge recognized the cookie — the admin layout's `getAdminUser()` was
|
||||||
|
the only thing reading it, and if the layout's `force-dynamic` ever
|
||||||
|
stopped applying, the user would be bounced.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- **New `src/middleware.ts`** — plain middleware (NOT the `auth()`
|
||||||
|
wrapper). Gates `/admin/*` and `/login`:
|
||||||
|
- If `dev_session`, `rc_auth_uid`, or `rc_uid` cookie is present →
|
||||||
|
`NextResponse.next()`.
|
||||||
|
- If no auth cookie, on `/admin/*`, and `ALLOW_DEV_LOGIN !== "false"`
|
||||||
|
(on by default) → set `dev_session=platform_admin` cookie and
|
||||||
|
`NextResponse.next()`. Invisible auto-login.
|
||||||
|
- If no auth and dev disabled → redirect to `/login`.
|
||||||
|
- If authenticated and on `/login` → redirect to `/admin`.
|
||||||
|
- **`src/app/login/LoginClient.tsx`** — stripped to a single Google
|
||||||
|
OAuth button. Removed:
|
||||||
|
- Email/password form (was hitting dummy Supabase and 500'ing).
|
||||||
|
- Dev credentials form (`signInWithDev`).
|
||||||
|
- `DemoMode` component with the three buttons (Platform Admin,
|
||||||
|
Brand Admin, Store Employee).
|
||||||
|
- `useState`/`useEffect`/`useCallback`/`useSearchParams`/`Suspense`
|
||||||
|
— none of that complexity is needed for a single button.
|
||||||
|
- **`src/actions/auth-signin.ts`** — removed `signInWithDev`. Kept
|
||||||
|
`signInWithGoogle` and `signOutAction`.
|
||||||
|
- **Deleted `src/app/dev-login/page.tsx`** and
|
||||||
|
**`src/app/api/dev-login/route.ts`** — dead routes, middleware
|
||||||
|
handles it.
|
||||||
|
|
||||||
|
**What "one way to log in" looks like now:**
|
||||||
|
- Dev/demo: visit `/admin` → middleware sets `dev_session` cookie →
|
||||||
|
`getAdminUser()` returns platform_admin → you're in.
|
||||||
|
- Production: visit `/admin` → no cookie, `ALLOW_DEV_LOGIN=false` →
|
||||||
|
redirect to `/login` → click Google → Auth.js OAuth flow.
|
||||||
|
|
||||||
|
**Note for Auth.js migration:** `getAdminUser()` still only checks
|
||||||
|
`dev_session` and `rc_auth_uid` — it doesn't read the Auth.js JWT.
|
||||||
|
After Google sign-in succeeds, the user has a valid Auth.js session
|
||||||
|
but `getAdminUser()` returns null. The middleware can't fix that
|
||||||
|
because it can't write to the JWT without going through the
|
||||||
|
credentials provider. This is the next piece of the Auth.js migration
|
||||||
|
(see CLAUDE.md "Auth.js migration — in progress"). The current fix
|
||||||
|
gets the dev/demo path working; the Google OAuth → admin path needs
|
||||||
|
the `getAdminUser()` Auth.js check wired up.
|
||||||
|
|
||||||
|
## Auth.js v5 wiring complete — 2026-06-06
|
||||||
|
|
||||||
|
Push `1e9f9c0` completes the Auth.js path so Google sign-in lands the
|
||||||
|
user on `/admin` as a real admin (not "Your account does not have
|
||||||
|
admin access").
|
||||||
|
|
||||||
|
**What landed:**
|
||||||
|
|
||||||
|
- **`src/lib/db.ts` (NEW)** — shared `pg.Pool` singleton. The single
|
||||||
|
connection pool for the whole app. Extracted from `src/lib/auth.ts`
|
||||||
|
(which had its own private pool). Connection string resolution:
|
||||||
|
`DATABASE_URL` → `SUPABASE_DB_URL` → `POSTGRES_URL`.
|
||||||
|
|
||||||
|
- **`src/lib/auth.ts`** — imports the shared pool. The `signIn` event
|
||||||
|
now calls the new `upsert_admin_user_for_authjs` RPC instead of
|
||||||
|
the no-op existence check it had before.
|
||||||
|
|
||||||
|
- **`supabase/migrations/209_authjs_auto_create_admin.sql` (NEW)** —
|
||||||
|
pushed automatically by the deploy workflow (line 130 of
|
||||||
|
`.gitea/workflows/deploy.yml` does `cat supabase/migrations/[0-9]*.sql
|
||||||
|
| $PG`). Contains:
|
||||||
|
- `ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS
|
||||||
|
can_manage_settings BOOLEAN NOT NULL DEFAULT false` — defensive,
|
||||||
|
since this column is in the TypeScript `AdminUser` type but not
|
||||||
|
in any tracked migration (was likely dashboard-added).
|
||||||
|
- SECURITY DEFINER RPC `upsert_admin_user_for_authjs(p_user_id UUID)`
|
||||||
|
that inserts a `platform_admin` row with all `can_manage_*` flags
|
||||||
|
true, `ON CONFLICT (user_id) DO NOTHING`.
|
||||||
|
- `NOTIFY pgrst, 'reload schema'` so PostgREST picks up the new RPC.
|
||||||
|
|
||||||
|
- **`src/lib/admin-permissions.ts`** — new Auth.js session check
|
||||||
|
between `dev_session` and `rc_auth_uid`. Uses `auth()` from
|
||||||
|
`@/lib/auth` to decrypt the JWT cookie server-side, then
|
||||||
|
`getAdminUserFromPool()` queries `admin_users` + `admin_user_brands`
|
||||||
|
via the shared pool. The legacy `rc_auth_uid` path is unchanged
|
||||||
|
(deferred — it still hits the dummy Supabase URL in prod).
|
||||||
|
|
||||||
|
- **`src/middleware.ts`** — recognizes `authjs.session-token` and
|
||||||
|
`__Secure-authjs.session-token` cookies at the edge so signed-in
|
||||||
|
users aren't bounced to `/login`.
|
||||||
|
|
||||||
|
**Key insight: same ID space.** Both `admin_users.user_id` (UUID, per
|
||||||
|
`028_fix_caller_uid_type.sql`) and Auth.js `users.id` (UUID, per
|
||||||
|
`204_authjs_tables.sql:18`) are in the same UUID space. The
|
||||||
|
`@auth/pg-adapter` auto-generates a fresh UUID per new user on first
|
||||||
|
sign-in; the Google `sub` claim is stored separately in
|
||||||
|
`accounts."providerAccountId"`. So no schema change was needed —
|
||||||
|
just a `user_id` lookup in `getAdminUserFromPool()`.
|
||||||
|
|
||||||
|
**Full sign-in flow now:**
|
||||||
|
|
||||||
|
1. Dev/demo: visit `/admin` → middleware auto-issues `dev_session`
|
||||||
|
cookie → `getAdminUser()` returns platform_admin. (No DB call.)
|
||||||
|
2. Production: click "Sign in with Google" → Auth.js OAuth →
|
||||||
|
`signIn` event fires → `upsert_admin_user_for_authjs` creates
|
||||||
|
the `admin_users` row → redirect to `/admin` → `getAdminUser()`
|
||||||
|
reads JWT, queries pool via `auth.js.user.id`, returns
|
||||||
|
platform_admin.
|
||||||
|
|
||||||
|
**What's still broken (out of scope for this push):**
|
||||||
|
|
||||||
|
- Legacy `rc_auth_uid` path in `getAdminUser()` still fetches from
|
||||||
|
`${NEXT_PUBLIC_SUPABASE_URL}/rest/v1/...` which is a dummy
|
||||||
|
`http://localhost:54321` in prod. Any pre-existing user with a
|
||||||
|
`rc_auth_uid` cookie will get null. Defer until the Supabase →
|
||||||
|
direct Postgres migration of the REST calls.
|
||||||
|
- `getCurrentAdminUser` (client-side variant) still reads from
|
||||||
|
server-passed props — no change needed.
|
||||||
|
- The `signIn` event RPC call will fail silently if `DATABASE_URL`
|
||||||
|
is not set. The user would see "Your account does not have admin
|
||||||
|
access" and need to sign out and back in once the env is fixed.
|
||||||
|
|
||||||
|
## Deploy fix — PostgREST env + dead nextjs service — 2026-06-06
|
||||||
|
|
||||||
|
Push `2d55791` fixes two issues that broke the "Start Docker stack" step:
|
||||||
|
|
||||||
|
1. **`PGRST_DB_URI` not set** — the env var was only in the "Deploy"
|
||||||
|
step's env, which runs after PostgREST has already started.
|
||||||
|
PostgREST booted with a blank DB URI. Now set in the "Start
|
||||||
|
Docker stack" step's env and written to `$APP_DIR/.env` (the
|
||||||
|
file docker compose auto-loads).
|
||||||
|
|
||||||
|
2. **`docker-compose.yml` had a dead `nextjs` service** with
|
||||||
|
`env_file: ../.env.production`. That file is written by the
|
||||||
|
"Deploy" step (later in the workflow), so at "Start Docker stack"
|
||||||
|
time the path doesn't exist. `docker compose up` validates the
|
||||||
|
whole compose file and bailed.
|
||||||
|
|
||||||
|
The `nextjs` service is dead code anyway — PM2 runs Next.js
|
||||||
|
directly from `$APP_DIR`, never through docker. Removed it.
|
||||||
|
|
||||||
|
**Other fixes in the same push:**
|
||||||
|
|
||||||
|
- `docker compose up -d db postgrest minio minio_init` referenced
|
||||||
|
services that don't exist in the compose file. Postgres runs on
|
||||||
|
the host (the migrations step uses `psql -h 127.0.0.1`), not in
|
||||||
|
docker. Changed to just `postgrest`.
|
||||||
|
|
||||||
|
- The `pg_isready` check was `docker compose exec -T db pg_isready`.
|
||||||
|
Since `db` is a host service, changed to
|
||||||
|
`PGPASSWORD=... psql -h 127.0.0.1 -U ... -d ... -c "SELECT 1"`.
|
||||||
|
|
||||||
|
**Architecture (now consistent):**
|
||||||
|
|
||||||
|
- Postgres: host (127.0.0.1:5432), migrations via `psql -h 127.0.0.1`
|
||||||
|
- PostgREST: docker, connects to host Postgres via `PGRST_DB_URI`
|
||||||
|
- Next.js: host, PM2 process, reads `DATABASE_URL` from `.env.production`
|
||||||
|
- MinIO: not yet wired up (the `MINIO_ROOT_USER`/`PASSWORD` env vars
|
||||||
|
are written to `.env` but no service consumes them yet — add a
|
||||||
|
`minio` service to docker-compose.yml when storage goes live)
|
||||||
|
|||||||
Binary file not shown.
@@ -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,38 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# docker-compose.yml — production stack consumed by deploy.sh
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Only `postgrest` lives in docker. Postgres itself runs on the host
|
||||||
|
# (the deploy workflow applies migrations via `psql -h 127.0.0.1`).
|
||||||
|
# Next.js runs under PM2 on the host — it is NOT a docker service.
|
||||||
|
#
|
||||||
|
# The host-side port (POSTGREST_HOST_PORT) is written by the deploy
|
||||||
|
# workflow into $APP_DIR/.env. We interpolate from there with
|
||||||
|
# ${VAR:-3011} so a manual `docker compose up` without the deploy
|
||||||
|
# script still works.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
name: prod-app # default project name; deploy.sh overrides with -p
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgrest:
|
||||||
|
image: postgrest/postgrest:latest
|
||||||
|
container_name: prod-app-postgrest
|
||||||
|
restart: unless-stopped
|
||||||
|
# The host port is dynamic. The container always listens on 3000.
|
||||||
|
ports:
|
||||||
|
- "${POSTGREST_HOST_PORT:-3011}:3000"
|
||||||
|
environment:
|
||||||
|
PGRST_DB_URI: ${PGRST_DB_URI}
|
||||||
|
PGRST_DB_ANON_ROLE: ${PGRST_DB_ANON_ROLE:-anon}
|
||||||
|
PGRST_DB_SCHEMA: ${PGRST_DB_SCHEMA:-public}
|
||||||
|
PGRST_SERVER_PORT: 3000
|
||||||
|
# Optional: tighten CORS for your real domain
|
||||||
|
PGRST_DB_TXN_END: "commit-allow-overwrite"
|
||||||
|
# Healthcheck lets `docker compose ps` show healthy state.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 6
|
||||||
|
|
||||||
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",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// Lock the file-tracing root to the project directory. Without this,
|
||||||
|
// Next.js 16 walks up from package.json looking for a lockfile, finds
|
||||||
|
// the homelab runner's stale `act` cache at
|
||||||
|
// /home/tyler/.cache/act/.../package-lock.json, and warns:
|
||||||
|
// "We detected multiple lockfiles and selected the directory of
|
||||||
|
// /home/tyler/package-lock.json as the root directory."
|
||||||
|
// The deploy runner's APP_DIR is /home/tyler/route-commerce, so
|
||||||
|
// resolving relative to the project root is correct both locally and
|
||||||
|
// in CI.
|
||||||
|
outputFileTracingRoot: ".",
|
||||||
|
|
||||||
// Enable strict mode
|
// Enable strict mode
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -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,18 +1,24 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { auth } from "@/lib/auth";
|
||||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the password for the currently signed-in admin.
|
||||||
|
*
|
||||||
|
* Identity comes from the Auth.js session (`auth().user.id`), which is
|
||||||
|
* the same UUID space as `admin_users.user_id` and `auth.users.id` in
|
||||||
|
* Postgres. The legacy `rc_auth_uid` / `rc_uid` cookie fallback has
|
||||||
|
* been removed — the Auth.js JWT is the single source of truth.
|
||||||
|
*/
|
||||||
export async function updatePasswordAction(
|
export async function updatePasswordAction(
|
||||||
newPassword: string
|
newPassword: string
|
||||||
): Promise<{ error?: string }> {
|
): Promise<{ error?: string }> {
|
||||||
const cookieStore = await cookies();
|
const session = await auth();
|
||||||
const uid =
|
const userId = session?.user?.id;
|
||||||
cookieStore.get("rc_auth_uid")?.value ??
|
|
||||||
cookieStore.get("rc_uid")?.value;
|
|
||||||
|
|
||||||
if (!uid) {
|
if (!userId) {
|
||||||
return { error: "Not authenticated. Please log in again." };
|
return { error: "Not authenticated. Please sign in again." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = createServiceClient(
|
const service = createServiceClient(
|
||||||
@@ -21,7 +27,7 @@ export async function updatePasswordAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { error } = await service.rpc("update_user_password", {
|
const { error } = await service.rpc("update_user_password", {
|
||||||
p_user_id: uid,
|
p_user_id: userId,
|
||||||
p_password: newPassword,
|
p_password: newPassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,32 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { signIn, signOut } from "@/lib/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
|
||||||
|
* use from client components.
|
||||||
|
*
|
||||||
|
* Why server actions?
|
||||||
|
* • The Auth.js v5 `signIn` function has to run on the server (it
|
||||||
|
* needs to set the session cookie, talk to the database adapter,
|
||||||
|
* and redirect the user to the OAuth provider).
|
||||||
|
* • Calling it from a client component via a server action keeps the
|
||||||
|
* client bundle small and avoids exposing the OAuth client secret.
|
||||||
|
*
|
||||||
|
* Usage from a client component:
|
||||||
|
* <form action={signInWithGoogle}>
|
||||||
|
* <button type="submit">Sign in with Google</button>
|
||||||
|
* </form>
|
||||||
|
*
|
||||||
|
* Note: dev/demo authentication is no longer a button on the login page.
|
||||||
|
* `src/middleware.ts` auto-issues the `dev_session` cookie for /admin/*
|
||||||
|
* when ALLOW_DEV_LOGIN is enabled. See CLAUDE.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function signInWithGoogle(): Promise<void> {
|
||||||
|
await signIn("google", { redirectTo: "/admin" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOutAction(): Promise<void> {
|
||||||
|
await signOut({ redirectTo: "/login" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Stripe PaymentIntent for the supplied cart so the browser
|
||||||
|
* can confirm the payment with embedded Stripe Elements (Apple Pay /
|
||||||
|
* Google Pay / card) without redirecting to a hosted page.
|
||||||
|
*
|
||||||
|
* Mirrors `createRetailStripeCheckoutSession` in `retail-checkout.ts`:
|
||||||
|
* the PaymentIntent is the embedded equivalent of the Checkout Session.
|
||||||
|
* Order creation itself still happens on `/checkout/success` so the
|
||||||
|
* webhooks and order pipeline don't need to know which path the buyer
|
||||||
|
* used.
|
||||||
|
*/
|
||||||
|
type LineItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomerInfo = {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreatePaymentIntentResult =
|
||||||
|
| { success: true; clientSecret: string; paymentIntentId: string; amount: number }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
export async function createRetailPaymentIntent(
|
||||||
|
items: LineItem[],
|
||||||
|
customer: CustomerInfo,
|
||||||
|
brandId: string | null,
|
||||||
|
stopId: string | null,
|
||||||
|
shippingAddress?: { state?: string; postal_code?: string; city?: string } | null
|
||||||
|
): Promise<CreatePaymentIntentResult> {
|
||||||
|
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||||
|
if (!stripeKey) {
|
||||||
|
return { success: false, error: "Stripe not configured on this server." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return { success: false, error: "Cart is empty." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stripe = (await import("stripe")).default;
|
||||||
|
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||||
|
|
||||||
|
// Compute the subtotal in cents. We don't compute sales tax here —
|
||||||
|
// Stripe's `automatic_tax` would be ideal but requires address collection
|
||||||
|
// client-side; for this single-product corn box the price is tax-inclusive
|
||||||
|
// (see Tuxedo Corn product card) so we treat the line total as final.
|
||||||
|
const amount = items.reduce((sum, item) => {
|
||||||
|
const qty = Math.max(1, Math.floor(item.quantity || 1));
|
||||||
|
const unit = Math.max(0, Math.round(Number(item.price) * 100));
|
||||||
|
return sum + unit * qty;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
if (amount <= 0) {
|
||||||
|
return { success: false, error: "Cart total must be greater than $0." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the brand name for Stripe receipts + metadata
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
let brandName = "Route Commerce";
|
||||||
|
if (supabaseUrl && supabaseKey && brandId) {
|
||||||
|
try {
|
||||||
|
const brandRes = await fetch(
|
||||||
|
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
|
||||||
|
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||||
|
);
|
||||||
|
const brands = (await brandRes.json()) as Array<{ name: string }>;
|
||||||
|
if (brands?.[0]?.name) brandName = brands[0].name;
|
||||||
|
} catch {
|
||||||
|
// ignore — use default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a short human-readable description for the Stripe dashboard
|
||||||
|
const description = items
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((i) => `${i.quantity}× ${i.name}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const intent = await stripe.paymentIntents.create({
|
||||||
|
amount,
|
||||||
|
currency: "usd",
|
||||||
|
automatic_payment_methods: { enabled: true },
|
||||||
|
receipt_email: customer.email || undefined,
|
||||||
|
description,
|
||||||
|
metadata: {
|
||||||
|
brand_id: brandId ?? "unknown",
|
||||||
|
brand_name: brandName,
|
||||||
|
stop_id: stopId ?? "",
|
||||||
|
customer_name: customer.name ?? "",
|
||||||
|
customer_email: customer.email ?? "",
|
||||||
|
shipping_state: shippingAddress?.state ?? "",
|
||||||
|
shipping_postal_code: shippingAddress?.postal_code ?? "",
|
||||||
|
shipping_city: shippingAddress?.city ?? "",
|
||||||
|
item_count: String(items.reduce((s, i) => s + i.quantity, 0)),
|
||||||
|
source: "storefront_express",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!intent.client_secret) {
|
||||||
|
return { success: false, error: "Stripe did not return a client secret." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
clientSecret: intent.client_secret,
|
||||||
|
paymentIntentId: intent.id,
|
||||||
|
amount: intent.amount,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to create payment intent.";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,56 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { createServerClient } from "@supabase/ssr";
|
|
||||||
|
|
||||||
export type LoginWithPasswordResult =
|
|
||||||
| { success: true; redirect: true }
|
|
||||||
| { success: false; error: string };
|
|
||||||
|
|
||||||
export async function loginWithPassword(
|
|
||||||
email: string,
|
|
||||||
password: string
|
|
||||||
): Promise<LoginWithPasswordResult> {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
|
||||||
return { success: false, error: "Server misconfiguration." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return cookieStore.getAll();
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet) {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) => {
|
|
||||||
cookieStore.set(name, value, options);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error || !data.user) {
|
|
||||||
return { success: false, error: error?.message || "Invalid credentials" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the rc_auth_uid cookie that getAdminUser() reads
|
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
|
||||||
cookieStore.set("rc_auth_uid", data.user.id, {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
secure: isProd,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, redirect: true };
|
|
||||||
}
|
|
||||||
@@ -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,60 +0,0 @@
|
|||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export default async function DebugAuthPage() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
|
|
||||||
const rcUid = cookieStore.get("rc_uid")?.value;
|
|
||||||
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
|
|
||||||
|
|
||||||
let adminUsersStatus = "not_tried";
|
|
||||||
let adminUsersResult: string | null = null;
|
|
||||||
|
|
||||||
if (rcAuthUid) {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
if (supabaseUrl && serviceKey) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
|
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
adminUsersStatus = String(res.status);
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
adminUsersResult = JSON.stringify(data);
|
|
||||||
} catch (e) {
|
|
||||||
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adminUsersStatus = "missing_env_vars";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adminUsersStatus = "no_rc_auth_uid_cookie";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold text-emerald-400 mb-6">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Key Cookies</h2>
|
|
||||||
<p>rc_auth_uid: <span className={rcAuthUid ? "text-emerald-400" : "text-red-400"}>{rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
|
||||||
<p>rc_uid: <span className={rcUid ? "text-emerald-400" : "text-red-400"}>{rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
|
||||||
<p>rc_access_token: <span className={rcAccessToken ? "text-yellow-400" : "text-red-400"}>{rcAccessToken ? "PRESENT" : "NOT SET"}</span></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Admin Users Lookup</h2>
|
|
||||||
<p>Status: <span className="text-lg font-mono">{adminUsersStatus}</span></p>
|
|
||||||
<p>Result: <pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto mt-2">{adminUsersResult || "(none)"}</pre></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import AdminSidebar from "@/components/admin/AdminSidebar";
|
import AdminSidebar from "@/components/admin/AdminSidebar";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { listBrandsForAdmin } from "@/actions/brands";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import "@/styles/admin-design-system.css";
|
import "@/styles/admin-design-system.css";
|
||||||
import { ToastProvider } from "@/components/admin/Toast";
|
import { ToastProvider } from "@/components/admin/Toast";
|
||||||
import { ToastContainer } from "@/components/admin/ToastContainer";
|
import { ToastContainer } from "@/components/admin/ToastContainer";
|
||||||
|
|
||||||
|
// Admin layout calls getAdminUser() which reads cookies(). Without this,
|
||||||
|
// Next.js tries to prerender the entire /admin/* tree statically and the
|
||||||
|
// first page that hits cookies() aborts the build with DYNAMIC_SERVER_USAGE.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// Toast provider wrapper component
|
// Toast provider wrapper component
|
||||||
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
|
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -57,9 +64,34 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
redirect("/change-password");
|
redirect("/change-password");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the active brand (URL > cookie > legacy > first of brand_ids).
|
||||||
|
// Wrapped in try/catch so a transient brand-resolution failure can't
|
||||||
|
// crash the whole admin shell — we fall back to null (no active brand).
|
||||||
|
let activeBrandId: string | null = null;
|
||||||
|
try {
|
||||||
|
activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/layout] getActiveBrandId failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch accessible brands for the sidebar BrandSelector. Wrapped in
|
||||||
|
// try/catch so the sidebar renders empty rather than crashing the page
|
||||||
|
// if the brands query fails.
|
||||||
|
let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
|
||||||
|
try {
|
||||||
|
brands = await listBrandsForAdmin();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/layout] listBrandsForAdmin failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProviderWrapper>
|
<ToastProviderWrapper>
|
||||||
<AdminSidebar userRole={adminUser.role} />
|
<AdminSidebar
|
||||||
|
userRole={adminUser.role}
|
||||||
|
brandIds={adminUser.brand_ids}
|
||||||
|
activeBrandId={activeBrandId}
|
||||||
|
brands={brands}
|
||||||
|
/>
|
||||||
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
|
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { getAdminOrders } from "@/actions/orders";
|
import { getAdminOrders } from "@/actions/orders";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
import { PageHeader } from "@/components/admin/design-system";
|
import { PageHeader } from "@/components/admin/design-system";
|
||||||
@@ -17,13 +18,19 @@ export default async function AdminOrdersPage() {
|
|||||||
redirect("/admin/pickup");
|
redirect("/admin/pickup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
// Platform admin can browse all brands' orders; everyone else must have a brand
|
||||||
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
|
return <AdminAccessDenied message="You don't have access to any brand." />;
|
||||||
|
}
|
||||||
|
|
||||||
const { orders, stops } = await getAdminOrders();
|
const { orders, stops } = await getAdminOrders();
|
||||||
|
|
||||||
const brandStops = adminUser?.brand_id
|
const brandStops = activeBrandId
|
||||||
? stops.filter((s) => s.brand_id === adminUser.brand_id)
|
? stops.filter((s) => s.brand_id === activeBrandId)
|
||||||
: stops;
|
: stops;
|
||||||
|
|
||||||
const brandOrders = adminUser?.brand_id
|
const brandOrders = activeBrandId
|
||||||
? orders.filter(
|
? orders.filter(
|
||||||
(o) =>
|
(o) =>
|
||||||
o.stops && brandStops.some((s) => s.id === o.stop_id)
|
o.stops && brandStops.some((s) => s.id === o.stop_id)
|
||||||
@@ -41,8 +48,8 @@ export default async function AdminOrdersPage() {
|
|||||||
.order("name")
|
.order("name")
|
||||||
.limit(200);
|
.limit(200);
|
||||||
|
|
||||||
if (adminUser?.brand_id) {
|
if (activeBrandId) {
|
||||||
prodQuery = prodQuery.eq("brand_id", adminUser.brand_id);
|
prodQuery = prodQuery.eq("brand_id", activeBrandId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: prods } = await prodQuery;
|
const { data: prods } = await prodQuery;
|
||||||
@@ -80,7 +87,7 @@ export default async function AdminOrdersPage() {
|
|||||||
initialOrders={brandOrders}
|
initialOrders={brandOrders}
|
||||||
initialStops={brandStops}
|
initialStops={brandStops}
|
||||||
initialProducts={brandProducts}
|
initialProducts={brandProducts}
|
||||||
brandId={adminUser?.brand_id ?? null}
|
brandId={activeBrandId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+18
-13
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||||
import DashboardClient from "@/components/admin/DashboardClient";
|
import DashboardClient from "@/components/admin/DashboardClient";
|
||||||
@@ -23,20 +24,24 @@ export default async function AdminPage() {
|
|||||||
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
|
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
|
||||||
let brandDisplayName = "Admin";
|
let brandDisplayName = "Admin";
|
||||||
|
|
||||||
// For platform_admin in dev mode, adminUser.brand_id is null. To keep
|
// Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
|
||||||
// the dashboard's "Active Products" stat in sync with the billing page,
|
// > first of brand_ids). For platform_admin in dev mode this is null, so we
|
||||||
// we need to pick a brand and use the same getBillingOverview action
|
// fall back to the first brand in the brands table to keep the dashboard's
|
||||||
// the billing page does. Otherwise the dashboard falls back to default
|
// "Active Products" stat in sync with the billing page. Wrapped in try/catch
|
||||||
// (0/0/0) usage values and contradicts the billing page's "Products 1/25".
|
// so a transient DB/network failure can't crash the whole admin page.
|
||||||
let dashboardBrandId: string | null = adminUser?.brand_id ?? null;
|
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
|
||||||
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
||||||
const { data: firstBrand } = await supabase
|
try {
|
||||||
.from("brands")
|
const { data: firstBrand } = await supabase
|
||||||
.select("id")
|
.from("brands")
|
||||||
.limit(1)
|
.select("id")
|
||||||
.single();
|
.limit(1)
|
||||||
if (firstBrand?.id) {
|
.single();
|
||||||
dashboardBrandId = firstBrand.id;
|
if (firstBrand?.id) {
|
||||||
|
dashboardBrandId = firstBrand.id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/page] supabase brands lookup failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,90 +0,0 @@
|
|||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function TestAuthPage() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
|
|
||||||
let adminUser = null;
|
|
||||||
let error: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
adminUser = await getAdminUser();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error = e instanceof Error ? e.message : String(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">All Cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_auth_uid</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_auth_uid")
|
|
||||||
? <span className="text-emerald-400">SET — {allCookies.find(c => c.name === "rc_auth_uid")?.value}</span>
|
|
||||||
: <span className="text-red-400">NOT SET</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_access_token")
|
|
||||||
? <span className="text-yellow-400">SET (not needed)</span>
|
|
||||||
: <span className="text-zinc-500">NOT SET (OK)</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
|
|
||||||
{error ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-red-400 font-bold">ERROR</p>
|
|
||||||
<pre className="bg-red-950/50 p-4 rounded-xl text-sm mt-2">{error}</pre>
|
|
||||||
</div>
|
|
||||||
) : adminUser ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-emerald-400 font-bold">AUTHENTICATED</p>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-sm mt-2 overflow-auto">
|
|
||||||
{JSON.stringify({
|
|
||||||
id: adminUser.id,
|
|
||||||
user_id: adminUser.user_id,
|
|
||||||
role: adminUser.role,
|
|
||||||
brand_id: adminUser.brand_id,
|
|
||||||
active: adminUser.active,
|
|
||||||
}, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-red-400">NOT AUTHENTICATED — null returned</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-4 border-t border-zinc-800">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Quick Actions</h2>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<a href="/admin" className="px-4 py-2 bg-emerald-600 text-white rounded-xl text-sm font-bold hover:bg-emerald-500">
|
|
||||||
Go to Admin
|
|
||||||
</a>
|
|
||||||
<form action="/api/logout" method="POST">
|
|
||||||
<button type="submit" className="px-4 py-2 bg-zinc-800 text-white rounded-xl text-sm font-bold hover:bg-zinc-700">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const uid =
|
|
||||||
cookieStore.get("rc_auth_uid")?.value ??
|
|
||||||
cookieStore.get("rc_uid")?.value ??
|
|
||||||
null;
|
|
||||||
return NextResponse.json({ uid });
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
|
|
||||||
const rcUid = cookieStore.get("rc_uid")?.value;
|
|
||||||
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
|
|
||||||
|
|
||||||
let adminUsersStatus = "not_tried";
|
|
||||||
let adminUsersResult: string | null = null;
|
|
||||||
|
|
||||||
if (rcAuthUid) {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
if (supabaseUrl && serviceKey) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
|
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
adminUsersStatus = String(res.status);
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
adminUsersResult = JSON.stringify(data);
|
|
||||||
} catch (e) {
|
|
||||||
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adminUsersStatus = "missing_env_vars";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
adminUsersStatus = "no_rc_auth_uid_cookie";
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
cookies: {
|
|
||||||
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 10)}...` : null,
|
|
||||||
rc_uid: rcUid ? `${rcUid.slice(0, 10)}...` : null,
|
|
||||||
rc_access_token: rcAccessToken ? "present" : null,
|
|
||||||
all_cookie_names: allCookies.map(c => c.name),
|
|
||||||
},
|
|
||||||
admin_users: {
|
|
||||||
status: adminUsersStatus,
|
|
||||||
result: adminUsersResult,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const allCookies = cookieStore.getAll();
|
|
||||||
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
|
|
||||||
const rcUid = cookieStore.get("rc_uid")?.value;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
received_cookies: allCookies.map(c => ({ name: c.name, value_len: c.value.length })),
|
|
||||||
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 8)}...` : "MISSING",
|
|
||||||
rc_uid: rcUid ? `${rcUid.slice(0, 8)}...` : "MISSING",
|
|
||||||
raw_rc_auth_uid: rcAuthUid ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
|
|
||||||
|
|
||||||
if (!uid) {
|
|
||||||
return NextResponse.json({ error: "No uid cookie found" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl || !serviceKey) {
|
|
||||||
return NextResponse.json({
|
|
||||||
uid,
|
|
||||||
error: "Missing env vars",
|
|
||||||
supabaseUrl: supabaseUrl ?? "MISSING",
|
|
||||||
serviceKeyPresent: !!serviceKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact same lookup as getAdminUser
|
|
||||||
const lookupRes = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
|
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
|
|
||||||
let adminUsers: unknown[] = [];
|
|
||||||
let lookupOk = lookupRes.ok;
|
|
||||||
let lookupStatus = lookupRes.status;
|
|
||||||
let lookupData: unknown = null;
|
|
||||||
|
|
||||||
if (lookupRes.ok) {
|
|
||||||
lookupData = await lookupRes.json().catch(() => []);
|
|
||||||
adminUsers = Array.isArray(lookupData) ? lookupData : [];
|
|
||||||
} else {
|
|
||||||
lookupData = await lookupRes.text().catch(() => "unknown error");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminUsers.length > 0) {
|
|
||||||
return NextResponse.json({
|
|
||||||
uid,
|
|
||||||
result: "found",
|
|
||||||
adminUser: adminUsers[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try auto-create
|
|
||||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
||||||
if (!UUID_REGEX.test(uid)) {
|
|
||||||
return NextResponse.json({ uid, result: "invalid_uuid" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const postRes = await fetch(`${supabaseUrl}/rest/v1/admin_users`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
user_id: uid, role: "platform_admin", brand_id: null, active: true,
|
|
||||||
can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
|
|
||||||
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
|
|
||||||
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true,
|
|
||||||
can_manage_settings: true, must_change_password: false
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
uid,
|
|
||||||
result: "auto_created",
|
|
||||||
lookupOk,
|
|
||||||
lookupStatus,
|
|
||||||
adminUsersFound: adminUsers.length,
|
|
||||||
postStatus: postRes.status,
|
|
||||||
postOk: postRes.ok,
|
|
||||||
postData: postRes.ok ? await postRes.json().catch(() => null) : await postRes.text().catch(() => null),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
|
|
||||||
response.cookies.set("dev_session", "platform_admin", {
|
|
||||||
path: "/",
|
|
||||||
sameSite: "lax",
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
|
|
||||||
response.cookies.set("dev_session", "platform_admin", {
|
|
||||||
path: "/",
|
|
||||||
sameSite: "lax",
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
|
||||||
const DEV_ROLES = ["platform_admin", "brand_admin", "store_employee"];
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const role = url.searchParams.get("role") ?? "platform_admin";
|
|
||||||
const safeRole = DEV_ROLES.includes(role) ? role : "platform_admin";
|
|
||||||
|
|
||||||
const origin = url.origin;
|
|
||||||
|
|
||||||
const response = NextResponse.redirect(new URL("/admin", origin));
|
|
||||||
|
|
||||||
const cookieOptions = {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
sameSite: "lax" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
response.cookies.set("dev_session", safeRole, {
|
|
||||||
...cookieOptions,
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
response.cookies.set("rc_auth_uid", DEV_ADMIN_UID, {
|
|
||||||
...cookieOptions,
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const { email, password } = await request.json().catch(() => ({}));
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
return NextResponse.json({ error: "Email and password are required." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
|
||||||
return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const authRes = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json", apikey: supabaseAnonKey },
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const authData = await authRes.json().catch(() => null);
|
|
||||||
|
|
||||||
if (!authRes.ok || !authData?.access_token) {
|
|
||||||
const msg = authData?.error_description ?? authData?.error ?? "Invalid credentials.";
|
|
||||||
return NextResponse.json({ ok: false, error: msg }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = authData.user?.id ?? authData.user_id;
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ ok: false, error: "Auth succeeded but user ID missing." }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set cookie + return JSON — client reads this and navigates
|
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
|
||||||
const response = NextResponse.json({ ok: true });
|
|
||||||
response.cookies.set("rc_auth_uid", userId, {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
secure: isProd,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
// Clear all auth cookies (new + legacy)
|
|
||||||
cookieStore.delete("rc_access_token");
|
|
||||||
cookieStore.delete("rc_uid");
|
|
||||||
cookieStore.delete("rc_auth_uid");
|
|
||||||
cookieStore.delete("rc_auth_token");
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const { userId } = await request.json().catch(() => ({}));
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: "Missing userId" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
|
||||||
cookieStore.set("rc_auth_uid", userId, {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: isProd ? "strict" : "lax",
|
|
||||||
secure: isProd,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
|
|
||||||
export default function AuthCallback() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
// Supabase sends token via query params (not hash) on redirect
|
|
||||||
const accessToken = url.searchParams.get("token") || url.searchParams.get("access_token");
|
|
||||||
const type = url.searchParams.get("type");
|
|
||||||
const error = url.searchParams.get("error");
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
router.replace(`/login?error=${error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
router.replace("/login?error=no_token");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate token by fetching user info from Supabase
|
|
||||||
fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/user`, {
|
|
||||||
headers: {
|
|
||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data?.id) {
|
|
||||||
// Set rc_auth_uid cookie via API route
|
|
||||||
return fetch("/api/set-auth-cookie", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ userId: data.id }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw new Error("No user ID in response");
|
|
||||||
})
|
|
||||||
.then(() => router.replace("/admin"))
|
|
||||||
.catch(() => router.replace("/login?error=token_invalid"));
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
|
||||||
<div className="text-zinc-400">Verifying reset link...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { updatePasswordAction } from "@/actions/admin/password";
|
||||||
|
import { logUserActivity } from "@/actions/admin/audit";
|
||||||
|
|
||||||
|
export default function ChangePasswordForm({ userId }: { userId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError("Password must be at least 8 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError("Passwords do not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const result = await updatePasswordAction(password);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logUserActivity({
|
||||||
|
user_id: userId,
|
||||||
|
activity_type: "password_change",
|
||||||
|
details: {},
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
router.push("/admin");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8">
|
||||||
|
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-full bg-amber-900/30 border border-amber-700/40">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-amber-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-zinc-100">Change Password</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
You must set a new password before continuing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl bg-red-900/20 border border-red-800/50 p-4 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
|
||||||
|
placeholder="Repeat password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-xl bg-violet-600 hover:bg-violet-500 px-6 py-4 text-base font-bold text-white disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Updating..." : "Update Password"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,150 +1,28 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import ChangePasswordForm from "./ChangePasswordForm";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
export const dynamic = "force-dynamic";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { updatePasswordAction } from "@/actions/admin/password";
|
|
||||||
import { logUserActivity } from "@/actions/admin/audit";
|
|
||||||
|
|
||||||
export default function ChangePasswordPage() {
|
/**
|
||||||
const router = useRouter();
|
* Forced password-change page.
|
||||||
const [password, setPassword] = useState("");
|
*
|
||||||
const [confirm, setConfirm] = useState("");
|
* Linked from `src/app/admin/layout.tsx:64` when the admin user's row
|
||||||
const [error, setError] = useState<string | null>(null);
|
* has `must_change_password = true`. Verifies the Auth.js session
|
||||||
const [loading, setLoading] = useState(false);
|
* server-side, then renders the form with the user id passed as a prop.
|
||||||
const [checkingSession, setCheckingSession] = useState(true);
|
*
|
||||||
const [userId, setUserId] = useState<string | null>(null);
|
* Previously this page fetched `/api/auth/uid` from a `useEffect` to
|
||||||
|
* resolve the current user. That endpoint (and the underlying
|
||||||
|
* `rc_auth_uid` cookie) has been removed — the Auth.js JWT is the
|
||||||
|
* single source of truth for identity now.
|
||||||
|
*/
|
||||||
|
export default async function ChangePasswordPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
useEffect(() => {
|
if (!userId) {
|
||||||
fetch("/api/auth/uid")
|
redirect("/login");
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (!data.uid) {
|
|
||||||
router.push("/login");
|
|
||||||
} else {
|
|
||||||
setUserId(data.uid);
|
|
||||||
setCheckingSession(false);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => router.push("/login"));
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (password.length < 8) {
|
|
||||||
setError("Password must be at least 8 characters.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (password !== confirm) {
|
|
||||||
setError("Passwords do not match.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
const result = await updatePasswordAction(password);
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
logUserActivity({
|
|
||||||
user_id: userId,
|
|
||||||
activity_type: "password_change",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push("/admin");
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkingSession) {
|
return <ChangePasswordForm userId={userId} />;
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 text-center">
|
|
||||||
<p className="text-zinc-500 text-sm">Checking session...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8">
|
|
||||||
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-full bg-amber-900/30 border border-amber-700/40">
|
|
||||||
<svg className="h-6 w-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-zinc-100">Change Password</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
You must set a new password before continuing.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-xl bg-red-900/20 border border-red-800/50 p-4 text-sm text-red-400">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
|
||||||
New Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
|
|
||||||
placeholder="Min. 8 characters"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
|
||||||
Confirm Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={confirm}
|
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
|
||||||
required
|
|
||||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
|
|
||||||
placeholder="Repeat password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-xl bg-violet-600 hover:bg-violet-500 px-6 py-4 text-base font-bold text-white disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? "Updating..." : "Update Password"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-4 border-t border-zinc-800 pt-4">
|
|
||||||
<Link
|
|
||||||
href="/logout"
|
|
||||||
className="block text-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
|
|
||||||
>
|
|
||||||
Sign out instead
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
+229
-161
@@ -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 (
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
export default function DevLoginPage() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
|
||||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 shadow-xl">
|
|
||||||
<h1 className="text-2xl font-bold text-zinc-100 mb-4">Dev Login</h1>
|
|
||||||
<p className="text-zinc-400 mb-6">Click below to login as platform admin:</p>
|
|
||||||
<form action="/api/dev-login" method="POST">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full rounded-xl bg-emerald-600 px-6 py-4 text-base font-bold text-white hover:bg-emerald-500 transition-colors"
|
|
||||||
>
|
|
||||||
Login as Platform Admin
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+589
-1
@@ -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 />
|
||||||
|
|||||||
+202
-380
@@ -1,100 +1,82 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, Suspense } from "react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { signInWithGoogle } from "@/actions/auth-signin";
|
||||||
|
|
||||||
function LoginForm() {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [forgotPassword, setForgotPassword] = useState(false);
|
|
||||||
const [forgotEmail, setForgotEmail] = useState("");
|
|
||||||
const [forgotSent, setForgotSent] = useState(false);
|
|
||||||
const [forgotLoading, setForgotLoading] = useState(false);
|
|
||||||
const [forgotError, setForgotError] = useState<string | null>(null);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setGlobalError(null);
|
|
||||||
if (!email.trim()) { setGlobalError("Please enter your email address."); return; }
|
|
||||||
if (!password.trim()) { setGlobalError("Please enter your password."); return; }
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email: email.trim(), password }),
|
|
||||||
});
|
|
||||||
const data = await res.json().catch(() => ({ error: "Login failed" }));
|
|
||||||
if (res.ok && data?.ok) {
|
|
||||||
window.location.replace("/admin");
|
|
||||||
} else {
|
|
||||||
setGlobalError(data?.error || `Login failed (${res.status})`);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setGlobalError("Network error. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [email, password]);
|
|
||||||
|
|
||||||
const handleForgotPassword = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!forgotEmail.trim()) return;
|
|
||||||
setForgotLoading(true);
|
|
||||||
setForgotError(null);
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.set("email", forgotEmail.trim());
|
|
||||||
const result = await fetch("/api/forgot-password", {
|
|
||||||
method: "POST",
|
|
||||||
body: fd,
|
|
||||||
}).then(r => r.json()).catch(() => ({ error: "Network error" }));
|
|
||||||
setForgotLoading(false);
|
|
||||||
if (result.error) {
|
|
||||||
setForgotError(result.error);
|
|
||||||
} else {
|
|
||||||
setForgotSent(true);
|
|
||||||
}
|
|
||||||
}, [forgotEmail]);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The login page is a single Google OAuth button.
|
||||||
|
*
|
||||||
|
* The three "modes" that used to live here are gone:
|
||||||
|
* • Email/password — removed. Hit a dummy Supabase and 500'd.
|
||||||
|
* • Dev credentials form — removed. The dev cookie is now issued by
|
||||||
|
* `src/middleware.ts` when ALLOW_DEV_LOGIN is enabled.
|
||||||
|
* • /login?demo=1 three-button picker — removed. Same reason.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* • In dev / demo: visiting /admin auto-logs you in via the middleware.
|
||||||
|
* • In production: click "Sign in with Google" → Auth.js handles OAuth.
|
||||||
|
*/
|
||||||
|
export default function LoginClient() {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
|
<main
|
||||||
{/* Google Fonts */}
|
className="min-h-screen flex flex-col relative overflow-hidden"
|
||||||
|
style={{ backgroundColor: "#faf8f5", height: "100vh" }}
|
||||||
|
>
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
||||||
html, body { overflow: hidden; }
|
html,
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* Organic background elements */}
|
{/* Organic background elements */}
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||||
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
|
<div
|
||||||
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
|
className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20"
|
||||||
<div className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10" style={{ background: "radial-gradient(circle, #1a4d2e15 0%, transparent 70%)", filter: "blur(30px)" }} />
|
style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15"
|
||||||
|
style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10"
|
||||||
|
style={{ background: "radial-gradient(circle, #1a4d2e15 0%, transparent 70%)", filter: "blur(30px)" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="w-full py-6 px-6 lg:px-8">
|
<header className="w-full py-6 px-6 lg:px-8">
|
||||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
|
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
|
||||||
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}>
|
<div
|
||||||
|
className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105"
|
||||||
|
style={{ backgroundColor: "#1a4d2e" }}
|
||||||
|
>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
<path
|
||||||
|
d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z"
|
||||||
|
fill="#faf8f5"
|
||||||
|
stroke="#faf8f5"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}>
|
<span
|
||||||
|
className="text-xl font-semibold tracking-tight"
|
||||||
|
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}
|
||||||
|
>
|
||||||
Route Commerce
|
Route Commerce
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}>
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm font-medium transition-opacity hover:opacity-60"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
|
||||||
|
>
|
||||||
Back to home
|
Back to home
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,202 +84,128 @@ function LoginForm() {
|
|||||||
|
|
||||||
{/* Login Card */}
|
{/* Login Card */}
|
||||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
||||||
<div className={`w-full max-w-sm transition-all duration-700 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"}`}>
|
<div className="w-full max-w-sm">
|
||||||
{/* Card */}
|
|
||||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
|
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
|
||||||
{/* Subtle top accent */}
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" />
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" />
|
||||||
|
|
||||||
<div className="p-8 sm:p-10">
|
<div className="p-8 sm:p-10">
|
||||||
{/* Logo & Title */}
|
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
|
<div
|
||||||
|
className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
||||||
|
boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}>
|
<h1
|
||||||
|
className="text-3xl font-semibold text-stone-900"
|
||||||
|
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
Welcome back
|
Welcome back
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
|
<p
|
||||||
|
className="mt-2 text-sm"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
|
||||||
|
>
|
||||||
Sign in to your account
|
Sign in to your account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
|
{/* Single sign-in method: Google OAuth via Auth.js */}
|
||||||
{globalError && (
|
<form action={signInWithGoogle} className="space-y-3">
|
||||||
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
{globalError}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Email</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
autoComplete="username"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
|
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
|
||||||
placeholder="you@company.com"
|
|
||||||
aria-required="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>Password</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
autoComplete="current-password"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-xl border border-stone-200/80 px-4 py-3.5 pr-12 text-stone-900 shadow-sm outline-none transition-all focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400"
|
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
|
||||||
placeholder="••••••••"
|
|
||||||
aria-required="true"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600 p-1 transition-colors"
|
|
||||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
className="w-full inline-flex items-center justify-center gap-3 rounded-xl border border-stone-200/80 bg-white px-6 py-3.5 text-sm font-semibold text-stone-900 shadow-sm transition-all hover:bg-stone-50 active:scale-[0.98]"
|
||||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2"
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
|
aria-label="Sign in with Google"
|
||||||
>
|
>
|
||||||
{loading ? (
|
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<>
|
<path
|
||||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
fill="#4285F4"
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
/>
|
||||||
</svg>
|
<path
|
||||||
Signing in...
|
fill="#34A853"
|
||||||
</>
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0 0 12 23z"
|
||||||
) : "Sign in"}
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09a6.6 6.6 0 0 1 0-4.18V7.07H2.18a11 11 0 0 0 0 9.86l3.66-2.84z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A10.99 10.99 0 0 0 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Sign in with Google</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!forgotPassword && !forgotSent && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setForgotPassword(true); setGlobalError(null); }}
|
|
||||||
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
|
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Password Reset Form */}
|
<p
|
||||||
{forgotPassword && !forgotSent && (
|
className="mt-6 text-center text-xs"
|
||||||
<form onSubmit={handleForgotPassword} className="mt-6 space-y-4 border-t border-stone-200/50 pt-6" aria-label="Password reset form">
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}
|
||||||
<p className="text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b6b6b" }}>Enter your email and we'll send you a reset link.</p>
|
>
|
||||||
{forgotError && (
|
By signing in you agree to our{" "}
|
||||||
<div role="alert" className="rounded-xl bg-red-50/80 p-3 text-sm text-red-600 border border-red-100">
|
<Link href="/terms-and-conditions" className="underline hover:text-stone-700">
|
||||||
{forgotError}
|
Terms
|
||||||
</div>
|
</Link>{" "}
|
||||||
)}
|
and{" "}
|
||||||
<input
|
<Link href="/privacy-policy" className="underline hover:text-stone-700">
|
||||||
type="email"
|
Privacy Policy
|
||||||
value={forgotEmail}
|
</Link>
|
||||||
onChange={(e) => setForgotEmail(e.target.value)}
|
.
|
||||||
required
|
</p>
|
||||||
className="w-full rounded-xl border border-stone-200/80 bg-white/90 px-4 py-3.5 text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400 transition-all"
|
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
|
||||||
placeholder="you@company.com"
|
|
||||||
aria-required="true"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={forgotLoading}
|
|
||||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
|
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
|
|
||||||
>
|
|
||||||
{forgotLoading ? "Sending..." : "Send Reset Link"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setForgotPassword(false); setForgotEmail(""); setForgotError(null); }}
|
|
||||||
className="w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
|
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
|
|
||||||
>
|
|
||||||
← Back to sign in
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reset Email Sent */}
|
|
||||||
{forgotSent && (
|
|
||||||
<div className="mt-6 border-t border-stone-200/50 pt-6" role="status" aria-live="polite">
|
|
||||||
<div className="rounded-xl p-4 text-sm border" style={{ backgroundColor: "#f0fdf4", color: "#166534", borderColor: "#bbf7d0" }}>
|
|
||||||
<strong>Check your inbox.</strong> If an account exists for <span className="font-medium">{forgotEmail}</span>, a reset link has been sent.
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setForgotPassword(false); setForgotSent(false); setForgotEmail(""); }}
|
|
||||||
className="mt-4 w-full text-center text-sm transition-colors hover:text-[#1a4d2e]"
|
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}
|
|
||||||
>
|
|
||||||
← Back to sign in
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security Trust Badges */}
|
{/* Security Trust Badges */}
|
||||||
<div className="border-t border-stone-100/50 px-8 py-5" style={{ backgroundColor: "rgba(250, 248, 245, 0.5)" }}>
|
<div
|
||||||
<div className="flex flex-col gap-3">
|
className="border-t border-stone-100/50 px-8 py-5"
|
||||||
<div className="flex items-center justify-center gap-4 text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}>
|
style={{ backgroundColor: "rgba(250, 248, 245, 0.5)" }}
|
||||||
<div className="flex items-center gap-1.5">
|
>
|
||||||
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<div
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
className="flex items-center justify-center gap-4 text-xs"
|
||||||
</svg>
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#9a9590" }}
|
||||||
<span>256-bit SSL</span>
|
>
|
||||||
</div>
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-stone-300">•</span>
|
<svg
|
||||||
<div className="flex items-center gap-1.5">
|
className="w-4 h-4 text-[#6b8f71]"
|
||||||
<svg className="w-4 h-4 text-[#6b8f71]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
fill="none"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
stroke="currentColor"
|
||||||
<span>SOC 2</span>
|
strokeWidth={2}
|
||||||
</div>
|
>
|
||||||
<span className="text-stone-300">•</span>
|
<path
|
||||||
<div className="flex items-center gap-1.5">
|
strokeLinecap="round"
|
||||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none">
|
strokeLinejoin="round"
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#6b8f71"/>
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
</svg>
|
/>
|
||||||
<span>Powered by Supabase</span>
|
</svg>
|
||||||
</div>
|
<span>256-bit SSL</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-stone-300">•</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-[#6b8f71]"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>SOC 2</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,8 +213,19 @@ function LoginForm() {
|
|||||||
|
|
||||||
{/* Back link */}
|
{/* Back link */}
|
||||||
<div className="text-center mt-6">
|
<div className="text-center mt-6">
|
||||||
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
|
<Link
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
href="/brands"
|
||||||
|
className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
View Farms
|
View Farms
|
||||||
@@ -319,20 +238,49 @@ function LoginForm() {
|
|||||||
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
|
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
|
||||||
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}>
|
<div
|
||||||
|
className="w-5 h-5 rounded-full flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "#1a4d2e" }}
|
||||||
|
>
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
|
||||||
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
<path
|
||||||
|
d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z"
|
||||||
|
fill="#faf8f5"
|
||||||
|
stroke="#faf8f5"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}
|
||||||
|
>
|
||||||
© {new Date().getFullYear()} Route Commerce
|
© {new Date().getFullYear()} Route Commerce
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center gap-6">
|
<nav className="flex items-center gap-6">
|
||||||
<Link href="/privacy-policy" className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71", letterSpacing: "0.08em" }}>
|
<Link
|
||||||
|
href="/privacy-policy"
|
||||||
|
className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]"
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||||
|
color: "#6b8f71",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Privacy
|
Privacy
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/terms-and-conditions" className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71", letterSpacing: "0.08em" }}>
|
<Link
|
||||||
|
href="/terms-and-conditions"
|
||||||
|
className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]"
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||||
|
color: "#6b8f71",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Terms
|
Terms
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -341,129 +289,3 @@ function LoginForm() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demo mode wrapper
|
|
||||||
function DemoMode() {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
|
|
||||||
{/* Organic background elements */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
|
||||||
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
|
|
||||||
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<header className="w-full py-6 px-6 lg:px-8">
|
|
||||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
|
||||||
<Link href="/" className="flex items-center gap-3 group" style={{ textDecoration: "none" }}>
|
|
||||||
<div className="w-10 h-10 rounded-full flex items-center justify-center transition-transform group-hover:scale-105" style={{ backgroundColor: "#1a4d2e" }}>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-xl font-semibold tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", color: "#1a1a1a" }}>
|
|
||||||
Route Commerce
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<Link href="/" className="text-sm font-medium transition-opacity hover:opacity-60" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#6b8f71" }}>
|
|
||||||
Back to home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Demo Card */}
|
|
||||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
|
||||||
<div className="w-full max-w-sm">
|
|
||||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden p-10">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
|
|
||||||
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}>
|
|
||||||
Demo Mode
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
|
|
||||||
Select a role to explore the platform
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => { document.cookie = "dev_session=platform_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
|
|
||||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#1a4d2e" }}
|
|
||||||
>
|
|
||||||
Platform Admin
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { document.cookie = "dev_session=brand_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
|
|
||||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#c97a3e" }}
|
|
||||||
>
|
|
||||||
Brand Admin
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { document.cookie = "dev_session=store_employee; path=/; max-age=86400"; window.location.replace("/admin"); }}
|
|
||||||
className="w-full rounded-xl px-6 py-4 text-sm font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
|
||||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", backgroundColor: "#6b8f71" }}
|
|
||||||
>
|
|
||||||
Store Employee
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Back link */}
|
|
||||||
<div className="text-center mt-6">
|
|
||||||
<Link href="/brands" className="text-sm transition-opacity hover:opacity-60 inline-flex items-center gap-1" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
View Farms
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="py-6 px-6 border-t border-stone-200/30 relative z-10">
|
|
||||||
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: "#1a4d2e" }}>
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" fill="#faf8f5" stroke="#faf8f5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
|
|
||||||
© {new Date().getFullYear()} Route Commerce
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inner component that uses useSearchParams - must be wrapped in Suspense
|
|
||||||
function LoginPageInner() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const isDemo = searchParams.get("demo") === "1";
|
|
||||||
if (isDemo) return <DemoMode />;
|
|
||||||
return <LoginForm />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginClient() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<div className="h-16 w-16 rounded-2xl bg-gradient-to-br from-emerald-600 to-emerald-500 animate-pulse" />
|
|
||||||
<p className="text-stone-500">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<LoginPageInner />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function LoginPage2() {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (!email.trim()) { setError("Email is required."); return; }
|
|
||||||
if (!password.trim()) { setError("Password is required."); return; }
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const res = await fetch("/api/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email: email.trim(), password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
if (res.status === 303) {
|
|
||||||
window.location.href = "/admin";
|
|
||||||
} else {
|
|
||||||
const data = await res.json().catch(() => ({ error: "Login failed" }));
|
|
||||||
setError(data.error || "Login failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-100 px-6 py-12">
|
|
||||||
<div className="mx-auto max-w-md">
|
|
||||||
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200">
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Admin Login</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">Sign in to your account.</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div role="alert" className="rounded-xl bg-red-50 p-4 text-sm text-red-700 border border-red-100">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-slate-700">Email</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
autoComplete="username"
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900 disabled:bg-slate-100 disabled:cursor-not-allowed"
|
|
||||||
placeholder="admin@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-slate-700">Password</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
autoComplete="current-password"
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900 disabled:bg-slate-100 disabled:cursor-not-allowed"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50 flex items-center justify-center gap-3"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
|
||||||
Signing in...
|
|
||||||
</>
|
|
||||||
) : "Sign In"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link href="/" className="mt-4 block text-sm text-slate-500 hover:text-slate-700">
|
|
||||||
← Back to storefront
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
|
|
||||||
export default function LogoutPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Clear all auth cookies — dev_session, rc_auth_uid, rc_auth_token
|
|
||||||
document.cookie = "dev_session=;path=/;max-age=0";
|
|
||||||
document.cookie = "rc_auth_uid=;path=/;max-age=0";
|
|
||||||
document.cookie = "rc_auth_token=;path=/;max-age=0";
|
|
||||||
// Clear shopping cart on logout
|
|
||||||
localStorage.removeItem("route_commerce_cart");
|
|
||||||
localStorage.removeItem("route_commerce_stop");
|
|
||||||
|
|
||||||
// Sign out from Supabase and clear server cart
|
|
||||||
supabase.auth.getUser().then(async ({ data }) => {
|
|
||||||
if (data.user?.id) {
|
|
||||||
const { clearServerCart } = await import("@/actions/checkout");
|
|
||||||
clearServerCart(data.user.id).catch(() => {});
|
|
||||||
}
|
|
||||||
supabase.auth.signOut().then(() => {
|
|
||||||
router.push("/login");
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-100 px-6 py-12">
|
|
||||||
<div className="mx-auto max-w-md">
|
|
||||||
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 text-center">
|
|
||||||
<p className="text-slate-500">Signing out...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,79 @@
|
|||||||
|
import type { NextAuthConfig } from "next-auth";
|
||||||
|
import Google from "next-auth/providers/google";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge-compatible Auth.js v5 configuration.
|
||||||
|
*
|
||||||
|
* This file is imported by `src/middleware.ts`, which runs in the Edge runtime.
|
||||||
|
* It must NOT import the `@auth/pg-adapter` (which uses `pg`, a Node-only lib)
|
||||||
|
* or any other Node-only module. Database wiring lives in `src/lib/auth.ts`.
|
||||||
|
*
|
||||||
|
* If you need to add a provider that uses Node-only APIs (e.g. an adapter
|
||||||
|
* implementation), define it in `src/lib/auth.ts` instead and add a thin
|
||||||
|
* placeholder here so the middleware can still reference it.
|
||||||
|
*/
|
||||||
|
const isDev = process.env.NODE_ENV !== "production";
|
||||||
|
const allowDevLogin = process.env.ALLOW_DEV_LOGIN !== "false"; // on by default in dev
|
||||||
|
|
||||||
|
export const authConfig = {
|
||||||
|
// Custom sign-in page (must exist at /login)
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Trust the host header in dev for callback URLs
|
||||||
|
trustHost: true,
|
||||||
|
|
||||||
|
// Providers — referenced from middleware edge runtime.
|
||||||
|
// The Google provider only needs the env vars at runtime; it does not pull
|
||||||
|
// in any Node-only code. The dev Credentials provider is added in
|
||||||
|
// `src/lib/auth.ts` (server-side only) — it's not safe to import
|
||||||
|
// `next-auth/providers/credentials` from the edge runtime.
|
||||||
|
providers: [
|
||||||
|
Google({
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID ?? process.env.AUTH_GOOGLE_ID,
|
||||||
|
clientSecret:
|
||||||
|
process.env.GOOGLE_CLIENT_SECRET ?? process.env.AUTH_GOOGLE_SECRET,
|
||||||
|
// No `authorization` override — we want the default scopes (openid email profile)
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
// New users are persisted in the database (handled in src/lib/auth.ts)
|
||||||
|
// Default to JWT here so middleware can run in edge runtime; the full
|
||||||
|
// server-side handler in src/lib/auth.ts switches this to "database".
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
|
||||||
|
callbacks: {
|
||||||
|
/**
|
||||||
|
* Forward the user id from the database user record into the JWT on
|
||||||
|
* initial sign-in. With database sessions this is what populates
|
||||||
|
* `session.user.id` for downstream server actions.
|
||||||
|
*/
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = (user as { id?: string }).id ?? token.sub;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user && token?.sub) {
|
||||||
|
(session.user as { id?: string }).id = token.sub;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cookie config — keep default names so legacy `rc_auth_uid` consumers
|
||||||
|
// continue to work until they're migrated. New Auth.js cookies default to
|
||||||
|
// `authjs.session-token` (dev) and `__Secure-authjs.session-token` (prod).
|
||||||
|
} satisfies NextAuthConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: are we in development AND allowed to use the dev credentials
|
||||||
|
* provider? Exposed so server-side `src/lib/auth.ts` can decide whether to
|
||||||
|
* include the provider in its provider list.
|
||||||
|
*/
|
||||||
|
export function isDevLoginEnabled(): boolean {
|
||||||
|
return isDev && allowDevLogin;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user