Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb6dbe37a4 | |||
| 3f4f46da7e | |||
| ec1506dc82 | |||
| 2b3fd214d8 | |||
| 858ca0d64d | |||
| 8a91494009 | |||
| b240b7b56e | |||
| fd9d6424d5 | |||
| 0b748adfaa | |||
| 69767eb250 | |||
| 8bca9e86b1 | |||
| 82d81b2f69 | |||
| 7ecb1f2fc0 | |||
| 015b1cf7b5 | |||
| 1b12a0a95d | |||
| b2aa53f274 | |||
| 66c6f45efc | |||
| 6c6b5d3053 | |||
| 73cc7d1dce | |||
| 4763884caf | |||
| bdcaf0f1da | |||
| df6f4181df | |||
| 63842a9efc | |||
| bd623020d5 | |||
| 9b51f5ae29 | |||
| bc29c70e8b | |||
| 1feba25ced | |||
| f26d43ac96 | |||
| 49a9900d15 |
@@ -0,0 +1,82 @@
|
||||
# ============================================================================
|
||||
# Route Commerce — Environment variables
|
||||
# ============================================================================
|
||||
# Copy to `.env.local` and fill in real values for local development.
|
||||
# Production: set these in your hosting dashboard (Vercel / Netlify / etc.).
|
||||
# ============================================================================
|
||||
|
||||
# ── App ─────────────────────────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:4000
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:4000
|
||||
|
||||
# ── Database (Postgres, direct — Supabase is being removed) ────────────────
|
||||
# Single connection string used by `pg.Pool` in src/lib/auth.ts and the
|
||||
# admin-permissions / data-service layer. Format:
|
||||
# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce
|
||||
|
||||
# ── Auth.js (NextAuth v5) ───────────────────────────────────────────────────
|
||||
# Generate with: npx auth secret
|
||||
# Or: openssl rand -base64 32
|
||||
AUTH_SECRET=replace-me-with-a-32-byte-base64-string
|
||||
|
||||
# Base URL used to build OAuth callback URLs. In dev:
|
||||
AUTH_URL=http://localhost:4000
|
||||
# In production, set to https://yourdomain.com
|
||||
|
||||
# Google OAuth provider.
|
||||
# 1. Go to https://console.cloud.google.com/apis/credentials
|
||||
# 2. Create an OAuth 2.0 Client ID (type: Web application)
|
||||
# 3. Add Authorized redirect URI: http://localhost:3000/api/auth/callback/google
|
||||
# 4. Copy client id + client secret below
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Auth.js also reads AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET if you prefer the
|
||||
# default NextAuth variable names. The code in src/auth.config.ts falls
|
||||
# back to those names if GOOGLE_CLIENT_ID is unset.
|
||||
|
||||
# Set to "false" to disable the in-app dev credentials provider even in
|
||||
# development. Default: enabled in dev only.
|
||||
ALLOW_DEV_LOGIN=true
|
||||
|
||||
# ── Supabase (legacy, being removed) ────────────────────────────────────────
|
||||
# Still used by the existing admin pages, server actions, and the
|
||||
# `getAdminUser` flow. Once the auth migration is complete and the
|
||||
# @supabase/* packages are removed, these can go away.
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# ── Stripe ─────────────────────────────────────────────────────────────────
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_PRICE_STARTER=
|
||||
STRIPE_PRICE_FARM=
|
||||
STRIPE_PRICE_ENTERPRISE=
|
||||
STRIPE_PRICE_HARVEST_REACH=
|
||||
STRIPE_PRICE_WHOLESALE_PORTAL=
|
||||
STRIPE_PRICE_WATER_LOG=
|
||||
STRIPE_PRICE_AI_TOOLS=
|
||||
STRIPE_PRICE_SQUARE_SYNC=
|
||||
STRIPE_PRICE_SMS_CAMPAIGNS=
|
||||
|
||||
# ── Resend (transactional email) ────────────────────────────────────────────
|
||||
RESEND_API_KEY=
|
||||
RESEND_WEBHOOK_SECRET=
|
||||
|
||||
# ── AI providers ────────────────────────────────────────────────────────────
|
||||
OPENAI_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
GOOGLE_API_KEY=
|
||||
XAI_API_KEY=
|
||||
MINIMAX_API_KEY=
|
||||
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||
|
||||
# ── Square (optional) ───────────────────────────────────────────────────────
|
||||
SQUARE_APP_SECRET=
|
||||
SQUARE_ENVIRONMENT=sandbox
|
||||
|
||||
# ── Cron / automation ───────────────────────────────────────────────────────
|
||||
CRON_SECRET=replace-me-with-a-random-string
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, linux, ubuntu-latest]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Apply fix-agents.js patch
|
||||
run: node fix-agents.js
|
||||
|
||||
- name: Typecheck
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:3001
|
||||
NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000
|
||||
STORAGE_ENDPOINT: http://localhost:9000
|
||||
DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce
|
||||
run: npm run type-check
|
||||
|
||||
- name: Lint
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:3001
|
||||
NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000
|
||||
STORAGE_ENDPOINT: http://localhost:9000
|
||||
DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:3001
|
||||
NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000
|
||||
STORAGE_ENDPOINT: http://localhost:9000
|
||||
DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce
|
||||
run: npm run build
|
||||
@@ -0,0 +1,288 @@
|
||||
name: Deploy to route.crispygoat.com
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Self-hosted Gitea runner (matches build.yml). The Gitea Actions runner
|
||||
# is registered with the [self-hosted, linux, ubuntu-latest] labels.
|
||||
runs-on: [self-hosted, linux, ubuntu-latest]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Start Docker stack
|
||||
env:
|
||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||
run: |
|
||||
APP_DIR=/home/tyler/route-commerce
|
||||
mkdir -p $APP_DIR
|
||||
|
||||
# Free the dev-stack port (3001) and the port the previous deploy used
|
||||
# (so a new deploy can pick it back up if it's the lowest free port)
|
||||
PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "")
|
||||
for port in 3001 $PREV_PORT; do
|
||||
if [ -n "$port" ] && ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${port}[[:space:]]"; then
|
||||
echo "Port $port in use, freeing..."
|
||||
fuser -k -9 $port/tcp 2>/dev/null || true
|
||||
docker ps -aq --filter "publish=$port" 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Hard-stop the previous stack. Errors are NOT swallowed: if down
|
||||
# fails, picking a port against a half-torn-down stack is exactly
|
||||
# what produces the TOCTOU "address already in use" we keep hitting.
|
||||
docker compose -f $APP_DIR/docker-compose.yml down --remove-orphans
|
||||
# Belt-and-braces: anything with the postgrest name that survived.
|
||||
docker ps -aq --filter "name=route_commerce_postgrest" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
# docker-proxy sometimes leaves a listener behind for the published port.
|
||||
pkill -9 -f 'docker-proxy.*3011' 2>/dev/null || true
|
||||
pkill -9 -f 'docker-proxy.*3012' 2>/dev/null || true
|
||||
pkill -9 -f 'docker-proxy.*3013' 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
# Verify the postgrest container is actually gone before we pick a port.
|
||||
if docker ps -aq --filter "name=route_commerce_postgrest" | grep -q .; then
|
||||
echo "ERROR: route_commerce_postgrest still running after down"
|
||||
docker ps --filter "name=route_commerce_postgrest"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the first free host port starting from 3011. Persist the choice
|
||||
# so the Build and Deploy steps below can use the same URL.
|
||||
POSTGREST_HOST_PORT=3011
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if ! ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${POSTGREST_HOST_PORT}[[:space:]]"; then
|
||||
break
|
||||
fi
|
||||
echo "Port $POSTGREST_HOST_PORT in use, trying next... (attempt $attempt)"
|
||||
POSTGREST_HOST_PORT=$((POSTGREST_HOST_PORT + 1))
|
||||
if [ $POSTGREST_HOST_PORT -gt 30200 ]; then
|
||||
echo "ERROR: no free port in 3011-30200 range"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Using PostgREST host port: $POSTGREST_HOST_PORT"
|
||||
echo "$POSTGREST_HOST_PORT" > .postgrest-port
|
||||
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
|
||||
export POSTGREST_HOST_PORT
|
||||
|
||||
# Seed config files into APP_DIR if missing (they live in the repo, not in APP_DIR)
|
||||
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
|
||||
[ -f $APP_DIR/docker-compose.yml ] || cp docker-compose.yml $APP_DIR/docker-compose.yml
|
||||
cd $APP_DIR
|
||||
[ -f .env ] || cp .env.example .env
|
||||
# Append production secrets to .env (overriding .env.example defaults)
|
||||
{
|
||||
echo "POSTGRES_USER=${POSTGRES_USER}"
|
||||
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
echo "POSTGRES_DB=${POSTGRES_DB}"
|
||||
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
|
||||
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
|
||||
echo "POSTGREST_JWT_SECRET=${POSTGRES_JWT_SECRET}"
|
||||
echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT"
|
||||
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL"
|
||||
} >> .env
|
||||
# Bring the stack up fresh — --force-recreate ensures no stale
|
||||
# network/container references from prior failed attempts
|
||||
docker compose up -d --force-recreate db postgrest minio minio_init
|
||||
# Wait for Postgres healthcheck
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then
|
||||
echo "Postgres is ready"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Apply migrations
|
||||
env:
|
||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||
run: |
|
||||
APP_DIR=/home/tyler/route-commerce
|
||||
# Seed supabase/ into APP_DIR if missing (the deploy step copies it after, but
|
||||
# we need it here for migrations)
|
||||
[ -d $APP_DIR/supabase ] || cp -r supabase $APP_DIR/supabase
|
||||
cd $APP_DIR
|
||||
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/migrations/000_preflight_supabase_compat.sql || true
|
||||
[ -f supabase/captured_schema.sql ] && PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/captured_schema.sql || echo "captured_schema.sql not present, skipping"
|
||||
for f in supabase/migrations/[0-9]*.sql; do
|
||||
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -q -f "$f" || echo "FAIL: $f"
|
||||
done
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
|
||||
# ── Auth.js v5 (NextAuth) ────────────────────────────────────────
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
AUTH_URL: ${{ secrets.AUTH_URL }}
|
||||
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
AUTH_GOOGLE_ID: ${{ secrets.AUTH_GOOGLE_ID }}
|
||||
AUTH_GOOGLE_SECRET: ${{ secrets.AUTH_GOOGLE_SECRET }}
|
||||
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
||||
|
||||
# ── 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 (local code references these) ──────────────────
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
|
||||
# ── 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
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
AUTH_URL: ${{ secrets.AUTH_URL }}
|
||||
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
|
||||
# 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 }}
|
||||
|
||||
# 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
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
|
||||
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 "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 "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 "OPENAI_API_KEY=%s\n" "$OPENAI_API_KEY"
|
||||
printf "ANTHROPIC_API_KEY=%s\n" "$ANTHROPIC_API_KEY"
|
||||
printf "GOOGLE_API_KEY=%s\n" "$GOOGLE_API_KEY"
|
||||
printf "XAI_API_KEY=%s\n" "$XAI_API_KEY"
|
||||
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 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"
|
||||
@@ -6,7 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
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 +23,12 @@ npx tsc --noEmit # TypeScript check (no emit)
|
||||
npx playwright test # Run E2E tests (Playwright)
|
||||
```
|
||||
|
||||
> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL.
|
||||
> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
|
||||
> For direct PG mode: `pg` and `dotenv` are already in devDependencies.
|
||||
> If `get_brand_settings` migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
||||
> The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies.
|
||||
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
||||
|
||||
**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 +41,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=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.
|
||||
|
||||
#### Auth.js (NextAuth v5) migration — in progress
|
||||
|
||||
The platform is migrating from the bespoke `dev_session` + `rc_auth_uid` cookie flow to **Auth.js (NextAuth v5)**, with Supabase as the database adapter and email/OAuth providers. While the migration is in flight:
|
||||
|
||||
- Do **not** add new code that depends on the `dev_session` or `rc_auth_uid` cookies — write against the Auth.js API (`auth()`, `signIn`, `signOut`, `getSession`) instead.
|
||||
- New env vars: `AUTH_SECRET`, `AUTH_URL`, and provider-specific keys (`AUTH_GITHUB_ID`/`SECRET`, `AUTH_GOOGLE_ID`/`SECRET`, etc.). See `.env.example` for the full list.
|
||||
- A new route handler at `src/app/api/auth/[...nextauth]/route.ts` will replace the ad-hoc `/api/login`, `/api/auth/uid`, and `/api/logout` endpoints.
|
||||
- The middleware (`src/middleware.ts`) will eventually use `auth()` from NextAuth to populate the session; the existing `dev_session` auto-login branch is a temporary fallback for demos.
|
||||
- `src/lib/admin-permissions.ts` will keep its public surface (`getAdminUser`, `getCurrentAdminUser`) but read the session from NextAuth internally — the `AdminUser` type does not need to change.
|
||||
- `clerk-auth.ts` is being removed in favor of Auth.js; do not extend it.
|
||||
- Until the migration ships, the `dev_session` and `rc_auth_uid` paths remain the source of truth — see the section above for current behavior.
|
||||
|
||||
### Server Actions Pattern
|
||||
|
||||
All database writes go through server actions in `src/actions/`. These:
|
||||
@@ -55,9 +69,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.
|
||||
|
||||
### 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
|
||||
- Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it
|
||||
@@ -182,10 +206,19 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
|
||||
|
||||
### Communications Module ("Harvest Reach")
|
||||
|
||||
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`.
|
||||
The 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`.
|
||||
|
||||
**Scheduled automations** (declared in `vercel.json`):
|
||||
- `POST /api/email-automation/abandoned-cart` — every 6h, fires abandoned-cart sequence emails
|
||||
- `POST /api/email-automation/welcome-sequence` — every 6h, fires welcome onboarding sequence
|
||||
- `POST /api/cron/send-scheduled` — daily 09:00, sends scheduled campaigns
|
||||
- `POST /api/wholesale/notifications/{send,dispatch,pickup-reminder}` — wholesale lifecycle
|
||||
- `POST /api/square/process-queue` — every 2 min, drains Square sync queue
|
||||
|
||||
These endpoints are also reachable via curl for manual triggering; the email-automation routes accept `Authorization: Bearer $CRON_SECRET`.
|
||||
|
||||
### Payments
|
||||
|
||||
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
|
||||
@@ -200,7 +233,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
||||
|
||||
## 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
|
||||
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
|
||||
- Status enums stored as TEXT — no PostgreSQL ENUM type
|
||||
@@ -217,11 +250,11 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
||||
|---|---|
|
||||
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
|
||||
| Middleware (route protection) | `src/middleware.ts` |
|
||||
| Server actions | `src/actions/*.ts` (one file per domain) |
|
||||
| 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 client components | `src/components/admin/*.tsx` |
|
||||
| Migrations | `supabase/migrations/` |
|
||||
| Supabase client | `src/lib/supabase.ts` |
|
||||
| Migrations | `supabase/migrations/` (kept for now; will likely move to `db/migrations/` in a later pass) |
|
||||
| Postgres pool / driver | `src/lib/db.ts` (TBD — create during the Supabase removal pass) |
|
||||
| Email templates | `src/lib/email-templates.ts` |
|
||||
| Date formatting | `src/lib/format-date.ts` |
|
||||
| Feature flags | `src/lib/feature-flags.ts` |
|
||||
@@ -238,7 +271,8 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
||||
## Gotchas
|
||||
|
||||
- **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone.
|
||||
- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have 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.
|
||||
- **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`.
|
||||
@@ -2,11 +2,40 @@
|
||||
|
||||
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)
|
||||
- User ran `supabase login`
|
||||
@@ -35,15 +64,23 @@ Key changes:
|
||||
- Falls back to direct `pg` only if CLI path fails.
|
||||
- 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
|
||||
# Supabase CLI path (legacy — do not use going forward)
|
||||
supabase login
|
||||
supabase link --project-ref wnzkhezyhnfzhkhiflrp
|
||||
node supabase/push-migrations.js 148 # or any prefix
|
||||
node supabase/push-migrations.js 148 # CLI path
|
||||
# or
|
||||
npm run migrate:one 148
|
||||
```
|
||||
|
||||
```bash
|
||||
# Direct pg path (this is the future — only the pg branch is kept alive in the script)
|
||||
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
|
||||
# or
|
||||
DATABASE_URL=postgres://... npm run migrate:one 148
|
||||
```
|
||||
|
||||
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
|
||||
|
||||
---
|
||||
@@ -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.
|
||||
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project.
|
||||
- When adding **new** migrations in the future, prefer the standard `supabase migration new` flow if possible, but the custom `push-migrations.js` + numeric prefix style is still the established pattern here.
|
||||
- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies.
|
||||
- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only.
|
||||
- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.)
|
||||
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase.
|
||||
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
|
||||
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch.
|
||||
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
|
||||
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
|
||||
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Memory
|
||||
|
||||
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
|
||||
- **Read the Direction Pivot section first** — it supersedes the older Supabase-flavored instructions.
|
||||
- Update this file with new key facts, applied migrations, or new gotchas.
|
||||
- Feel free to add dated sections.
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
# =============================================================================
|
||||
# .env.production — secrets + dynamic ports for the running containers
|
||||
# =============================================================================
|
||||
#
|
||||
# deploy.sh writes the first three lines on every successful deploy.
|
||||
# Everything below is YOUR responsibility to populate. deploy.sh preserves
|
||||
# unknown lines verbatim across deploys (it only overwrites the lines it
|
||||
# knows about), so you can safely commit this file to a private repo or
|
||||
# provision it via your secrets manager of choice.
|
||||
#
|
||||
# In production, this file should be mode 0600 and owned by the deploy user.
|
||||
# =============================================================================
|
||||
|
||||
# --- managed by deploy.sh (do not edit by hand) -------------------------------
|
||||
POSTGREST_HOST_PORT=3011
|
||||
NEXTJS_HOST_PORT=3012
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3011
|
||||
|
||||
# --- PostgREST connection ---------------------------------------------------
|
||||
PGRST_DB_URI=postgres://app:secret@db.internal:5432/app_production
|
||||
PGRST_DB_ANON_ROLE=anon
|
||||
PGRST_DB_SCHEMA=public
|
||||
|
||||
# --- Next.js server-side secrets -------------------------------------------
|
||||
# Anything not prefixed NEXT_PUBLIC_ is server-only and read at request time.
|
||||
DATABASE_URL=postgres://app:secret@db.internal:5432/app_production
|
||||
|
||||
# Auth.js v5 (NextAuth). Generate AUTH_SECRET with `npx auth secret` or
|
||||
# `openssl rand -base64 32`. AUTH_URL is the public base URL the browser
|
||||
# uses to build OAuth callback URLs.
|
||||
AUTH_SECRET=change-me-to-a-long-random-string
|
||||
AUTH_URL=https://app.example.com
|
||||
NEXT_PUBLIC_AUTH_URL=https://app.example.com
|
||||
ALLOW_DEV_LOGIN=false
|
||||
|
||||
# Google OAuth provider for Auth.js. Set both AUTH_GOOGLE_ID/SECRET and
|
||||
# GOOGLE_CLIENT_ID/SECRET (the v5 code reads either name).
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
|
||||
# --- External services ------------------------------------------------------
|
||||
STRIPE_SECRET_KEY=sk_live_replace_me
|
||||
STRIPE_PUBLISHABLE_KEY=pk_live_replace_me
|
||||
STRIPE_WEBHOOK_SECRET=whsec_replace_me
|
||||
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASSWORD=replace_me
|
||||
SMTP_FROM="My App <noreply@example.com>"
|
||||
@@ -0,0 +1,6 @@
|
||||
# Runtime artefacts written by deploy.sh — do NOT commit these.
|
||||
.deploy.lock
|
||||
deploy.log
|
||||
.postgrest-port
|
||||
.nextjs-port
|
||||
.env.production
|
||||
@@ -0,0 +1,57 @@
|
||||
# =============================================================================
|
||||
# Dockerfile.nextjs — multi-stage build for the Next.js frontend
|
||||
# =============================================================================
|
||||
# Used by docker-compose.yml's `nextjs` service.
|
||||
#
|
||||
# Why this looks the way it does:
|
||||
# - `NEXT_PUBLIC_API_URL` must be present at BUILD time (Next.js inlines
|
||||
# it into the client JS). We pass it through as an ARGs so the build
|
||||
# context is reproducible (`docker build --build-arg` or via deploy.sh's
|
||||
# `docker compose --env-file` flow).
|
||||
# - We copy the host's pre-built `.next/` (produced by `npm run build` in
|
||||
# deploy.sh) rather than running `next build` inside the image. This
|
||||
# keeps the image lean and avoids double-building.
|
||||
# =============================================================================
|
||||
|
||||
# ---- builder: produce node_modules with dev deps for the build step --------
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
# ---- builder: produce the standalone .next/ output ------------------------
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# These ARGs are wired through docker-compose's `args:` block (or the CLI).
|
||||
# deploy.sh exports them in the build environment.
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
ARG NEXTJS_HOST_PORT
|
||||
ENV NEXTJS_HOST_PORT=${NEXTJS_HOST_PORT}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- runner: minimal image, standalone server -----------------------------
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
# Run as non-root.
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy only what the standalone server needs.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
# Adjust this CMD to match the actual server file your build emits.
|
||||
# For `output: "standalone"` in next.config.js the file is server.js.
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,54 @@
|
||||
# =============================================================================
|
||||
# Makefile — convenience targets around deploy.sh
|
||||
# =============================================================================
|
||||
# All targets are wrappers; you can also invoke deploy.sh directly.
|
||||
|
||||
SHELL := /usr/bin/env bash
|
||||
.SHELLFLAGS := -Eeu -o pipefail -c
|
||||
.SHELLFLAGS_LOG := $(.SHELLFLAGS)
|
||||
|
||||
DEPLOY := ./deploy.sh
|
||||
HEALTH := ./healthcheck.sh
|
||||
WORKSPACE ?= $(CURDIR)
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help message
|
||||
@awk 'BEGIN {FS = ":.*##"; printf "Targets:\n"} /^[a-zA-Z_-]+:.*##/ { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
|
||||
|
||||
.PHONY: deploy
|
||||
deploy: ## Run a full deploy (build + up + nginx + healthcheck)
|
||||
$(DEPLOY)
|
||||
|
||||
.PHONY: deploy-verbose
|
||||
deploy-verbose: ## Deploy with extra logging (PRUNE_IMAGES=0, longer healthcheck)
|
||||
PRUNE_IMAGES=0 HEALTHCHECK_TIMEOUT=120 $(DEPLOY)
|
||||
|
||||
.PHONY: health
|
||||
health: ## Run a one-shot health check against the running stack
|
||||
WORKSPACE=$(WORKSPACE) $(HEALTH)
|
||||
|
||||
.PHONY: health-nginx
|
||||
health-nginx: ## Health check including the nginx-fronted URL
|
||||
WORKSPACE=$(WORKSPACE) $(HEALTH) --nginx
|
||||
|
||||
.PHONY: status
|
||||
status: ## Show current prod ports and running containers
|
||||
@echo "PostgREST port: $$(cat .postgrest-port 2>/dev/null || echo none)"
|
||||
@echo "Next.js port: $$(cat .nextjs-port 2>/dev/null || echo none)"
|
||||
@cd deploy && docker compose -p prod-app ps
|
||||
|
||||
.PHONY: logs
|
||||
logs: ## Tail deploy.log
|
||||
tail -n 200 -f deploy.log
|
||||
|
||||
.PHONY: down
|
||||
down: ## Stop the production stack (without redeploying)
|
||||
cd deploy && docker compose -p prod-app down --remove-orphans
|
||||
|
||||
.PHONY: rollback
|
||||
rollback: ## Restart the previous stack (the one whose ports are still on disk)
|
||||
@if [[ ! -f .postgrest-port ]]; then echo "no .postgrest-port to roll back to"; exit 1; fi
|
||||
cd deploy && \
|
||||
POSTGREST_HOST_PORT=$$(cat ../.postgrest-port) \
|
||||
NEXTJS_HOST_PORT=$$(cat ../.nextjs-port) \
|
||||
docker compose -p prod-app --env-file ../.env.production up -d
|
||||
Executable
+429
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# deploy.sh — Idempotent PostgREST + Next.js production deploy
|
||||
# =============================================================================
|
||||
#
|
||||
# Self-hosted single-server deploy. Triggered manually, by Gitea webhook, or
|
||||
# by a Gitea Actions runner after a push to `main` (or `gitea-sync`).
|
||||
#
|
||||
# What it does, in order:
|
||||
# 1. Acquires an exclusive flock (concurrent deploys die loudly).
|
||||
# 2. CLEANUP: stops the dev stack on :3001 and the previous prod stack
|
||||
# (port read from .postgrest-port / .nextjs-port).
|
||||
# 3. PORT_SELECTION: picks the lowest free port in [3011..30200] for
|
||||
# PostgREST, then the next free one for the Next.js frontend.
|
||||
# 4. BUILD: runs `npm run build` with NEXT_PUBLIC_API_URL exported so it
|
||||
# gets inlined into the client bundle.
|
||||
# 5. DEPLOY: writes the chosen ports to .env.production, brings the
|
||||
# compose stack up.
|
||||
# 6. NGINX: renders the nginx config from a template (with the current
|
||||
# ports), `nginx -t`s it, and reloads the host systemd nginx.
|
||||
# 7. HEALTHCHECK: curls the new stack; if anything is down, rolls back.
|
||||
# 8. IMAGE_PRUNE: optional, removes dangling images on success.
|
||||
#
|
||||
# Files written to the workspace root:
|
||||
# .postgrest-port current PostgREST host port (atomic)
|
||||
# .nextjs-port current Next.js host port (atomic)
|
||||
# .env.production rendered env fed to docker compose
|
||||
# .deploy.lock flock target
|
||||
# deploy.log append-only log
|
||||
# =============================================================================
|
||||
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configurable variables (override via environment before invoking)
|
||||
# -----------------------------------------------------------------------------
|
||||
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-${WORKSPACE}/deploy}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-${COMPOSE_DIR}/docker-compose.yml}"
|
||||
NGINX_TEMPLATE="${NGINX_TEMPLATE:-${COMPOSE_DIR}/nginx.conf.template}"
|
||||
NGINX_RENDERED="${NGINX_RENDERED:-/etc/nginx/sites-available/prod-app.conf}"
|
||||
NGINX_LINK="${NGINX_LINK:-/etc/nginx/sites-enabled/prod-app.conf}"
|
||||
NGINX_OWNER="${NGINX_OWNER:-www-data:www-data}"
|
||||
|
||||
PROJECT_NAME="${PROJECT_NAME:-prod-app}"
|
||||
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
|
||||
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
|
||||
ENV_FILE="${ENV_FILE:-${WORKSPACE}/.env.production}"
|
||||
LOCK_FILE="${LOCK_FILE:-${WORKSPACE}/.deploy.lock}"
|
||||
LOG_FILE="${LOG_FILE:-${WORKSPACE}/deploy.log}"
|
||||
|
||||
DEV_PORT="${DEV_PORT:-3001}"
|
||||
PORT_RANGE_START="${PORT_RANGE_START:-3011}"
|
||||
PORT_RANGE_END="${PORT_RANGE_END:-30200}"
|
||||
HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-60}" # seconds total
|
||||
HEALTHCHECK_INTERVAL="${HEALTHCHECK_INTERVAL:-2}" # seconds between tries
|
||||
|
||||
# Image pruning (set PRUNE_IMAGES=0 to skip)
|
||||
PRUNE_IMAGES="${PRUNE_IMAGES:-1}"
|
||||
|
||||
# Optional: pin the public URL the browser uses. If empty, we default to
|
||||
# http://localhost:${POSTGREST_HOST_PORT}. For production with a real domain
|
||||
# and nginx in front, set e.g. NEXT_PUBLIC_API_URL=https://app.example.com/api
|
||||
NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging — every line is timestamped, tee'd to stdout AND the log file.
|
||||
# We replace the shell's fd 1/2 with a tee so any tool that prints (npm, docker,
|
||||
# curl) lands in both places automatically.
|
||||
# -----------------------------------------------------------------------------
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||
|
||||
ts() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||
log() { printf '[%s] %s\n' "$(ts)" "$*"; }
|
||||
hr() { printf '%s\n' '----------------------------------------------------------------'; }
|
||||
section() { hr; log "== $* =="; hr; }
|
||||
|
||||
# Trap so we always release the lock and surface a useful message.
|
||||
on_exit() {
|
||||
local exit_code=$?
|
||||
if (( exit_code != 0 )); then
|
||||
log "DEPLOY FAILED with exit code ${exit_code}"
|
||||
log "See ${LOG_FILE} for full output. Rollback hints:"
|
||||
log " - Previous port was: ${PREVIOUS_POSTGREST_PORT:-<unknown>}"
|
||||
log " - Current .postgrest-port value: $(read_port_file "$POSTGREST_PORT_FILE" || echo '<none>')"
|
||||
log " - To restart the old stack manually:"
|
||||
log " POSTGREST_HOST_PORT=${PREVIOUS_POSTGREST_PORT:-3011} \\"
|
||||
log " NEXTJS_HOST_PORT=${PREVIOUS_NEXTJS_PORT:-3012} \\"
|
||||
log " docker compose -p ${PROJECT_NAME} --env-file ${ENV_FILE} up -d"
|
||||
else
|
||||
log "DEPLOY OK — PostgREST on :${NEW_POSTGREST_PORT}, Next.js on :${NEW_NEXTJS_PORT}"
|
||||
fi
|
||||
# flock on fd 9 releases automatically when the script exits.
|
||||
}
|
||||
trap on_exit EXIT
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
read_port_file() {
|
||||
# Echo the port in $1, or empty string if missing/garbage.
|
||||
local f="$1"
|
||||
[[ -f "$f" ]] || return 1
|
||||
local v
|
||||
v=$(tr -d '[:space:]' < "$f" 2>/dev/null || true)
|
||||
[[ "$v" =~ ^[0-9]+$ ]] || return 1
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
render_template() {
|
||||
# Portable envsubst: replaces $VAR and ${VAR} references in stdin with
|
||||
# values from the current environment. Only the variable names given as
|
||||
# args are expanded (matches `envsubst` behavior). If real envsubst is
|
||||
# available we use it for speed.
|
||||
local vars="$1"
|
||||
if command -v envsubst >/dev/null 2>&1; then
|
||||
envsubst "$vars"
|
||||
else
|
||||
# Build a sed expression like: s/\${VAR}/$VAR/g; s/\bVAR\b/$VAR/g
|
||||
local sed_expr=()
|
||||
for v in $vars; do
|
||||
v="${v#\$}"
|
||||
v="${v#\{}"
|
||||
v="${v%\}}"
|
||||
sed_expr+=( -e "s|\${${v}}|${!v:-}|g" )
|
||||
sed_expr+=( -e "s|\$${v}\b|${!v:-}|g" )
|
||||
done
|
||||
sed "${sed_expr[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
is_listening() {
|
||||
# Returns 0 if port $1 has a TCP listener (v4 or v6) on this host.
|
||||
local port="$1"
|
||||
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${port}$"
|
||||
}
|
||||
|
||||
next_free_port() {
|
||||
# Walk PORT_RANGE_START..PORT_RANGE_END and return the first port nobody
|
||||
# is listening on. Returns 1 if none are free.
|
||||
local p
|
||||
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
|
||||
if ! is_listening "$p"; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
atomic_write() {
|
||||
# Write stdin to $1 atomically: write to temp, fsync, rename. This is
|
||||
# what lets us use .postgrest-port as a single source of truth — readers
|
||||
# always see either the old value or the new value, never a half-written one.
|
||||
local target="$1"
|
||||
local tmp
|
||||
tmp=$(mktemp "${target}.tmp.XXXXXX")
|
||||
cat > "$tmp"
|
||||
sync
|
||||
mv -f "$tmp" "$target"
|
||||
}
|
||||
|
||||
free_port() {
|
||||
# Try several strategies to free a port:
|
||||
# 1. docker compose down for our project (idempotent)
|
||||
# 2. brute-force kill of any process bound to the port
|
||||
local port="$1" label="$2"
|
||||
if [[ -z "$port" ]]; then return 0; fi
|
||||
log " ${label} port ${port}: stopping project '${PROJECT_NAME}' (if up)"
|
||||
( cd "$COMPOSE_DIR" && docker compose -p "$PROJECT_NAME" down --remove-orphans --timeout 10 ) \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
if is_listening "$port"; then
|
||||
log " ${label} port ${port}: still listening, attempting pkill"
|
||||
# fuser prints PIDs holding the port; xargs kills them.
|
||||
local pids
|
||||
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
if [[ -n "$pids" ]]; then
|
||||
# shellcheck disable=SC2086
|
||||
kill $pids 2>/dev/null || true
|
||||
sleep 1
|
||||
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
[[ -n "$pids" ]] && kill -9 $pids 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
if is_listening "$port"; then
|
||||
log " ${label} port ${port}: WARNING — still in use after cleanup"
|
||||
return 1
|
||||
fi
|
||||
log " ${label} port ${port}: free"
|
||||
return 0
|
||||
}
|
||||
|
||||
healthcheck() {
|
||||
# Hit $1 (URL) until it returns 2xx within HEALTHCHECK_TIMEOUT seconds.
|
||||
local url="$1" label="$2" elapsed=0
|
||||
log " ${label}: ${url}"
|
||||
while (( elapsed < HEALTHCHECK_TIMEOUT )); do
|
||||
if curl -fsS --max-time 5 -o /dev/null "$url"; then
|
||||
log " ${label}: OK (after ${elapsed}s)"
|
||||
return 0
|
||||
fi
|
||||
sleep "$HEALTHCHECK_INTERVAL"
|
||||
elapsed=$(( elapsed + HEALTHCHECK_INTERVAL ))
|
||||
done
|
||||
log " ${label}: FAILED after ${HEALTHCHECK_TIMEOUT}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Lock — refuse to run if another deploy is in flight.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "LOCK"
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
log "Another deploy holds ${LOCK_FILE}. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
log "Acquired exclusive lock on ${LOCK_FILE}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 0. Banner
|
||||
# -----------------------------------------------------------------------------
|
||||
section "DEPLOY START"
|
||||
log "Workspace: ${WORKSPACE}"
|
||||
log "Project: ${PROJECT_NAME}"
|
||||
log "Compose: ${COMPOSE_FILE}"
|
||||
log "Nginx tpl: ${NGINX_TEMPLATE}"
|
||||
log "Port range: ${PORT_RANGE_START}..${PORT_RANGE_END}"
|
||||
log "Caller: ${USER:-<unknown>}@$(hostname)"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 1. CLEANUP — port 3001 (dev) and the previous prod ports.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "CLEANUP"
|
||||
|
||||
free_port "$DEV_PORT" "dev"
|
||||
PREVIOUS_POSTGREST_PORT=$(read_port_file "$POSTGREST_PORT_FILE" || true)
|
||||
PREVIOUS_NEXTJS_PORT=$(read_port_file "$NEXTJS_PORT_FILE" || true)
|
||||
log "Previous prod ports: PostgREST=${PREVIOUS_POSTGREST_PORT:-<none>} Next.js=${PREVIOUS_NEXTJS_PORT:-<none>}"
|
||||
|
||||
# Stale-port guard: if the file points to a port that is NOT in our standard
|
||||
# range, or to a port that nothing is listening on anymore, we still tear
|
||||
# down the project (cheap) but we don't try to free the port itself —
|
||||
# someone else might be using it.
|
||||
free_port "${PREVIOUS_POSTGREST_PORT:-}" "prev-postgrest"
|
||||
free_port "${PREVIOUS_NEXTJS_PORT:-}" "prev-nextjs"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 2. PORT_SELECTION — find the two lowest free ports.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "PORT_SELECTION"
|
||||
|
||||
NEW_POSTGREST_PORT=$(next_free_port) || {
|
||||
log "No free port in [${PORT_RANGE_START}..${PORT_RANGE_END}]. Bailing out."
|
||||
exit 2
|
||||
}
|
||||
log "PostgREST: ${NEW_POSTGREST_PORT}"
|
||||
|
||||
# Re-check after allocation, since we want distinct ports for both services.
|
||||
NEW_NEXTJS_PORT=""
|
||||
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
|
||||
if (( p == NEW_POSTGREST_PORT )); then continue; fi
|
||||
if ! is_listening "$p"; then NEW_NEXTJS_PORT="$p"; break; fi
|
||||
done
|
||||
if [[ -z "$NEW_NEXTJS_PORT" ]]; then
|
||||
log "No free port for Next.js after allocating ${NEW_POSTGREST_PORT}. Bailing out."
|
||||
exit 2
|
||||
fi
|
||||
log "Next.js: ${NEW_NEXTJS_PORT}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 3. BUILD — Next.js, with NEXT_PUBLIC_API_URL inlined into the client bundle.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "BUILD"
|
||||
|
||||
cd "$WORKSPACE"
|
||||
|
||||
# Default the public API URL the browser will see.
|
||||
if [[ -z "$NEXT_PUBLIC_API_URL" ]]; then
|
||||
NEXT_PUBLIC_API_URL="http://localhost:${NEW_POSTGREST_PORT}"
|
||||
fi
|
||||
log "NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}"
|
||||
|
||||
# Node-only check: don't try to build if there's no package.json.
|
||||
if [[ -f package.json ]]; then
|
||||
# Make sure the deps are present (idempotent — npm ci is a no-op when locked).
|
||||
if [[ -f package-lock.json ]]; then
|
||||
log "npm ci (locked install)"
|
||||
npm ci --no-audit --no-fund
|
||||
else
|
||||
log "npm install (no lockfile present — consider committing package-lock.json)"
|
||||
npm install --no-audit --no-fund
|
||||
fi
|
||||
log "npm run build"
|
||||
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
|
||||
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
|
||||
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
|
||||
npm run build
|
||||
else
|
||||
log "No package.json in ${WORKSPACE} — skipping build step."
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 4. ENV FILE — render .env.production for the running containers.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "ENV"
|
||||
|
||||
# Preserve any pre-existing secrets in .env.production. We only own the lines
|
||||
# we write; everything else is left alone. (The simplest sane strategy.)
|
||||
SECRETS_FILE=""
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
SECRETS_FILE=$(mktemp)
|
||||
# Drop any lines we manage; keep the rest verbatim.
|
||||
grep -v -E '^(POSTGREST_HOST_PORT|NEXTJS_HOST_PORT|NEXT_PUBLIC_API_URL)=' \
|
||||
"$ENV_FILE" > "$SECRETS_FILE" || true
|
||||
fi
|
||||
|
||||
{
|
||||
printf '# Generated by deploy.sh on %s — safe to edit, lines below are managed\n' "$(ts)"
|
||||
printf 'POSTGREST_HOST_PORT=%s\n' "$NEW_POSTGREST_PORT"
|
||||
printf 'NEXTJS_HOST_PORT=%s\n' "$NEW_NEXTJS_PORT"
|
||||
printf 'NEXT_PUBLIC_API_URL=%q\n' "$NEXT_PUBLIC_API_URL"
|
||||
if [[ -n "$SECRETS_FILE" ]]; then
|
||||
cat "$SECRETS_FILE"
|
||||
rm -f "$SECRETS_FILE"
|
||||
fi
|
||||
} > "${ENV_FILE}.new"
|
||||
|
||||
mv -f "${ENV_FILE}.new" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
log "Wrote ${ENV_FILE}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 5. DEPLOY — bring the stack up.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "DEPLOY"
|
||||
|
||||
cd "$COMPOSE_DIR"
|
||||
log "docker compose -p ${PROJECT_NAME} up -d --build"
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --build
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 6. NGINX — render config from template, test, reload.
|
||||
# -----------------------------------------------------------------------------
|
||||
section "NGINX"
|
||||
|
||||
if [[ -f "$NGINX_TEMPLATE" ]]; then
|
||||
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
|
||||
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
|
||||
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
|
||||
render_template '${POSTGREST_HOST_PORT} ${NEXTJS_HOST_PORT} ${NEXT_PUBLIC_API_URL}' \
|
||||
< "$NGINX_TEMPLATE" > "$NGINX_RENDERED"
|
||||
|
||||
log "Rendered: ${NGINX_RENDERED}"
|
||||
chown "$NGINX_OWNER" "$NGINX_RENDERED" 2>/dev/null || true
|
||||
chmod 644 "$NGINX_RENDERED"
|
||||
|
||||
# Wire it into sites-enabled if not already linked.
|
||||
if [[ ! -L "$NGINX_LINK" && ! -e "$NGINX_LINK" ]]; then
|
||||
log "Enabling site: ${NGINX_LINK} -> ${NGINX_RENDERED}"
|
||||
ln -s "$NGINX_RENDERED" "$NGINX_LINK"
|
||||
fi
|
||||
|
||||
log "nginx -t"
|
||||
nginx -t
|
||||
log "systemctl reload nginx"
|
||||
systemctl reload nginx
|
||||
else
|
||||
log "No nginx template at ${NGINX_TEMPLATE} — skipping reverse proxy step."
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 7. HEALTHCHECK — direct + via nginx (when applicable).
|
||||
# -----------------------------------------------------------------------------
|
||||
section "HEALTHCHECK"
|
||||
|
||||
# Direct checks (bypass nginx, catch compose issues)
|
||||
healthcheck "http://127.0.0.1:${NEW_POSTGREST_PORT}/" "postgrest-direct" || ROLLBACK=1
|
||||
healthcheck "http://127.0.0.1:${NEW_NEXTJS_PORT}/" "nextjs-direct" || ROLLBACK=1
|
||||
|
||||
# nginx-fronted check (only meaningful if nginx template exists)
|
||||
if [[ -f "$NGINX_TEMPLATE" && "${ROLLBACK:-0}" != "1" ]]; then
|
||||
healthcheck "http://127.0.0.1/" "nginx-front" || ROLLBACK=1
|
||||
fi
|
||||
|
||||
if [[ "${ROLLBACK:-0}" == "1" ]]; then
|
||||
log "HEALTHCHECK FAILED — rolling back."
|
||||
log "Tearing down the new stack on :${NEW_POSTGREST_PORT} / :${NEW_NEXTJS_PORT}"
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" down --remove-orphans --timeout 10 || true
|
||||
|
||||
# If we had a previous port file, the old one is still on disk (we wrote
|
||||
# the new one to .new and only mv'd on success... but we DID mv already,
|
||||
# so re-write the old value).
|
||||
if [[ -n "${PREVIOUS_POSTGREST_PORT:-}" ]]; then
|
||||
printf '%s\n' "$PREVIOUS_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
|
||||
else
|
||||
rm -f "$POSTGREST_PORT_FILE"
|
||||
fi
|
||||
if [[ -n "${PREVIOUS_NEXTJS_PORT:-}" ]]; then
|
||||
printf '%s\n' "$PREVIOUS_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
|
||||
else
|
||||
rm -f "$NEXTJS_PORT_FILE"
|
||||
fi
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 8. PERSIST — commit the chosen ports as the new single source of truth.
|
||||
# (Done AFTER healthcheck so a failed deploy doesn't clobber the old one.)
|
||||
# -----------------------------------------------------------------------------
|
||||
section "PERSIST"
|
||||
printf '%s\n' "$NEW_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
|
||||
printf '%s\n' "$NEW_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
|
||||
log ".postgrest-port = ${NEW_POSTGREST_PORT}"
|
||||
log ".nextjs-port = ${NEW_NEXTJS_PORT}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 9. IMAGE_PRUNE — optional housekeeping.
|
||||
# -----------------------------------------------------------------------------
|
||||
if [[ "$PRUNE_IMAGES" == "1" ]]; then
|
||||
section "IMAGE_PRUNE"
|
||||
docker image prune -f
|
||||
fi
|
||||
|
||||
section "DONE"
|
||||
exit 0
|
||||
@@ -0,0 +1,70 @@
|
||||
# =============================================================================
|
||||
# docker-compose.yml — production stack consumed by deploy.sh
|
||||
# =============================================================================
|
||||
#
|
||||
# The host-side ports (POSTGREST_HOST_PORT, NEXTJS_HOST_PORT) are written by
|
||||
# deploy.sh into .env.production. We interpolate from there with ${VAR:-3011}
|
||||
# so a manual `docker compose up` without the deploy script still works.
|
||||
#
|
||||
# Note on networking: the Next.js container calls PostgREST on
|
||||
# `host.docker.internal:POSTGREST_HOST_PORT` so the inlined
|
||||
# NEXT_PUBLIC_API_URL (a localhost URL, per the deploy contract) resolves
|
||||
# correctly. On Linux you may need to add
|
||||
# extra_hosts:
|
||||
# - "host.docker.internal:host-gateway"
|
||||
# which is included below for that reason.
|
||||
# =============================================================================
|
||||
|
||||
name: prod-app # default project name; deploy.sh overrides with -p
|
||||
|
||||
services:
|
||||
postgrest:
|
||||
image: postgrest/postgrest:latest
|
||||
container_name: prod-app-postgrest
|
||||
restart: unless-stopped
|
||||
# The host port is dynamic. The container always listens on 3000.
|
||||
ports:
|
||||
- "${POSTGREST_HOST_PORT:-3011}:3000"
|
||||
environment:
|
||||
PGRST_DB_URI: ${PGRST_DB_URI}
|
||||
PGRST_DB_ANON_ROLE: ${PGRST_DB_ANON_ROLE:-anon}
|
||||
PGRST_DB_SCHEMA: ${PGRST_DB_SCHEMA:-public}
|
||||
PGRST_SERVER_PORT: 3000
|
||||
# Optional: tighten CORS for your real domain
|
||||
PGRST_DB_TXN_END: "commit-allow-overwrite"
|
||||
# Healthcheck lets `docker compose ps` show healthy state.
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 6
|
||||
|
||||
nextjs:
|
||||
# Build context is the workspace root (one level up from this file).
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.nextjs
|
||||
container_name: prod-app-nextjs
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NEXTJS_HOST_PORT:-3012}:3000"
|
||||
environment:
|
||||
# Runtime vars — these can change without rebuilding. NEXT_PUBLIC_*
|
||||
# is also exported here for completeness, but the BROWSER's view of
|
||||
# NEXT_PUBLIC_API_URL is baked in at build time (see Dockerfile).
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
|
||||
env_file:
|
||||
- ../.env.production # server-side secrets read at runtime
|
||||
extra_hosts:
|
||||
# Lets the container reach the host on the dynamically allocated port.
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
postgrest:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 6
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# healthcheck.sh — standalone, callable from cron / monitoring
|
||||
# =============================================================================
|
||||
#
|
||||
# Reads the current prod ports from .postgrest-port / .nextjs-port and curls
|
||||
# each service. Exit code is the count of failed checks (0 = all healthy).
|
||||
#
|
||||
# Usage:
|
||||
# ./healthcheck.sh
|
||||
# ./healthcheck.sh --nginx # also check the fronted URL
|
||||
# WORKSPACE=/srv/app ./healthcheck.sh
|
||||
# =============================================================================
|
||||
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
|
||||
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
|
||||
TIMEOUT="${HEALTHCHECK_TIMEOUT:-5}"
|
||||
|
||||
failures=0
|
||||
|
||||
check() {
|
||||
local label="$1" url="$2"
|
||||
if curl -fsS --max-time "$TIMEOUT" -o /dev/null "$url"; then
|
||||
printf ' [ OK ] %-20s %s\n' "$label" "$url"
|
||||
else
|
||||
printf ' [FAIL] %-20s %s\n' "$label" "$url"
|
||||
failures=$(( failures + 1 ))
|
||||
fi
|
||||
}
|
||||
|
||||
pgrest_port=$(tr -d '[:space:]' < "$POSTGREST_PORT_FILE" 2>/dev/null || echo "")
|
||||
next_port=$(tr -d '[:space:]' < "$NEXTJS_PORT_FILE" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$pgrest_port" ]]; then
|
||||
check "postgrest" "http://127.0.0.1:${pgrest_port}/"
|
||||
else
|
||||
printf ' [SKIP] postgrest (no .postgrest-port)\n'
|
||||
fi
|
||||
|
||||
if [[ -n "$next_port" ]]; then
|
||||
check "nextjs" "http://127.0.0.1:${next_port}/"
|
||||
else
|
||||
printf ' [SKIP] nextjs (no .nextjs-port)\n'
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "--nginx" ]]; then
|
||||
check "nginx" "http://127.0.0.1/"
|
||||
fi
|
||||
|
||||
exit "$failures"
|
||||
@@ -0,0 +1,89 @@
|
||||
# =============================================================================
|
||||
# nginx.conf.template — rendered by deploy.sh on every deploy
|
||||
# =============================================================================
|
||||
#
|
||||
# Variables substituted by `envsubst`:
|
||||
# ${POSTGREST_HOST_PORT} dynamic host port of the PostgREST container
|
||||
# ${NEXTJS_HOST_PORT} dynamic host port of the Next.js container
|
||||
# ${NEXT_PUBLIC_API_URL} (informational only — used in comment header)
|
||||
#
|
||||
# Layout:
|
||||
# /api/* -> http://127.0.0.1:${POSTGREST_HOST_PORT}
|
||||
# /* -> http://127.0.0.1:${NEXTJS_HOST_PORT}
|
||||
#
|
||||
# Tested against nginx >= 1.18 (Debian 11 / Ubuntu 22.04). Adjust ssl_*
|
||||
# lines if you don't have a cert yet — deploy.sh only tests/renders, the
|
||||
# operator decides whether to terminate TLS here.
|
||||
# =============================================================================
|
||||
|
||||
# --- upstream definitions ---------------------------------------------------
|
||||
upstream postgrest_upstream {
|
||||
server 127.0.0.1:${POSTGREST_HOST_PORT};
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
upstream nextjs_upstream {
|
||||
server 127.0.0.1:${NEXTJS_HOST_PORT};
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
# --- HTTP -> HTTPS upgrade (optional; remove if you only run on LAN) --------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# ACME http-01 challenge needs to be served on port 80.
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/letsencrypt;
|
||||
}
|
||||
|
||||
# Redirect everything else to HTTPS. Comment out for plain-HTTP dev.
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# --- main server block ------------------------------------------------------
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name _;
|
||||
|
||||
# --- TLS (uncomment + adjust after you obtain a cert) ------------------
|
||||
# ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# --- sensible defaults ------------------------------------------------
|
||||
client_max_body_size 25m;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# --- API: /api/* -> PostgREST ----------------------------------------
|
||||
location /api/ {
|
||||
proxy_pass http://postgrest_upstream;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
|
||||
# PostgREST exposes its OpenAPI spec at the root of the API; expose it
|
||||
# under a stable URL too.
|
||||
location = /api {
|
||||
proxy_pass http://postgrest_upstream;
|
||||
}
|
||||
|
||||
# --- everything else -> Next.js --------------------------------------
|
||||
location / {
|
||||
proxy_pass http://nextjs_upstream;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
# Multi-Brand Admin Support
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Status:** Draft → Approved
|
||||
**Author:** Grok (brainstorming session)
|
||||
**Migration file:** `supabase/migrations/204_multi_brand_admin.sql`
|
||||
**Follow-up migration (out of scope):** `220_drop_legacy_brand_id.sql`
|
||||
|
||||
## Problem
|
||||
|
||||
`admin_users.brand_id` is a single `UUID | null`. The platform supports a `platform_admin` role (no brand) and a `brand_admin` role (one brand). There is no representation for an admin who legitimately needs access to 2+ specific brands — e.g., a parent company operating multiple storefronts with shared operations staff.
|
||||
|
||||
The current `effectiveBrandId = brandId ?? adminUser.brand_id ?? null` pattern silently does the wrong thing for multi-brand use cases:
|
||||
|
||||
- Data model can't represent "Jane is admin for Brand A AND Brand B" — would require two `admin_users` rows (and the auth-user→admin-user join gets messy).
|
||||
- No central validation that an admin is acting in a brand they actually have access to.
|
||||
- No persistent "current brand" context — every page re-derives it from scratch.
|
||||
- No per-brand permission overrides possible (locks in flexibility for the future).
|
||||
|
||||
This spec addresses multi-brand tenants / franchises: same staff managing multiple brands under a parent org, where brands are separate for storefront/billing but share operations.
|
||||
|
||||
## Goals
|
||||
|
||||
- An admin can be associated with multiple brands via a junction table.
|
||||
- A persistent "active brand" is stored in a cookie, switchable via UI, and used as the default when no explicit brand is requested.
|
||||
- A new `multi_brand_admin` role makes the relationship explicit in the data and the UI.
|
||||
- `platform_admin` continues to work unchanged (gets all brands implicitly).
|
||||
- Existing single-brand `brand_admin`, `store_employee`, and `staff` users continue to work with zero behavior change.
|
||||
- Server actions get a single, central place to resolve and validate the active brand.
|
||||
|
||||
## Non-Goals (YAGNI)
|
||||
|
||||
- Per-(admin, brand) permission overrides. The user explicitly chose "same perms across all brands."
|
||||
- Brand-group / parent-org concept. The junction table makes this possible later, but it's not built now.
|
||||
- "Last accessed brand" auto-redirect. The cookie is the source of truth; no extra logic.
|
||||
- UI for managing `admin_user_brands` rows. Use Supabase Studio or a follow-up admin UI PR.
|
||||
- Dropping the legacy `admin_users.brand_id` column. A follow-up migration `220_drop_legacy_brand_id.sql` will do this after we verify nothing reads it. Out of scope for this spec.
|
||||
|
||||
## Approach: Junction Table + Backwards-Compat `brand_id`
|
||||
|
||||
Selected from among three options:
|
||||
|
||||
| Option | Why not |
|
||||
|---|---|
|
||||
| A. Junction + keep `brand_id` (selected) | — |
|
||||
| B. Junction only, drop `brand_id` | High migration risk; every server action reference must change. |
|
||||
| C. `brand_ids UUID[]` on `admin_users` | No FK, awkward reverse lookups, locks out per-brand metadata. |
|
||||
|
||||
A is the lowest-risk additive path that solves the problem.
|
||||
|
||||
## Data Model
|
||||
|
||||
### New table: `admin_user_brands`
|
||||
|
||||
```sql
|
||||
CREATE TABLE admin_user_brands (
|
||||
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
added_by UUID REFERENCES admin_users(id),
|
||||
PRIMARY KEY (admin_user_id, brand_id)
|
||||
);
|
||||
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
|
||||
```
|
||||
|
||||
- Composite PK enforces uniqueness.
|
||||
- Index on `brand_id` makes "which admins are in Brand X?" queries fast.
|
||||
- `added_at` / `added_by` for audit trail.
|
||||
- `ON DELETE CASCADE` for both FKs — deleting an admin or brand cleans up the junction.
|
||||
|
||||
### New role: `multi_brand_admin`
|
||||
|
||||
Added to the `role` CHECK constraint. Functionally equivalent to `brand_admin` permission-wise — same `can_manage_*` flags apply. The role label disambiguates intent in the UI ("this person manages 3 brands" vs "this person manages 1 brand") and in audit logs.
|
||||
|
||||
### Updated `AdminUser` type
|
||||
|
||||
```ts
|
||||
// src/lib/admin-permissions-types.ts
|
||||
export type AdminUser = {
|
||||
// ... existing fields
|
||||
brand_id: string | null; // active brand (one of brand_ids, or null for platform_admin)
|
||||
brand_ids: string[]; // all brands this admin can act in
|
||||
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
|
||||
};
|
||||
```
|
||||
|
||||
### Membership rules
|
||||
|
||||
| Role | `brand_id` (active) | `brand_ids` (membership) |
|
||||
|---|---|---|
|
||||
| `platform_admin` | `null` (or cookie-selected brand) | Implicitly all brands; `brand_ids` populated by `listBrandsForAdmin` querying the `brands` table |
|
||||
| `multi_brand_admin` | First/selected brand | 2+ brands from `admin_user_brands` |
|
||||
| `brand_admin` | Their single brand | `[that one brand]` |
|
||||
| `store_employee` | Their single brand | `[that one brand]` |
|
||||
| `staff` | Their single brand | `[that one brand]` |
|
||||
|
||||
For `platform_admin`, the application layer short-circuits brand-access checks (`if (adminUser.role === "platform_admin") return ...`). The `brand_ids` field is only used by `listBrandsForAdmin` to render the dropdown options; it is not used for permission gating for `platform_admin`.
|
||||
|
||||
### `getAdminUser()` resolution order
|
||||
|
||||
1. If `dev_session` cookie set → return dev admin. For `platform_admin` dev: `brand_id: null, brand_ids: []` (resolved against `brands` table by `listBrandsForAdmin`). For `store_employee` dev: `brand_id: <first-real-brand-id>, brand_ids: [<that-id>]` — fetched from the `brands` table so dev store_employee can browse a real brand's data. (If no brands exist, dev store_employee sees `<AdminAccessDenied />` — known limitation.)
|
||||
2. If `NEXT_PUBLIC_USE_MOCK_DATA=true` → same as `platform_admin` dev.
|
||||
3. Real auth → load `admin_users` row, then JOIN `admin_user_brands` to populate `brand_ids`.
|
||||
4. Set `brand_id` from (in order): `active_brand_id` cookie (if in `brand_ids` for non-platform-admin, or always for platform-admin) → `admin_users.brand_id` (if in `brand_ids`) → first of `brand_ids`.
|
||||
|
||||
## Server Action Patterns
|
||||
|
||||
### New file: `src/lib/brand-scope.ts`
|
||||
|
||||
```ts
|
||||
import { cookies } from "next/headers";
|
||||
import type { AdminUser } from "./admin-permissions-types";
|
||||
|
||||
const ACTIVE_BRAND_COOKIE = "active_brand_id";
|
||||
|
||||
export async function getActiveBrandId(
|
||||
adminUser: AdminUser,
|
||||
requested?: string | null
|
||||
): Promise<string | null> {
|
||||
// Cookie is the source of truth for "what brand am I acting in right now"
|
||||
// for everyone — including platform_admin (who can pin a specific brand
|
||||
// or fall back to null = "all brands").
|
||||
const cookieStore = await cookies();
|
||||
const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null;
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
// requested > cookie > null (all brands)
|
||||
return requested ?? cookieBrand ?? null;
|
||||
}
|
||||
|
||||
// Non-platform-admin: requested (if in brand_ids) > cookie (if in brand_ids) > adminUser.brand_id
|
||||
if (requested) {
|
||||
return adminUser.brand_ids.includes(requested) ? requested : null;
|
||||
}
|
||||
if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) {
|
||||
return cookieBrand;
|
||||
}
|
||||
return adminUser.brand_id;
|
||||
}
|
||||
|
||||
export async function setActiveBrandCookie(brandId: string): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(ACTIVE_BRAND_COOKIE, brandId, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearActiveBrandCookie(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(ACTIVE_BRAND_COOKIE);
|
||||
}
|
||||
|
||||
export function assertBrandAccess(adminUser: AdminUser, brandId: string): void {
|
||||
if (adminUser.role === "platform_admin") return;
|
||||
if (!adminUser.brand_ids.includes(brandId)) {
|
||||
throw new Error("Brand access denied");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Server action: set active brand
|
||||
|
||||
```ts
|
||||
// src/actions/admin/set-active-brand.ts
|
||||
"use server";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { setActiveBrandCookie, clearActiveBrandCookie } from "@/lib/brand-scope";
|
||||
|
||||
export async function setActiveBrand(brandId: string | null): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// null = "All brands" (platform_admin only)
|
||||
if (brandId === null) {
|
||||
if (adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Only platform admins can select 'All brands'" };
|
||||
}
|
||||
await clearActiveBrandCookie();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (adminUser.role !== "platform_admin" && !adminUser.brand_ids.includes(brandId)) {
|
||||
return { success: false, error: "No access to that brand" };
|
||||
}
|
||||
await setActiveBrandCookie(brandId);
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
### New server function: list brands for admin
|
||||
|
||||
```ts
|
||||
// src/actions/brands.ts
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function listBrandsForAdmin(): Promise<
|
||||
{ id: string; name: string; slug: string; logo_url: string | null }[]
|
||||
> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
|
||||
{ headers: svcHeaders(serviceKey) }
|
||||
);
|
||||
return res.ok ? await res.json() : [];
|
||||
}
|
||||
|
||||
if (adminUser.brand_ids.length === 0) return [];
|
||||
|
||||
const filter = `id=in.(${adminUser.brand_ids.map((id) => `"${id}"`).join(",")})`;
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
|
||||
{ headers: svcHeaders(serviceKey) }
|
||||
);
|
||||
return res.ok ? await res.json() : [];
|
||||
}
|
||||
```
|
||||
|
||||
### Server action migration pattern
|
||||
|
||||
```ts
|
||||
// Before:
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||
|
||||
// After:
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
```
|
||||
|
||||
This is a mechanical one-line swap in ~30 server actions identified by the grep:
|
||||
|
||||
- `src/actions/wholesale.ts` (multiple)
|
||||
- `src/actions/products.ts`
|
||||
- `src/actions/communications/templates.ts`
|
||||
- `src/actions/communications/campaigns.ts`
|
||||
- `src/actions/orders/create-admin-order.ts`
|
||||
- `src/actions/stops.ts`
|
||||
- `src/actions/analytics.ts`
|
||||
- `src/actions/square-sync-ui.ts`
|
||||
- `src/actions/shipping.ts`
|
||||
- `src/actions/payments.ts`
|
||||
- `src/actions/ai-import.ts`
|
||||
- `src/actions/wholesale-register.ts`
|
||||
|
||||
### Page server component pattern
|
||||
|
||||
```ts
|
||||
// Before (src/app/admin/orders/page.tsx):
|
||||
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
|
||||
|
||||
// After:
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
|
||||
export default async function AdminOrdersPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
|
||||
// ... rest of the page
|
||||
}
|
||||
```
|
||||
|
||||
This applies to every page under `src/app/admin/` — including the existing `/admin/taxes/[brandId]` and `/admin/settings/billing/[brandId]` which already do brand param resolution.
|
||||
|
||||
## UI Components
|
||||
|
||||
### Brand selector
|
||||
|
||||
**Location:** `src/components/admin/AdminHeader.tsx` (the top bar already rendered on every `/admin/*` page).
|
||||
|
||||
**Behavior:** A dropdown showing:
|
||||
- Brand logo + name (current active brand)
|
||||
- Chevron
|
||||
- "All brands" option at the top (only for `platform_admin`)
|
||||
- List of accessible brands (`adminUser.brand_ids`)
|
||||
- Small badge "Multi-brand manager" next to the user's name when `role === "multi_brand_admin"`
|
||||
|
||||
**Visibility matrix:**
|
||||
|
||||
| Admin | Show dropdown? | Options |
|
||||
|---|---|---|
|
||||
| `platform_admin` | Yes | "All brands" + list of all brands |
|
||||
| `multi_brand_admin` (2+ brands) | Yes | List of their brands |
|
||||
| `brand_admin` / `store_employee` / `staff` (1 brand) | No | — |
|
||||
| `platform_admin` (dev_session) | Yes | "All brands" + list of all brands (same UX as production) |
|
||||
|
||||
**On select:**
|
||||
|
||||
```ts
|
||||
// src/components/admin/BrandSelector.tsx (client component)
|
||||
"use client";
|
||||
async function handleSelect(brandId: string | null) {
|
||||
await setActiveBrand(brandId); // null = "All brands"
|
||||
router.refresh();
|
||||
}
|
||||
```
|
||||
|
||||
`router.refresh()` re-runs server components and re-reads the cookie, so all data on the current page reloads in the new brand context. The URL is **not** changed — the cookie is the source of truth for "what brand am I acting in right now."
|
||||
|
||||
### URL-level brand params
|
||||
|
||||
Keep URL-level brand as-is — URLs are shareable links. The resolution order for the `brandId` param passed to `getActiveBrandId` is:
|
||||
|
||||
1. URL `brandId` param (if present)
|
||||
2. `active_brand_id` cookie
|
||||
3. `adminUser.brand_id` (legacy single brand)
|
||||
4. First of `adminUser.brand_ids`
|
||||
5. (platform_admin only) `null` → "all brands"
|
||||
|
||||
## Migration: `supabase/migrations/204_multi_brand_admin.sql`
|
||||
|
||||
```sql
|
||||
-- 1. Add multi_brand_admin to role CHECK constraint
|
||||
ALTER TABLE admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check;
|
||||
ALTER TABLE admin_users ADD CONSTRAINT admin_users_role_check
|
||||
CHECK (role IN ('platform_admin', 'brand_admin', 'multi_brand_admin', 'store_employee', 'staff'));
|
||||
|
||||
-- 2. Create junction table
|
||||
CREATE TABLE admin_user_brands (
|
||||
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
added_by UUID REFERENCES admin_users(id),
|
||||
PRIMARY KEY (admin_user_id, brand_id)
|
||||
);
|
||||
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
|
||||
|
||||
-- 3. Backfill from existing brand_id (single-brand admins)
|
||||
INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
||||
SELECT id, brand_id FROM admin_users
|
||||
WHERE brand_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 4. Promote anyone with > 1 brand to multi_brand_admin
|
||||
UPDATE admin_users
|
||||
SET role = 'multi_brand_admin'
|
||||
WHERE role = 'brand_admin'
|
||||
AND id IN (
|
||||
SELECT admin_user_id FROM admin_user_brands
|
||||
GROUP BY admin_user_id HAVING COUNT(*) > 1
|
||||
);
|
||||
|
||||
-- 5. New RPCs for adding/removing brand access
|
||||
CREATE OR REPLACE FUNCTION add_admin_user_brand(
|
||||
p_admin_user_id UUID, p_brand_id UUID, p_added_by UUID
|
||||
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
|
||||
INSERT INTO admin_user_brands (admin_user_id, brand_id, added_by)
|
||||
VALUES (p_admin_user_id, p_brand_id, p_added_by)
|
||||
ON CONFLICT DO NOTHING;
|
||||
UPDATE admin_users SET role = 'multi_brand_admin'
|
||||
WHERE id = p_admin_user_id
|
||||
AND role = 'brand_admin'
|
||||
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) > 1;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION remove_admin_user_brand(
|
||||
p_admin_user_id UUID, p_brand_id UUID
|
||||
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
|
||||
DELETE FROM admin_user_brands
|
||||
WHERE admin_user_id = p_admin_user_id AND brand_id = p_brand_id;
|
||||
UPDATE admin_users SET role = 'brand_admin'
|
||||
WHERE id = p_admin_user_id
|
||||
AND role = 'multi_brand_admin'
|
||||
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) = 1;
|
||||
$$;
|
||||
```
|
||||
|
||||
The `admin_users.brand_id` column is **kept** for backwards compat. A follow-up migration drops it after we verify nothing reads it.
|
||||
|
||||
## Error Handling & Security Boundaries
|
||||
|
||||
### Three failure modes
|
||||
|
||||
1. **Requested brand not in `brand_ids`** (e.g., URL param has a brand the admin doesn't have):
|
||||
- `getActiveBrandId()` returns `null`
|
||||
- Server action returns `{ success: false, error: "Brand access required" }`
|
||||
- Page renders `<AdminAccessDenied />` with: "You don't have access to that brand. [Switch to a brand you have access to]"
|
||||
|
||||
2. **Cookie brand no longer in `brand_ids`** (admin's access was revoked while cookie was still set):
|
||||
- `getActiveBrandId()` falls through to `adminUser.brand_id`, then first of `brand_ids`
|
||||
- Silent recovery — no error, no UI flash
|
||||
- The dropped brand just disappears from the dropdown next page load
|
||||
|
||||
3. **Platform admin acting on a brand they don't own:**
|
||||
- Platform admin: `brand_ids = ["*"]` (sentinel) — RPCs treat as "all brands"
|
||||
- Server action: never blocks platform admin from any brand
|
||||
- This is intentional — platform admin = superuser
|
||||
|
||||
### Validation placement (defense in depth)
|
||||
|
||||
- Server action: `getActiveBrandId(adminUser, requested)` validates.
|
||||
- Server action: `assertBrandAccess(adminUser, brandIdFromUrl)` validates (separate, for cases where the brandId comes from URL/form/RPC return rather than `getActiveBrandId`).
|
||||
- RPC: still trusts `p_brand_id` (SECURITY DEFINER) — application layer is the gate, matching the existing architecture.
|
||||
|
||||
### Audit logging (additive)
|
||||
|
||||
- `admin_user_brands.added_by` column tracks who added an admin to a brand.
|
||||
- Audit log entry on add/remove: out of scope for v1; documented for follow-up.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests (`vitest` — new dev dependency)
|
||||
|
||||
- `src/lib/brand-scope.test.ts`:
|
||||
- `resolveActiveBrandId(platformAdmin, "X")` → `"X"`
|
||||
- `resolveActiveBrandId(brandAdmin, "X")` where X is their brand → `"X"`
|
||||
- `resolveActiveBrandId(brandAdmin, "Y")` where Y is not their brand → `null`
|
||||
- `getActiveBrandId(multiBrandAdmin)` with cookie set to valid brand → that brand
|
||||
- `getActiveBrandId(multiBrandAdmin)` with cookie set to revoked brand → first of `brand_ids`
|
||||
- `assertBrandAccess(...)` throws for non-platform-admin with invalid brand
|
||||
- `setActiveBrand(null)` rejected for non-platform-admin
|
||||
- `setActiveBrand("X")` rejected for admin without X in `brand_ids`
|
||||
|
||||
### Integration tests (Playwright — already in repo)
|
||||
|
||||
- `tests/admin/multi-brand.spec.ts`:
|
||||
- As `multi_brand_admin`, dropdown shows 2+ brands
|
||||
- Click brand B → URL stays, cookie updates, page data refreshes to brand B
|
||||
- Direct-navigate to `/admin/orders?brand=<other-brand>` for a brand admin returns access denied
|
||||
- As `platform_admin`, "All brands" option is present and works
|
||||
- As `brand_admin` with 1 brand, no dropdown is shown
|
||||
|
||||
### Migration smoke test (manual, documented in MEMORY.md)
|
||||
|
||||
- Before migration: 5 brand_admins exist, each with 1 brand
|
||||
- After migration: 5 rows in `admin_user_brands`, all `role = 'brand_admin'`
|
||||
- Create a 6th admin with 2 brands via `add_admin_user_brand` → `role = 'multi_brand_admin'`, 2 rows in junction
|
||||
- Remove one of their brands via `remove_admin_user_brand` → `role` demotes to `brand_admin`, 1 row in junction
|
||||
|
||||
## Out of Scope (v1)
|
||||
|
||||
- Per-(admin, brand) permission overrides
|
||||
- Brand-group / parent-org concept
|
||||
- "Last accessed brand" auto-redirect
|
||||
- UI for managing `admin_user_brands` rows (Supabase Studio works for now)
|
||||
- Dropping the legacy `admin_users.brand_id` column (follow-up `220_*` migration)
|
||||
- Audit log entries for add/remove (junction's `added_by` column is the seed)
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Migration `204_multi_brand_admin.sql` applied
|
||||
2. `src/lib/brand-scope.ts` + unit tests (TDD)
|
||||
3. `AdminUser` type updated; `getAdminUser()` returns `brand_ids`
|
||||
4. `setActiveBrand` server action + tests
|
||||
5. `listBrandsForAdmin` server function + tests
|
||||
6. BrandSelector UI component
|
||||
7. Wire BrandSelector into AdminHeader
|
||||
8. Server action migration (~30 actions) — mechanical one-line swap each
|
||||
9. Page server component migration (~10+ pages) — same pattern
|
||||
10. Playwright integration tests
|
||||
11. Manual smoke test of the migration on dev DB
|
||||
@@ -0,0 +1,14 @@
|
||||
import openpyxl
|
||||
path = "/home/coder/dev/x1/kyle/route_commerce-main/Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
|
||||
wb = openpyxl.load_workbook(path, data_only=True)
|
||||
for name in wb.sheetnames:
|
||||
ws = wb[name]
|
||||
print(f"=== SHEET: {name} ({ws.max_row} rows x {ws.max_column} cols) ===")
|
||||
for row in ws.iter_rows(values_only=False):
|
||||
for cell in row:
|
||||
if cell.value is not None:
|
||||
v = str(cell.value)
|
||||
if len(v) > 200:
|
||||
v = v[:200] + "..."
|
||||
print(f" {cell.coordinate}: {v!r}")
|
||||
print()
|
||||
@@ -1,64 +0,0 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const response = NextResponse.next({ request });
|
||||
|
||||
// ── Dev session bypass (enabled in all envs for demo) ──────────────
|
||||
// Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
|
||||
const devSession = request.cookies.get("dev_session")?.value;
|
||||
const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee";
|
||||
const rcAuthUid = request.cookies.get("rc_auth_uid")?.value;
|
||||
|
||||
let authUid: string | null = null;
|
||||
|
||||
if (isDevMode) {
|
||||
// Dev session only valid in development
|
||||
authUid = DEV_UID;
|
||||
} else if (rcAuthUid) {
|
||||
// rc_auth_uid is set by /api/login — treat as authenticated
|
||||
authUid = rcAuthUid;
|
||||
}
|
||||
// No rc_auth_uid in production → authUid stays null → redirect to /login
|
||||
|
||||
const isAdmin = request.nextUrl.pathname.startsWith("/admin");
|
||||
const isLogin = request.nextUrl.pathname === "/login";
|
||||
|
||||
if (isAdmin && !authUid) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
// Auto-login for demo: no Supabase configured, no auth cookie present
|
||||
if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/admin";
|
||||
url.searchParams.set("demo", "1");
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set("dev_session", "platform_admin", {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24,
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
if (isLogin && authUid) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/admin";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/admin/:path*",
|
||||
"/admin",
|
||||
"/login",
|
||||
],
|
||||
};
|
||||
+7
-2
@@ -3,7 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"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",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
@@ -15,10 +15,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.96.0",
|
||||
"@auth/pg-adapter": "^1.11.2",
|
||||
"@clerk/nextjs": "^7.4.2",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@sentry/nextjs": "^10.55.0",
|
||||
"@stripe/react-stripe-js": "^3.10.0",
|
||||
"@stripe/stripe-js": "^5.10.0",
|
||||
"@supabase/ssr": "^0.10.2",
|
||||
"@supabase/supabase-js": "^2.105.3",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
@@ -28,6 +31,7 @@
|
||||
"gsap": "^3.15.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "^16.2.6",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.37.0",
|
||||
"papaparse": "^5.5.3",
|
||||
@@ -48,6 +52,7 @@
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -58,7 +63,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"playwright": "^1.59.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": "{}"
|
||||
}
|
||||
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
# End-to-end validation test for the local Postgres + PostgREST + MinIO + Next.js stack
|
||||
# Exit 0 = all green, exit 1 = at least one failure.
|
||||
|
||||
set -e
|
||||
API="http://localhost:3001"
|
||||
WEB="http://localhost:4000"
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
declare -a FAILURES
|
||||
|
||||
check() {
|
||||
local name="$1" url="$2" expected="${3:-200}" cookies="${4:-}"
|
||||
local cmd="curl -s -o /dev/null -w '%{http_code}'"
|
||||
if [ -n "$cookies" ]; then cmd="$cmd -b \"$cookies\""; fi
|
||||
local code=$(eval "$cmd $url")
|
||||
if [ "$code" = "$expected" ]; then
|
||||
echo " PASS $name ($code)"
|
||||
pass=$((pass+1))
|
||||
else
|
||||
echo " FAIL $name expected=$expected got=$code url=$url"
|
||||
fail=$((fail+1))
|
||||
FAILURES+=("$name")
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Postgres connection ==="
|
||||
PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT 'ok';" >/dev/null
|
||||
echo " PASS postgres responds"
|
||||
pass=$((pass+1))
|
||||
|
||||
echo "=== DB integrity ==="
|
||||
TABLE_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';")
|
||||
[ "$TABLE_COUNT" -ge 65 ] && echo " PASS $TABLE_COUNT public tables (>=65)" && pass=$((pass+1)) || { echo " FAIL $TABLE_COUNT tables"; fail=$((fail+1)); }
|
||||
|
||||
FN_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname='public';")
|
||||
[ "$FN_COUNT" -ge 250 ] && echo " PASS $FN_COUNT functions (>=250)" && pass=$((pass+1)) || { echo " FAIL $FN_COUNT functions"; fail=$((fail+1)); }
|
||||
|
||||
BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands;")
|
||||
[ "$BRANDS" -ge 2 ] && echo " PASS $BRANDS brands" && pass=$((pass+1)) || { echo " FAIL $BRANDS brands"; fail=$((fail+1)); }
|
||||
|
||||
STOPS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM stops WHERE active=true AND deleted_at IS NULL;")
|
||||
[ "$STOPS" -ge 200 ] && echo " PASS $STOPS active stops" && pass=$((pass+1)) || { echo " FAIL $STOPS stops"; fail=$((fail+1)); }
|
||||
|
||||
# No test brands
|
||||
TEST_BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands WHERE slug IN ('sunrise-farms','green-valley','orchard-fresh');")
|
||||
[ "$TEST_BRANDS" = "0" ] && echo " PASS no test brands" && pass=$((pass+1)) || { echo " FAIL test brands found"; fail=$((fail+1)); }
|
||||
|
||||
echo "=== Tuxedo Corn data ==="
|
||||
PHONE=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT phone FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de';")
|
||||
[ "$PHONE" = "970-323-6874" ] && echo " PASS phone=$PHONE" && pass=$((pass+1)) || { echo " FAIL phone=$PHONE (expected 970-323-6874)"; fail=$((fail+1)); }
|
||||
|
||||
LOGOS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de' AND logo_url LIKE '/storage/%';")
|
||||
[ "$LOGOS" = "1" ] && echo " PASS logo_url is /storage/ path" && pass=$((pass+1)) || { echo " FAIL logo_url not /storage/"; fail=$((fail+1)); }
|
||||
|
||||
echo "=== PostgREST ==="
|
||||
check "GET /" "$API/"
|
||||
check "GET /brands" "$API/brands?select=id,name,slug&order=name"
|
||||
check "GET /brands?slug=eq.tuxedo" "$API/brands?slug=eq.tuxedo&select=*"
|
||||
check "GET /stops" "$API/stops?select=id,city&limit=5"
|
||||
check "GET /products" "$API/products?select=id,name&limit=5"
|
||||
check "POST rpc get_brand_settings_by_slug" "$API/rpc/get_brand_settings_by_slug" 200 \
|
||||
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
|
||||
check "POST rpc get_brand_plan_info" "$API/rpc/get_brand_plan_info" 200 \
|
||||
"-X POST -H Content-Type:application/json -d {\"p_brand_id\":\"64294306-5f42-463d-a5e8-2ad6c81a96de\"}"
|
||||
check "POST rpc get_public_stops_for_brand" "$API/rpc/get_public_stops_for_brand" 200 \
|
||||
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
|
||||
check "GET /admin_users with brand join" "$API/admin_users?select=id,user_id,role,brand_id,brands(name)&limit=5"
|
||||
check "GET /stops with brand join" "$API/stops?select=id,city,brand_id,brands(name)&limit=5"
|
||||
|
||||
echo "=== MinIO ==="
|
||||
check "Storage logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
|
||||
check "Storage olathe-sweet-logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
|
||||
check "Storage olathe-sweet-logo-dark.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
|
||||
|
||||
echo "=== Public storefronts ==="
|
||||
check "GET /" "$WEB/"
|
||||
check "GET /tuxedo" "$WEB/tuxedo"
|
||||
check "GET /tuxedo/about" "$WEB/tuxedo/about"
|
||||
check "GET /tuxedo/stops" "$WEB/tuxedo/stops"
|
||||
check "GET /indian-river-direct" "$WEB/indian-river-direct"
|
||||
check "GET /login" "$WEB/login"
|
||||
|
||||
echo "=== Admin pages (dev_session=platform_admin) ==="
|
||||
for p in /admin /admin/products /admin/stops /admin/orders /admin/users /admin/settings /admin/settings/billing /admin/settings/apps /admin/settings/payments /admin/communications /admin/communications/compose /admin/time-tracking /admin/wholesale /admin/water-log /admin/analytics /admin/reports; do
|
||||
check "GET $p" "$WEB$p" 200 "dev_session=platform_admin"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
echo " PASS: $pass"
|
||||
echo " FAIL: $fail"
|
||||
if [ $fail -gt 0 ]; then
|
||||
echo " Failures:"
|
||||
for f in "${FAILURES[@]}"; do echo " - $f"; done
|
||||
exit 1
|
||||
fi
|
||||
echo " ALL GREEN"
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
seed_tuxedo_tour.py
|
||||
|
||||
Parses Tuxedo_Corn_2026_Tour_Schedule-3.xlsx and seeds the `stops` table for
|
||||
the Tuxedo brand via the admin_create_stops_batch RPC.
|
||||
|
||||
Skips:
|
||||
- Title / subtitle / legend rows (rows 1-3)
|
||||
- Week header rows (col A = "Wk N", col D-J empty)
|
||||
- Cross-Dock / Monday OFF rows (col D contains "OFF" or "Cross-Dock")
|
||||
|
||||
Joins with the Stop Directory sheet to enrich each stop with:
|
||||
- address, phone, contact
|
||||
|
||||
Usage:
|
||||
python3 scripts/seed_tuxedo_tour.py --dry-run # show what would be inserted
|
||||
python3 scripts/seed_tuxedo_tour.py # actually insert
|
||||
|
||||
Requires:
|
||||
- supabase CLI linked to project wnzkhezyhnfzhkhiflrp
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"
|
||||
YEAR = 2026
|
||||
|
||||
MONTH_MAP = {
|
||||
"Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04",
|
||||
"May": "05", "Jun": "06", "Jul": "07", "Aug": "08",
|
||||
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
|
||||
}
|
||||
|
||||
DEFAULT_XLSX = (
|
||||
"/home/coder/dev/x1/kyle/route_commerce-main/"
|
||||
"Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
|
||||
)
|
||||
|
||||
|
||||
def parse_excel_date(s):
|
||||
"""'Jul 22' -> '2026-07-22'"""
|
||||
if not s:
|
||||
return None
|
||||
m = re.match(r"^([A-Za-z]{3})\s+(\d{1,2})$", str(s).strip())
|
||||
if not m:
|
||||
return None
|
||||
mm = MONTH_MAP.get(m.group(1))
|
||||
if not mm:
|
||||
return None
|
||||
return f"{YEAR}-{mm}-{int(m.group(2)):02d}"
|
||||
|
||||
|
||||
def parse_time_range(s):
|
||||
"""'10:00 AM - 1:00 PM' -> '10:00 AM' (start time)"""
|
||||
if not s:
|
||||
return ""
|
||||
cleaned = re.sub(r"[–—]", "-", str(s)).strip()
|
||||
cleaned = re.sub(r"\s+", " ", cleaned)
|
||||
m = re.match(r"^(\d{1,2}:\d{2}\s*[AP]M)", cleaned, re.IGNORECASE)
|
||||
return m.group(1).upper().replace(" ", " ") if m else cleaned
|
||||
|
||||
|
||||
def split_city_state(s):
|
||||
"""'Cheyenne, WY' -> ('Cheyenne', 'WY')"""
|
||||
if not s:
|
||||
return "", ""
|
||||
parts = [p.strip() for p in str(s).split(",")]
|
||||
if len(parts) == 1:
|
||||
return parts[0], ""
|
||||
return parts[0], parts[1]
|
||||
|
||||
|
||||
def slugify(s):
|
||||
s = (s or "").lower()
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
def is_week_header(row):
|
||||
a = str(row[0] or "").strip()
|
||||
d = str(row[3] or "").strip()
|
||||
return re.match(r"^Wk\s", a) and d == ""
|
||||
|
||||
|
||||
def is_off_row(row):
|
||||
d = str(row[3] or "").strip()
|
||||
return "OFF" in d or "Cross-Dock" in d or "Cross‑Dock" in d
|
||||
|
||||
|
||||
def is_data_row(row):
|
||||
d = str(row[3] or "").strip()
|
||||
e = str(row[4] or "").strip()
|
||||
if not d or not e:
|
||||
return False
|
||||
if "," not in e:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def load(xlsx_path):
|
||||
wb = load_workbook(xlsx_path, data_only=True)
|
||||
schedule = wb["Full Schedule"]
|
||||
directory = wb["Stop Directory"]
|
||||
|
||||
# Build Stop Directory lookup: (truck, host_normalized) -> {address, phone, contact, ...}
|
||||
dir_map = {}
|
||||
for row in directory.iter_rows(min_row=2, values_only=True):
|
||||
truck = str(row[0] or "").strip()
|
||||
city = str(row[1] or "").strip()
|
||||
state = str(row[2] or "").strip()
|
||||
host = str(row[3] or "").strip()
|
||||
address = str(row[4] or "").strip()
|
||||
phone = str(row[5] or "").strip()
|
||||
contact = str(row[6] or "").strip()
|
||||
if not truck or not host:
|
||||
continue
|
||||
key = f"{truck}|{host.lower()}"
|
||||
dir_map[key] = {
|
||||
"city": city, "state": state, "host": host,
|
||||
"address": address, "phone": phone, "contact": contact,
|
||||
}
|
||||
|
||||
# Read Full Schedule (skip first 3 title/subtitle/legend rows)
|
||||
stops = []
|
||||
skipped = {"weekHeader": 0, "off": 0, "invalid": 0}
|
||||
for row in schedule.iter_rows(min_row=4, values_only=True):
|
||||
# Trim to 10 cols
|
||||
cells = [("" if v is None else str(v).strip()) for v in row[:10]]
|
||||
if is_week_header(cells):
|
||||
skipped["weekHeader"] += 1
|
||||
continue
|
||||
if is_off_row(cells):
|
||||
skipped["off"] += 1
|
||||
continue
|
||||
if not is_data_row(cells):
|
||||
skipped["invalid"] += 1
|
||||
continue
|
||||
|
||||
wk, region, date_text, day, city_state, host, time, truck, status, notes = cells
|
||||
date_iso = parse_excel_date(date_text)
|
||||
if not date_iso:
|
||||
skipped["invalid"] += 1
|
||||
continue
|
||||
city, state = split_city_state(city_state)
|
||||
if not city:
|
||||
skipped["invalid"] += 1
|
||||
continue
|
||||
|
||||
# Enrich from directory
|
||||
dir_key = f"{truck}|{host.lower()}"
|
||||
d = dir_map.get(dir_key)
|
||||
|
||||
stops.append({
|
||||
"week": wk,
|
||||
"region": region,
|
||||
"date": date_iso,
|
||||
"day": day,
|
||||
"city": city,
|
||||
"state": state or (d["state"] if d else ""),
|
||||
"location": host,
|
||||
"time": parse_time_range(time),
|
||||
"time_range": time,
|
||||
"truck": truck,
|
||||
"status_text": status,
|
||||
"notes": notes,
|
||||
"address": d["address"] if d and d["address"] else None,
|
||||
"phone": d["phone"] if d and d["phone"] else None,
|
||||
"contact": d["contact"] if d and d["contact"] else None,
|
||||
})
|
||||
|
||||
return stops, skipped, len(dir_map)
|
||||
|
||||
|
||||
def assign_slugs(stops, dry_run):
|
||||
used = set()
|
||||
if not dry_run:
|
||||
out = subprocess.run(
|
||||
["supabase", "db", "query", "--linked",
|
||||
f"SELECT slug FROM stops WHERE brand_id = '{TUXEDO_BRAND_ID}';"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
# Parse the table output - slugs are in second column between │
|
||||
for m in re.finditer(r"│\s*([a-z0-9][a-z0-9-]*)\s*│", out.stdout):
|
||||
used.add(m.group(1))
|
||||
|
||||
for s in stops:
|
||||
base = f"{slugify(s['city'])}-{s['date']}"
|
||||
slug = base
|
||||
n = 0
|
||||
while slug in used:
|
||||
n += 1
|
||||
slug = f"{base}-{n}"
|
||||
used.add(slug)
|
||||
s["slug"] = slug
|
||||
|
||||
|
||||
def to_rpc_row(s):
|
||||
return {
|
||||
"city": s["city"],
|
||||
"state": s["state"],
|
||||
"location": s["location"],
|
||||
"date": f"{s['date']} 00:00:00+00",
|
||||
"time": s["time"],
|
||||
"address": s["address"],
|
||||
"zip": None,
|
||||
"cutoff_time": None,
|
||||
# active=true so the stops appear on the public storefront immediately.
|
||||
# Matches the behavior of publishStop in src/actions/stops.ts.
|
||||
"active": True,
|
||||
}
|
||||
|
||||
|
||||
def build_payload_json(batch):
|
||||
"""Build a clean JSON string for use in a SQL file."""
|
||||
return json.dumps(batch, ensure_ascii=False)
|
||||
|
||||
|
||||
def insert_batch(batch):
|
||||
"""Write SQL to a temp file and execute via --file to avoid shell escaping."""
|
||||
payload_json = build_payload_json(batch)
|
||||
sql = (
|
||||
f"SELECT admin_create_stops_batch("
|
||||
f"'{TUXEDO_BRAND_ID}'::uuid, "
|
||||
f"$${payload_json}$$::jsonb);\n"
|
||||
)
|
||||
# Write to temp file
|
||||
tmp_path = Path("/tmp/seed_tuxedo_tour.sql")
|
||||
tmp_path.write_text(sql, encoding="utf-8")
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["supabase", "db", "query", "--linked", "--file", str(tmp_path)],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"RPC failed: {proc.stderr[:800]}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--xlsx", default=DEFAULT_XLSX)
|
||||
args = ap.parse_args()
|
||||
|
||||
if not Path(args.xlsx).exists():
|
||||
sys.exit(f"XLSX not found: {args.xlsx}")
|
||||
|
||||
stops, skipped, dir_count = load(args.xlsx)
|
||||
assign_slugs(stops, dry_run=args.dry_run)
|
||||
|
||||
print(f"\nParsed {len(stops)} stops "
|
||||
f"(skipped: {skipped['weekHeader']} week-headers, "
|
||||
f"{skipped['off']} OFF days, {skipped['invalid']} invalid)")
|
||||
print(f"Stop Directory: {dir_count} entries loaded for enrichment\n")
|
||||
|
||||
if not stops:
|
||||
sys.exit("No stops to insert.")
|
||||
|
||||
print("Sample (first 3):")
|
||||
for s in stops[:3]:
|
||||
print(f" {s['date']} {s['time']:10s} | {s['city']:18s}, {s['state']:2s} | "
|
||||
f"{s['location'][:35]:35s} | {s['truck']} | {s['status_text']} | {s['slug']}")
|
||||
if s["notes"]:
|
||||
print(f" notes: {s['notes'][:120]}")
|
||||
if s["address"]:
|
||||
print(f" addr: {s['address']} ph: {s['phone']} ctc: {s['contact']}")
|
||||
print()
|
||||
|
||||
# Show counts by week and region
|
||||
by_week = {}
|
||||
by_region = {}
|
||||
by_truck = {}
|
||||
for s in stops:
|
||||
by_week[s["week"]] = by_week.get(s["week"], 0) + 1
|
||||
by_region[s["region"]] = by_region.get(s["region"], 0) + 1
|
||||
by_truck[s["truck"]] = by_truck.get(s["truck"], 0) + 1
|
||||
print("By week:", dict(sorted(by_week.items())))
|
||||
print("By region:", by_region)
|
||||
print("By truck:", by_truck)
|
||||
print()
|
||||
|
||||
# Date range
|
||||
dates = sorted(s["date"] for s in stops)
|
||||
print(f"Date range: {dates[0]} to {dates[-1]}\n")
|
||||
|
||||
if args.dry_run:
|
||||
batches = (len(stops) + 49) // 50
|
||||
print(f"[DRY RUN] Would insert {len(stops)} stops in {batches} batch(es) of 50.")
|
||||
return
|
||||
|
||||
BATCH = 50
|
||||
total = 0
|
||||
batches = (len(stops) + BATCH - 1) // BATCH
|
||||
for i in range(0, len(stops), BATCH):
|
||||
batch = [to_rpc_row(s) for s in stops[i:i + BATCH]]
|
||||
bnum = i // BATCH + 1
|
||||
sys.stdout.write(f" Inserting batch {bnum}/{batches} ({len(batch)} stops)... ")
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
insert_batch(batch)
|
||||
total += len(batch)
|
||||
print("OK")
|
||||
except Exception as e:
|
||||
print("FAIL")
|
||||
print(f" {e}")
|
||||
|
||||
# The batch RPC hardcodes status='draft' on insert. The Tuxedo storefront
|
||||
# page only filters on active=true (not status), so active=true is enough
|
||||
# to make stops visible. But for consistency with the publishStop server
|
||||
# action — which sets both — flip status to 'active' for the rows we just
|
||||
# inserted. Slug-based so we only touch stops from this run, not the
|
||||
# pre-existing "Olathe" test stop.
|
||||
if total > 0:
|
||||
slugs = [s["slug"] for s in stops]
|
||||
# Build a safe IN list (slug is a text column)
|
||||
slug_list = ", ".join(f"'{slug.replace(chr(39), chr(39)+chr(39))}'" for slug in slugs)
|
||||
publish_sql = (
|
||||
f"UPDATE stops SET status = 'active' "
|
||||
f"WHERE brand_id = '{TUXEDO_BRAND_ID}' "
|
||||
f"AND slug IN ({slug_list});"
|
||||
)
|
||||
tmp = Path("/tmp/seed_tuxedo_publish.sql")
|
||||
tmp.write_text(publish_sql, encoding="utf-8")
|
||||
try:
|
||||
subprocess.run(
|
||||
["supabase", "db", "query", "--linked", "--file", str(tmp)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
print(f"\n Published {total} stops (status -> 'active').")
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
print(f"\nDone. Inserted {total}/{len(stops)} stops for Tuxedo brand.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser";
|
||||
import { importProductsBatch } from "@/actions/import-products";
|
||||
import { importOrdersBatch } from "@/actions/import-orders";
|
||||
@@ -41,7 +42,9 @@ export async function analyzeImport(
|
||||
): Promise<AnalyzeImportResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
@@ -96,7 +97,7 @@ async function brandScopedRPC<T>(
|
||||
const adminUser = await getAdminUser();
|
||||
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}`, {
|
||||
method: "POST",
|
||||
@@ -232,7 +233,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const brandId = adminUser.brand_id ?? null;
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
select: "id,customer_name,subtotal,status,created_at,fulfillment",
|
||||
@@ -293,7 +294,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const brandId = adminUser.brand_id ?? null;
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
select: "id,status",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
|
||||
import { signIn, signOut } from "@/lib/auth";
|
||||
import { AuthError } from "next-auth";
|
||||
|
||||
/**
|
||||
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
|
||||
* use from client components.
|
||||
*
|
||||
* Why server actions?
|
||||
* • The Auth.js v5 `signIn` function has to run on the server (it
|
||||
* needs to set the session cookie, talk to the database adapter,
|
||||
* and redirect the user to the OAuth provider).
|
||||
* • Calling it from a client component via a server action keeps the
|
||||
* client bundle small and avoids exposing the OAuth client secret.
|
||||
*
|
||||
* Usage from a client component:
|
||||
* <form action={signInWithGoogle}>
|
||||
* <button type="submit">Sign in with Google</button>
|
||||
* </form>
|
||||
*
|
||||
* Usage for the dev credentials provider (dev only):
|
||||
* <form action={signInWithDev}>
|
||||
* <input name="username" />
|
||||
* <input name="password" type="password" />
|
||||
* <button type="submit">Dev login</button>
|
||||
* </form>
|
||||
*/
|
||||
|
||||
export async function signInWithGoogle(): Promise<void> {
|
||||
await signIn("google", { redirectTo: "/admin" });
|
||||
}
|
||||
|
||||
export async function signInWithDev(formData: FormData): Promise<void> {
|
||||
const username = String(formData.get("username") ?? "admin");
|
||||
const password = String(formData.get("password") ?? "dev");
|
||||
try {
|
||||
await signIn("dev-login", {
|
||||
username,
|
||||
password,
|
||||
redirectTo: "/admin",
|
||||
});
|
||||
} catch (e) {
|
||||
// signIn() throws a `NEXT_REDIRECT` to navigate — let that through
|
||||
// so the redirect actually happens. Re-throw any other error so the
|
||||
// caller can render a meaningful message.
|
||||
if (e instanceof AuthError) {
|
||||
throw new Error(`Dev sign-in failed: ${e.type}`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOutAction(): Promise<void> {
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use server";
|
||||
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
/**
|
||||
* Creates a Stripe PaymentIntent for the supplied cart so the browser
|
||||
* can confirm the payment with embedded Stripe Elements (Apple Pay /
|
||||
* Google Pay / card) without redirecting to a hosted page.
|
||||
*
|
||||
* Mirrors `createRetailStripeCheckoutSession` in `retail-checkout.ts`:
|
||||
* the PaymentIntent is the embedded equivalent of the Checkout Session.
|
||||
* Order creation itself still happens on `/checkout/success` so the
|
||||
* webhooks and order pipeline don't need to know which path the buyer
|
||||
* used.
|
||||
*/
|
||||
type LineItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
type CustomerInfo = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
type CreatePaymentIntentResult =
|
||||
| { success: true; clientSecret: string; paymentIntentId: string; amount: number }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function createRetailPaymentIntent(
|
||||
items: LineItem[],
|
||||
customer: CustomerInfo,
|
||||
brandId: string | null,
|
||||
stopId: string | null,
|
||||
shippingAddress?: { state?: string; postal_code?: string; city?: string } | null
|
||||
): Promise<CreatePaymentIntentResult> {
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!stripeKey) {
|
||||
return { success: false, error: "Stripe not configured on this server." };
|
||||
}
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return { success: false, error: "Cart is empty." };
|
||||
}
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
|
||||
|
||||
// Compute the subtotal in cents. We don't compute sales tax here —
|
||||
// Stripe's `automatic_tax` would be ideal but requires address collection
|
||||
// client-side; for this single-product corn box the price is tax-inclusive
|
||||
// (see Tuxedo Corn product card) so we treat the line total as final.
|
||||
const amount = items.reduce((sum, item) => {
|
||||
const qty = Math.max(1, Math.floor(item.quantity || 1));
|
||||
const unit = Math.max(0, Math.round(Number(item.price) * 100));
|
||||
return sum + unit * qty;
|
||||
}, 0);
|
||||
|
||||
if (amount <= 0) {
|
||||
return { success: false, error: "Cart total must be greater than $0." };
|
||||
}
|
||||
|
||||
// Pull the brand name for Stripe receipts + metadata
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
let brandName = "Route Commerce";
|
||||
if (supabaseUrl && supabaseKey && brandId) {
|
||||
try {
|
||||
const brandRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
|
||||
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||
);
|
||||
const brands = (await brandRes.json()) as Array<{ name: string }>;
|
||||
if (brands?.[0]?.name) brandName = brands[0].name;
|
||||
} catch {
|
||||
// ignore — use default
|
||||
}
|
||||
}
|
||||
|
||||
// Build a short human-readable description for the Stripe dashboard
|
||||
const description = items
|
||||
.slice(0, 3)
|
||||
.map((i) => `${i.quantity}× ${i.name}`)
|
||||
.join(", ");
|
||||
|
||||
try {
|
||||
const intent = await stripe.paymentIntents.create({
|
||||
amount,
|
||||
currency: "usd",
|
||||
automatic_payment_methods: { enabled: true },
|
||||
receipt_email: customer.email || undefined,
|
||||
description,
|
||||
metadata: {
|
||||
brand_id: brandId ?? "unknown",
|
||||
brand_name: brandName,
|
||||
stop_id: stopId ?? "",
|
||||
customer_name: customer.name ?? "",
|
||||
customer_email: customer.email ?? "",
|
||||
shipping_state: shippingAddress?.state ?? "",
|
||||
shipping_postal_code: shippingAddress?.postal_code ?? "",
|
||||
shipping_city: shippingAddress?.city ?? "",
|
||||
item_count: String(items.reduce((s, i) => s + i.quantity, 0)),
|
||||
source: "storefront_express",
|
||||
},
|
||||
});
|
||||
|
||||
if (!intent.client_secret) {
|
||||
return { success: false, error: "Stripe did not return a client secret." };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
clientSecret: intent.client_secret,
|
||||
paymentIntentId: intent.id,
|
||||
amount: intent.amount,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create payment intent.";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
@@ -57,7 +58,11 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
|
||||
const adminUser = await getAdminUser();
|
||||
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 supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
@@ -147,7 +152,7 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: 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",
|
||||
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";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
@@ -120,11 +121,15 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Resolve brand from campaign or parameter
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id;
|
||||
// Resolve brand from campaign or parameter (URL > cookie > legacy > first of brand_ids)
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
|
||||
// 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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
@@ -33,7 +34,11 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
|
||||
const adminUser = await getAdminUser();
|
||||
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
|
||||
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",
|
||||
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";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
@@ -66,7 +67,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const brandId = adminUser.brand_id ?? null;
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
|
||||
// Get today's date range
|
||||
const today = new Date();
|
||||
@@ -202,7 +203,7 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const brandId = adminUser.brand_id ?? null;
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type LocationInput = {
|
||||
name: string;
|
||||
address?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
zip?: string | null;
|
||||
phone?: string | null;
|
||||
contact_name?: string | null;
|
||||
contact_email?: string | null;
|
||||
notes?: string | null;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export type Location = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
zip: string | null;
|
||||
phone: string | null;
|
||||
contact_name: string | null;
|
||||
contact_email: string | null;
|
||||
notes: string | null;
|
||||
active: boolean;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
slug: string | null;
|
||||
};
|
||||
|
||||
export type LocationWithCount = Location & { stop_count: number };
|
||||
|
||||
// ── Create (single) ──────────────────────────────────────────────────────────
|
||||
export async function createLocation(
|
||||
brandId: string,
|
||||
input: LocationInput
|
||||
): Promise<{ success: true; id: string; slug: string } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) {
|
||||
return { success: false, error: "Not authorized to manage locations" };
|
||||
}
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand selected" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_create_location`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: effectiveBrandId,
|
||||
p_name: input.name,
|
||||
p_address: input.address ?? null,
|
||||
p_city: input.city ?? null,
|
||||
p_state: input.state ?? null,
|
||||
p_zip: input.zip ?? null,
|
||||
p_phone: input.phone ?? null,
|
||||
p_contact_name: input.contact_name ?? null,
|
||||
p_contact_email: input.contact_email ?? null,
|
||||
p_notes: input.notes ?? null,
|
||||
p_active: input.active ?? true,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||
return { success: true, id: data.id, slug: data.slug };
|
||||
}
|
||||
|
||||
// ── Create (batch) ───────────────────────────────────────────────────────────
|
||||
export async function createLocationsBatch(
|
||||
brandId: string,
|
||||
locations: LocationInput[]
|
||||
): Promise<{ success: boolean; created: number; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) {
|
||||
return { success: false, created: 0, error: "Not authorized" };
|
||||
}
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, created: 0, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_create_locations_batch`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||
}
|
||||
|
||||
const inserted = await res.json();
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||
return {
|
||||
success: true,
|
||||
created: Array.isArray(inserted) ? inserted.length : locations.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Update (partial) ─────────────────────────────────────────────────────────
|
||||
export async function updateLocation(
|
||||
locationId: string,
|
||||
brandId: string,
|
||||
updates: Partial<LocationInput>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_update_location`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Update failed" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
|
||||
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Delete (soft) ────────────────────────────────────────────────────────────
|
||||
export async function deleteLocation(
|
||||
locationId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_delete_location`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Delete failed" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Delete failed" };
|
||||
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Read (admin, by brand_id) ────────────────────────────────────────────────
|
||||
export async function adminListLocations(
|
||||
brandId: string
|
||||
): Promise<LocationWithCount[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Application-layer brand scoping: platform_admin (brand_id: null) gets all
|
||||
// brands; everyone else gets only their own brand.
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
const effectiveBrandId = activeBrandId;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_list_locations`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||
next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? (data as LocationWithCount[]) : [];
|
||||
}
|
||||
|
||||
// ── Read (public, by brand slug) ─────────────────────────────────────────────
|
||||
export type PublicLocation = Pick<
|
||||
Location,
|
||||
"id" | "name" | "address" | "city" | "state" | "zip" | "phone" | "slug"
|
||||
> & { stop_count: number };
|
||||
|
||||
export async function getPublicLocationsForBrand(
|
||||
brandSlug: string
|
||||
): Promise<PublicLocation[]> {
|
||||
if (!brandSlug) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_locations_for_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||
next: { revalidate: 300, tags: ["locations", `brand:${brandSlug}:locations`] },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? (data as PublicLocation[]) : [];
|
||||
}
|
||||
|
||||
// ── Attach a stop to a location ──────────────────────────────────────────────
|
||||
export async function attachStopToLocation(
|
||||
stopId: string,
|
||||
locationId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_location_id: locationId,
|
||||
p_brand_id: brandId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Attach failed" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Attach failed" };
|
||||
|
||||
revalidateTag("stops", "default");
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${brandId}:stops`, "default");
|
||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
@@ -41,7 +42,11 @@ export async function createAdminOrder(
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type PaymentProvider = "stripe" | "square" | "manual";
|
||||
@@ -65,10 +66,9 @@ export async function savePaymentSettings(params: {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
adminUser.brand_id !== params.brandId
|
||||
) {
|
||||
try {
|
||||
assertBrandAccess(adminUser, params.brandId);
|
||||
} catch {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function deleteProduct(
|
||||
@@ -16,7 +17,11 @@ export async function deleteProduct(
|
||||
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 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}`,
|
||||
{
|
||||
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,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
@@ -153,7 +154,14 @@ export interface FieldYieldSummary {
|
||||
export async function getRouteTraceLots(brandId: string, status?: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
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" };
|
||||
|
||||
try {
|
||||
@@ -235,7 +243,14 @@ export async function createHarvestLot(
|
||||
) {
|
||||
const adminUser = await getAdminUser();
|
||||
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" };
|
||||
|
||||
try {
|
||||
@@ -298,7 +313,14 @@ export async function updateHarvestLotStatus(
|
||||
export async function getRouteTraceStats(brandId: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
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" };
|
||||
|
||||
try {
|
||||
@@ -318,7 +340,14 @@ export async function getRouteTraceStats(brandId: string) {
|
||||
export async function searchHarvestLots(brandId: string, query: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const 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" };
|
||||
|
||||
try {
|
||||
@@ -347,7 +376,14 @@ export async function getTraceChain(lotId: string) {
|
||||
export async function getHarvestLotsReadyToHaul(brandId: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
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" };
|
||||
|
||||
try {
|
||||
@@ -364,7 +400,14 @@ export async function getHarvestLotsReadyToHaul(brandId: string) {
|
||||
export async function getFieldYieldSummary(brandId: string) {
|
||||
const adminUser = await getAdminUser();
|
||||
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" };
|
||||
|
||||
try {
|
||||
@@ -405,7 +448,14 @@ export interface InventoryByCrop {
|
||||
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const 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" };
|
||||
|
||||
try {
|
||||
@@ -451,7 +501,14 @@ export async function getRecentLotEvents(
|
||||
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
const 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" };
|
||||
|
||||
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";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type UpdateShippingStatusResult =
|
||||
@@ -28,7 +29,7 @@ export async function updateShippingStatus(
|
||||
p_order_id: orderId,
|
||||
p_shipping_status: status,
|
||||
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",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: await getActiveBrandId(adminUser),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { FedExServiceType } from "./fedex-rates";
|
||||
|
||||
@@ -156,6 +157,10 @@ export async function createFedExShipment(
|
||||
const order = orders[0];
|
||||
if (!order) return { success: false, error: "Order not found" };
|
||||
|
||||
// The order's brand is the source of truth. Validate the admin has access.
|
||||
if (order.brand_id) {
|
||||
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||
|
||||
// Get FedEx settings
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -177,6 +178,10 @@ export async function getFedExRates(
|
||||
const order = orders[0];
|
||||
if (!order) return { success: false, error: "Order not found" };
|
||||
|
||||
// The order's brand is the source of truth. Validate the admin has access.
|
||||
if (order.brand_id) {
|
||||
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
|
||||
}
|
||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||
|
||||
// Fetch shipping settings for this brand
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type SyncLogEntry = {
|
||||
@@ -29,7 +30,9 @@ export async function syncSquareNow(
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
|
||||
if (!adminUser.can_manage_orders) return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
try {
|
||||
assertBrandAccess(adminUser, brandId);
|
||||
} catch {
|
||||
return { success: false, synced: 0, errors: ["Not authorized"] };
|
||||
}
|
||||
|
||||
@@ -61,7 +64,9 @@ export async function getSyncLog(brandId: string): Promise<{
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, logs: [] };
|
||||
if (!adminUser.can_manage_orders) return { success: false, logs: [] };
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
try {
|
||||
assertBrandAccess(adminUser, brandId);
|
||||
} catch {
|
||||
return { success: false, logs: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type StopImportRow = {
|
||||
@@ -25,7 +26,11 @@ export async function createStopsBatch(
|
||||
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) {
|
||||
return { success: false, created: 0, error: "No brand selected" };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
type Irrigator = {
|
||||
@@ -94,7 +95,7 @@ export async function updateWaterHeadgate(
|
||||
p_name: name,
|
||||
p_active: active,
|
||||
p_unit: unit ?? null,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||
p_high_threshold: highThreshold ?? null,
|
||||
p_low_threshold: lowThreshold ?? null,
|
||||
}),
|
||||
@@ -186,7 +187,7 @@ export async function updateWaterIrrigator(
|
||||
p_active: active,
|
||||
p_lang: lang,
|
||||
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" },
|
||||
body: JSON.stringify({
|
||||
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" },
|
||||
body: JSON.stringify({
|
||||
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" },
|
||||
body: JSON.stringify({
|
||||
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",
|
||||
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_notes: notes,
|
||||
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" },
|
||||
body: JSON.stringify({
|
||||
p_entry_id: entryId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function registerWholesaleCustomer(params: {
|
||||
@@ -81,7 +82,9 @@ export async function approveWholesaleRegistration(
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
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" };
|
||||
}
|
||||
|
||||
|
||||
+33
-27
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type WholesaleOrder = {
|
||||
@@ -129,15 +130,16 @@ export type WholesaleDashboardStats = {
|
||||
* platform_admin → null (means "all brands" — passes to RPC unchanged)
|
||||
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
|
||||
* store_employee → their own brand_id
|
||||
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
|
||||
* unauthenticated → null (actions should already bail out earlier)
|
||||
*
|
||||
* This prevents brand_admin from seeing or modifying another brand's data
|
||||
* even if they manually pass a different brandId to the action.
|
||||
*/
|
||||
function resolveBrandId(
|
||||
async function resolveBrandId(
|
||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||
requestedBrandId?: string
|
||||
): string | null {
|
||||
): Promise<string | null> {
|
||||
if (!adminUser) return null;
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
@@ -145,15 +147,15 @@ function resolveBrandId(
|
||||
return null;
|
||||
}
|
||||
|
||||
// brand_admin and store_employee are scoped to their own brand
|
||||
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) {
|
||||
// Brand admin trying to operate on another brand's data — block it
|
||||
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.
|
||||
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
|
||||
*/
|
||||
function enforceBrandScope(
|
||||
async function enforceBrandScope(
|
||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||
requestedBrandId?: string
|
||||
): { brandId: string | null; error?: string } {
|
||||
): Promise<{ brandId: string | null; error?: string }> {
|
||||
if (!adminUser) return { brandId: null, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
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: userBrand };
|
||||
return { brandId: activeBrandId };
|
||||
}
|
||||
|
||||
// ── Orders ──────────────────────────────────────────────────────────────────
|
||||
@@ -185,7 +188,7 @@ function enforceBrandScope(
|
||||
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = resolveBrandId(adminUser, brandId);
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
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[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = resolveBrandId(adminUser, brandId);
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
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.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 };
|
||||
|
||||
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.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 };
|
||||
|
||||
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.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 };
|
||||
|
||||
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.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 };
|
||||
|
||||
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.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 };
|
||||
|
||||
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[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = resolveBrandId(adminUser, brandId);
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
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.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 };
|
||||
|
||||
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[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = resolveBrandId(adminUser, brandId);
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
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.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 };
|
||||
|
||||
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> {
|
||||
const adminUser = await getAdminUser();
|
||||
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 supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
@@ -700,7 +706,7 @@ export async function recordWholesaleDeposit(
|
||||
p_method: method,
|
||||
p_reference: reference ?? null,
|
||||
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" };
|
||||
|
||||
// Fire webhook — fire-and-forget
|
||||
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, adminUser.brand_id ?? undefined).catch(() => {});
|
||||
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, (await getActiveBrandId(adminUser)) ?? undefined).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -753,7 +759,7 @@ export async function bulkFulfillWholesaleOrders(
|
||||
body: JSON.stringify({
|
||||
p_order_ids: orderIds,
|
||||
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_reference: reference ?? null,
|
||||
p_recorded_by: adminUser.user_id,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_brand_id: await getActiveBrandId(adminUser),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import AdminSidebar from "@/components/admin/AdminSidebar";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { listBrandsForAdmin } from "@/actions/brands";
|
||||
import { redirect } from "next/navigation";
|
||||
import "@/styles/admin-design-system.css";
|
||||
import { ToastProvider } from "@/components/admin/Toast";
|
||||
@@ -57,12 +59,25 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
redirect("/change-password");
|
||||
}
|
||||
|
||||
// Resolve the active brand (URL > cookie > legacy > first of brand_ids)
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
|
||||
// Fetch accessible brands for the sidebar BrandSelector. We do this
|
||||
// unconditionally — `listBrandsForAdmin` is cheap and the sidebar
|
||||
// decides whether to show the dropdown.
|
||||
const brands = await listBrandsForAdmin();
|
||||
|
||||
return (
|
||||
<ToastProviderWrapper>
|
||||
<AdminSidebar userRole={adminUser.role} />
|
||||
<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)" }}>
|
||||
{children}
|
||||
</div>
|
||||
</ToastProviderWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { getAdminOrders } from "@/actions/orders";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
@@ -17,13 +18,19 @@ export default async function AdminOrdersPage() {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
// Platform admin can browse all brands' orders; everyone else must have a brand
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return <AdminAccessDenied message="You don't have access to any brand." />;
|
||||
}
|
||||
|
||||
const { orders, stops } = await getAdminOrders();
|
||||
|
||||
const brandStops = adminUser?.brand_id
|
||||
? stops.filter((s) => s.brand_id === adminUser.brand_id)
|
||||
const brandStops = activeBrandId
|
||||
? stops.filter((s) => s.brand_id === activeBrandId)
|
||||
: stops;
|
||||
|
||||
const brandOrders = adminUser?.brand_id
|
||||
const brandOrders = activeBrandId
|
||||
? orders.filter(
|
||||
(o) =>
|
||||
o.stops && brandStops.some((s) => s.id === o.stop_id)
|
||||
@@ -41,8 +48,8 @@ export default async function AdminOrdersPage() {
|
||||
.order("name")
|
||||
.limit(200);
|
||||
|
||||
if (adminUser?.brand_id) {
|
||||
prodQuery = prodQuery.eq("brand_id", adminUser.brand_id);
|
||||
if (activeBrandId) {
|
||||
prodQuery = prodQuery.eq("brand_id", activeBrandId);
|
||||
}
|
||||
|
||||
const { data: prods } = await prodQuery;
|
||||
@@ -80,7 +87,7 @@ export default async function AdminOrdersPage() {
|
||||
initialOrders={brandOrders}
|
||||
initialStops={brandStops}
|
||||
initialProducts={brandProducts}
|
||||
brandId={adminUser?.brand_id ?? null}
|
||||
brandId={activeBrandId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||
import DashboardClient from "@/components/admin/DashboardClient";
|
||||
@@ -23,12 +24,11 @@ export default async function AdminPage() {
|
||||
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
|
||||
let brandDisplayName = "Admin";
|
||||
|
||||
// For platform_admin in dev mode, adminUser.brand_id is null. To keep
|
||||
// the dashboard's "Active Products" stat in sync with the billing page,
|
||||
// we need to pick a brand and use the same getBillingOverview action
|
||||
// the billing page does. Otherwise the dashboard falls back to default
|
||||
// (0/0/0) usage values and contradicts the billing page's "Products 1/25".
|
||||
let dashboardBrandId: string | null = adminUser?.brand_id ?? null;
|
||||
// Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
|
||||
// > first of brand_ids). For platform_admin in dev mode this is null, so we
|
||||
// fall back to the first brand in the brands table to keep the dashboard's
|
||||
// "Active Products" stat in sync with the billing page.
|
||||
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
|
||||
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
||||
const { data: firstBrand } = await supabase
|
||||
.from("brands")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import ProductsClient from "@/components/admin/ProductsClient";
|
||||
import { getBrands } from "@/actions/admin/users";
|
||||
|
||||
// Icon for page header
|
||||
const PackageIcon = () => (
|
||||
@@ -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
|
||||
.from("products")
|
||||
@@ -48,8 +59,8 @@ export default async function AdminProductsPage() {
|
||||
.is("deleted_at", null)
|
||||
.order("name");
|
||||
|
||||
if (adminUser.brand_id) {
|
||||
query = query.eq("brand_id", adminUser.brand_id);
|
||||
if (brandId) {
|
||||
query = query.eq("brand_id", brandId);
|
||||
}
|
||||
|
||||
const { data: products, error } = await query;
|
||||
@@ -69,7 +80,12 @@ export default async function AdminProductsPage() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import ReportsDashboard from "@/components/admin/ReportsDashboard";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
@@ -19,7 +20,7 @@ export default async function ReportsPage() {
|
||||
|
||||
const initialBrandId = isPlatformAdmin
|
||||
? null
|
||||
: adminUser.brand_id ?? null;
|
||||
: await getActiveBrandId(adminUser);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import BillingClientPage from "./BillingClientPage";
|
||||
@@ -13,7 +14,11 @@ export default async function BillingPage({ params }: Props) {
|
||||
const adminUser = await getAdminUser();
|
||||
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";
|
||||
|
||||
let resolvedBrandId = effectiveBrandId;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import StopTableClient from "@/components/admin/StopTableClient";
|
||||
import StopsDashboardClient from "@/components/admin/stops/StopsDashboardClient";
|
||||
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
@@ -90,9 +90,7 @@ export default async function AdminStopsPage() {
|
||||
|
||||
{/* Content */}
|
||||
<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">
|
||||
<StopTableClient stops={stops ?? []} />
|
||||
</div>
|
||||
<StopsDashboardClient stops={stops ?? []} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import TaxDashboard from "@/components/admin/TaxDashboard";
|
||||
@@ -15,7 +16,11 @@ export default async function TaxesPage({ params }: Props) {
|
||||
const adminUser = await getAdminUser();
|
||||
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";
|
||||
|
||||
let resolvedBrandId = effectiveBrandId;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import WholesaleClient from "./WholesaleClient";
|
||||
|
||||
export default async function WholesalePage() {
|
||||
@@ -11,10 +13,10 @@ export default async function WholesalePage() {
|
||||
devSession === "store_employee";
|
||||
|
||||
if (!isDevMode) {
|
||||
const { getAdminUser } = await import("@/lib/admin-permissions");
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
+230
-162
@@ -4,25 +4,36 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import type { StopInfo } from "@/context/CartContext";
|
||||
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
location: string;
|
||||
brand_id: string;
|
||||
};
|
||||
import StripeExpressCheckout, {
|
||||
type CheckoutItem as ExpressItem,
|
||||
} from "@/components/storefront/StripeExpressCheckout";
|
||||
|
||||
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 [stops, setStops] = useState<Stop[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stops, setStops] = useState<StopInfo[]>([]);
|
||||
|
||||
// Controlled form state — passed down to StripeExpressCheckout so the
|
||||
// Express + Card paths know who the buyer is and where to ship / pick up.
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [customerEmail, setCustomerEmail] = useState("");
|
||||
const [customerPhone, setCustomerPhone] = useState("");
|
||||
const [shippingState, setShippingState] = useState("");
|
||||
const [shippingPostal, setShippingPostal] = useState("");
|
||||
const [shippingCity, setShippingCity] = useState("");
|
||||
|
||||
// Hosted-checkout (legacy Stripe Checkout) fallback state
|
||||
const [hostedLoading, setHostedLoading] = useState(false);
|
||||
const [hostedError, setHostedError] = useState<string | null>(null);
|
||||
|
||||
// Stable idempotency key per checkout session — survives retries
|
||||
const idempotencyKeyRef = useRef<string | null>(null);
|
||||
@@ -33,7 +44,7 @@ export default function CheckoutClient() {
|
||||
useEffect(() => {
|
||||
if (!cartBrandId) return;
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,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: {
|
||||
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 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 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;
|
||||
|
||||
// Guard: if selected stop brand mismatches cart brand, block checkout
|
||||
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
|
||||
setSelectedStop(null);
|
||||
router.replace("/cart");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const customerName = formData.get("customer_name") as string;
|
||||
const customerEmail = formData.get("customer_email") as string;
|
||||
const customerPhone = formData.get("customer_phone") as string;
|
||||
const stopId = (selectedStop?.id ?? formData.get("stop_id") as string | null) as string | null;
|
||||
|
||||
// Shipping address for tax calculation (ship items only)
|
||||
let shippingAddress;
|
||||
if (hasShipItems) {
|
||||
const shippingPostal = formData.get("shipping_postal_code") as string;
|
||||
const shippingState = formData.get("shipping_state") as string;
|
||||
const shippingCity = formData.get("shipping_city") as string;
|
||||
if (shippingState) {
|
||||
shippingAddress = { state: shippingState, postal_code: shippingPostal, city: shippingCity };
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStopPickupItems && !stopId) {
|
||||
setError("Please select a pickup location.");
|
||||
setLoading(false);
|
||||
if (!customerEmail.trim()) {
|
||||
setHostedError("Please enter your email before continuing to checkout.");
|
||||
return;
|
||||
}
|
||||
if (hasStopPickupItems && !selectedStop) {
|
||||
setHostedError("Please select a pickup location.");
|
||||
return;
|
||||
}
|
||||
|
||||
setHostedLoading(true);
|
||||
setHostedError(null);
|
||||
|
||||
const items = cart.map((item) => ({
|
||||
id: item.id,
|
||||
@@ -95,36 +98,61 @@ export default function CheckoutClient() {
|
||||
fulfillment: item.fulfillment ?? "pickup",
|
||||
}));
|
||||
|
||||
// Build return URLs for Stripe
|
||||
const siteUrl = typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
|
||||
const siteUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
|
||||
const successUrl = `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
|
||||
const cancelUrl = `${siteUrl}/checkout?status=cancelled`;
|
||||
|
||||
// Store customer info for order creation on success page
|
||||
if (typeof sessionStorage !== "undefined") {
|
||||
sessionStorage.setItem("pending_checkout", JSON.stringify({
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
stopId,
|
||||
items: items.map((item) => ({ ...item, fulfillment: item.fulfillment as "pickup" | "ship" })),
|
||||
cartBrandId,
|
||||
shippingAddress,
|
||||
idempotencyKey: idempotencyKeyRef.current,
|
||||
}));
|
||||
sessionStorage.setItem(
|
||||
"pending_checkout",
|
||||
JSON.stringify({
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
stopId: selectedStop?.id ?? null,
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
fulfillment: item.fulfillment as "pickup" | "ship",
|
||||
})),
|
||||
cartBrandId,
|
||||
shippingAddress: hasShipItems
|
||||
? { state: shippingState, postal_code: shippingPostal, city: shippingCity }
|
||||
: undefined,
|
||||
idempotencyKey: idempotencyKeyRef.current,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Create Stripe Checkout session first
|
||||
const stripeResult = await createRetailStripeCheckoutSession(items, "", cartBrandId ?? "unknown", successUrl, cancelUrl);
|
||||
const stripeResult = await createRetailStripeCheckoutSession(
|
||||
items,
|
||||
"",
|
||||
cartBrandId ?? "unknown",
|
||||
successUrl,
|
||||
cancelUrl
|
||||
);
|
||||
if (!stripeResult.success || !stripeResult.url) {
|
||||
setError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
|
||||
setLoading(false);
|
||||
setHostedError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
|
||||
setHostedLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to Stripe Checkout
|
||||
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) {
|
||||
return (
|
||||
@@ -132,9 +160,7 @@ export default function CheckoutClient() {
|
||||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||||
<main className="px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-stone-900">
|
||||
Your cart is empty
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold text-stone-900">Your cart is empty</h1>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
|
||||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||||
<main className="px-6 py-12">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
|
||||
{/* LEFT — Form + Embedded Stripe Elements */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-slate-900">
|
||||
Checkout
|
||||
</h1>
|
||||
|
||||
<h1 className="text-4xl font-bold text-slate-900">Checkout</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Complete your order details.
|
||||
Tap to pay with Apple Pay, Google Pay, or card. Your details stay secure — we never see them.
|
||||
</p>
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
{/* Error alert (shared across both payment paths) */}
|
||||
{(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="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 text-red-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
<span>{hostedError}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<div className="mt-6 flex items-center gap-3 rounded-xl bg-emerald-50 border border-emerald-200 p-4" role="status" aria-live="polite">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-300 border-t-emerald-600" aria-hidden="true" />
|
||||
<span className="text-sm text-emerald-700">Placing your order...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{/* Customer Information */}
|
||||
<div className="mt-8 space-y-6">
|
||||
{/* Customer Information — controlled inputs feed the embedded
|
||||
Stripe Elements above. The wallet (Apple Pay) will overwrite
|
||||
name/email at confirm time, but we still collect them here
|
||||
so the order row has them in case the wallet omits them. */}
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
||||
<legend className="text-xl font-bold text-slate-900">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>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<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>
|
||||
<input
|
||||
id="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"
|
||||
placeholder="Jane Smith"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
|
||||
Email <span className="text-slate-400 text-xs">(optional)</span>
|
||||
Email <span className="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="customer_email"
|
||||
name="customer_email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={customerEmail}
|
||||
onChange={(e) => setCustomerEmail(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="jane@example.com"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -224,6 +260,8 @@ export default function CheckoutClient() {
|
||||
name="customer_phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
value={customerPhone}
|
||||
onChange={(e) => setCustomerPhone(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="(555) 555-5555"
|
||||
/>
|
||||
@@ -231,7 +269,7 @@ export default function CheckoutClient() {
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Shed Pickup */}
|
||||
{/* Shed Pickup notice */}
|
||||
{hasShedPickupItems && (
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
|
||||
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
|
||||
@@ -278,6 +316,11 @@ export default function CheckoutClient() {
|
||||
id="stop_id"
|
||||
name="stop_id"
|
||||
required
|
||||
value={(selectedStop as StopInfo | null)?.id ?? ""}
|
||||
onChange={(e) => {
|
||||
const found = stops.find((s) => s.id === e.target.value) ?? null;
|
||||
setSelectedStop(found as StopInfo | null);
|
||||
}}
|
||||
className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all appearance-none bg-white"
|
||||
aria-required="true"
|
||||
>
|
||||
@@ -301,8 +344,8 @@ export default function CheckoutClient() {
|
||||
Enter your shipping details for delivery.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
|
||||
Street Address
|
||||
</label>
|
||||
@@ -314,80 +357,97 @@ export default function CheckoutClient() {
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
id="shipping_city"
|
||||
name="shipping_city"
|
||||
autoComplete="address-level2"
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
|
||||
State
|
||||
</label>
|
||||
<input
|
||||
id="shipping_state"
|
||||
name="shipping_state"
|
||||
autoComplete="address-level1"
|
||||
maxLength={2}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase"
|
||||
placeholder="NC"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
|
||||
ZIP Code
|
||||
</label>
|
||||
<input
|
||||
id="shipping_postal_code"
|
||||
name="shipping_postal_code"
|
||||
autoComplete="postal-code"
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="28147"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
id="shipping_city"
|
||||
name="shipping_city"
|
||||
autoComplete="address-level2"
|
||||
value={shippingCity}
|
||||
onChange={(e) => setShippingCity(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
|
||||
State
|
||||
</label>
|
||||
<input
|
||||
id="shipping_state"
|
||||
name="shipping_state"
|
||||
autoComplete="address-level1"
|
||||
maxLength={2}
|
||||
value={shippingState}
|
||||
onChange={(e) => setShippingState(e.target.value.toUpperCase())}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase"
|
||||
placeholder="NC"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
|
||||
ZIP Code
|
||||
</label>
|
||||
<input
|
||||
id="shipping_postal_code"
|
||||
name="shipping_postal_code"
|
||||
autoComplete="postal-code"
|
||||
value={shippingPostal}
|
||||
onChange={(e) => setShippingPostal(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
placeholder="28147"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 px-6 py-4 text-lg font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/30 hover:-translate-y-0.5 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Redirecting to payment...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Pay Now — ${subtotal.toFixed(2)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{/* Embedded Stripe Elements (Express + Card) — real checkout */}
|
||||
<div data-testid="stripe-express-checkout">
|
||||
<StripeExpressCheckout
|
||||
items={expressItems}
|
||||
brandId={cartBrandId}
|
||||
customerName={customerName}
|
||||
customerEmail={customerEmail}
|
||||
customerPhone={customerPhone}
|
||||
selectedStop={selectedStop as StopInfo | null}
|
||||
shippingState={shippingState}
|
||||
shippingPostal={shippingPostal}
|
||||
shippingCity={shippingCity}
|
||||
checkoutSessionKey={idempotencyKeyRef.current ?? undefined}
|
||||
onHostedCheckout={() => void handleHostedCheckout()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hosted-checkout fallback (legacy Stripe Checkout) */}
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-4">
|
||||
<p className="text-xs text-slate-500 mb-2 text-center">
|
||||
Prefer Stripe’s hosted page? Tap below.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleHostedCheckout()}
|
||||
disabled={hostedLoading}
|
||||
className="w-full rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-800 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{hostedLoading ? (
|
||||
<>
|
||||
<span className="h-4 w-4 rounded-full border-2 border-slate-300 border-t-slate-700 animate-spin" />
|
||||
Redirecting to Stripe…
|
||||
</>
|
||||
) : (
|
||||
<>Use secure hosted checkout →</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<aside aria-label="Order summary">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100 sticky top-6">
|
||||
<h2 className="text-xl font-bold text-slate-900">
|
||||
Order Summary
|
||||
</h2>
|
||||
<h2 className="text-xl font-bold text-slate-900">Order Summary</h2>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{cart.map((item) => (
|
||||
@@ -420,12 +480,20 @@ export default function CheckoutClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secure payment badge */}
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Secured by Stripe</span>
|
||||
{/* Trust badges */}
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Secured by Stripe</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span>100% Sweetness Guarantee</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -435,4 +503,4 @@ export default function CheckoutClient() {
|
||||
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +47,16 @@ function formatStopDate(dateStr: string): string {
|
||||
function SuccessContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams.get("session_id");
|
||||
// Embedded Stripe Elements path — we navigated here programmatically
|
||||
// with ?payment_intent=pi_... after `stripe.confirmPayment` resolved.
|
||||
const paymentIntentId = searchParams.get("payment_intent");
|
||||
const redirectStatus = searchParams.get("redirect_status");
|
||||
const orderIdParam = searchParams.get("order_id");
|
||||
// Direct access with order_id — load from sessionStorage in lazy initializer.
|
||||
// searchParams values are stable, and sessionStorage is client-only, so this
|
||||
// is safe in a client component.
|
||||
const [order, setOrder] = useState<StoredOrder | null>(() => {
|
||||
if (!orderIdParam || sessionId) return null;
|
||||
if (!orderIdParam || sessionId || paymentIntentId) return null;
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(`order_${orderIdParam}`);
|
||||
@@ -62,14 +66,20 @@ function SuccessContent() {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const [creating, setCreating] = useState(!!sessionId);
|
||||
const [creating, setCreating] = useState(Boolean(sessionId || (paymentIntentId && redirectStatus === "succeeded")));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Stripe redirected back — create order from pending checkout data.
|
||||
// Wrapped in an async IIFE so all setState calls happen inside a callback,
|
||||
// not in the synchronous effect body (satisfies set-state-in-effect rule).
|
||||
// Create the order from the pending-checkout payload, regardless of
|
||||
// whether the user paid via hosted Stripe Checkout (?session_id=...) or
|
||||
// embedded Stripe Elements (?payment_intent=...). Both paths store the
|
||||
// same payload in `pending_checkout` before payment is initiated.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (!sessionId && !paymentIntentId) return;
|
||||
if (paymentIntentId && redirectStatus && redirectStatus !== "succeeded") {
|
||||
setError("Payment was not completed. Please try again.");
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const pendingStr = sessionStorage.getItem("pending_checkout");
|
||||
@@ -88,6 +98,7 @@ function SuccessContent() {
|
||||
cartBrandId?: string;
|
||||
shippingAddress?: { state: string; postal_code: string; city?: string };
|
||||
idempotencyKey: string;
|
||||
paymentIntentId?: string;
|
||||
};
|
||||
try {
|
||||
pending = JSON.parse(pendingStr);
|
||||
@@ -123,7 +134,7 @@ function SuccessContent() {
|
||||
setCreating(false);
|
||||
}
|
||||
})();
|
||||
}, [sessionId]);
|
||||
}, [sessionId, paymentIntentId, redirectStatus]);
|
||||
|
||||
if (!order || !orderIdParam) {
|
||||
return (
|
||||
|
||||
+590
-2
@@ -1,6 +1,11 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Custom typefaces — Field Almanac */
|
||||
--font-display: var(--font-fraunces, "Georgia"), Georgia, serif;
|
||||
--font-sans: var(--font-manrope, system-ui), system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: var(--font-fragment-mono, "JetBrains Mono"), ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
/* Apple-inspired dark palette — sophisticated depth */
|
||||
--color-surface-50: #f5f5f7;
|
||||
--color-surface-100: #e8e8ed;
|
||||
@@ -85,7 +90,7 @@ html {
|
||||
}
|
||||
|
||||
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-attachment: fixed;
|
||||
color: #1a1a1a;
|
||||
@@ -441,4 +446,587 @@ select:-webkit-autofill:focus {
|
||||
|
||||
.transition-glass {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Editorial Modal — "Atelier des Récoltes" ───────────────── */
|
||||
|
||||
/* Warm cream canvas for the modal — like aged paper */
|
||||
.atelier-canvas {
|
||||
background-color: #FAF7F0;
|
||||
background-image:
|
||||
radial-gradient(ellipse 70% 50% at 15% 0%, rgba(180, 155, 100, 0.10) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 60% 45% at 100% 100%, rgba(34, 78, 47, 0.06) 0%, transparent 55%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Subtle grain texture for tactile feel */
|
||||
.atelier-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.35;
|
||||
mix-blend-mode: multiply;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.18 0 0 0 0 0.15 0 0 0 0 0.10 0 0 0 0.045 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Botanical wreath / corner ornament */
|
||||
.atelier-flourish {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='80' height='80' viewBox='0 0 80 80' xmlns='http://www.w3.org/2000/svg' fill='none' stroke='%23224E2F' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 40 Q 25 20 40 30 Q 55 40 70 30'/%3E%3Cpath d='M22 32 Q 25 28 28 30'/%3E%3Cpath d='M52 38 Q 55 34 58 36'/%3E%3Cpath d='M14 45 Q 18 50 24 48'/%3E%3Ccircle cx='40' cy='30' r='2' fill='%23224E2F'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* The big editorial numeral — large, italic, gold */
|
||||
.atelier-numeral {
|
||||
font-family: var(--font-fraunces);
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-variation-settings: "opsz" 144, "SOFT" 30;
|
||||
line-height: 0.85;
|
||||
letter-spacing: -0.04em;
|
||||
background: linear-gradient(135deg, #CA8A04 0%, #854D0E 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Editorial title — Fraunces with optical sizing */
|
||||
.atelier-title {
|
||||
font-family: var(--font-fraunces);
|
||||
font-weight: 500;
|
||||
font-variation-settings: "opsz" 144, "SOFT" 50;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 0.95;
|
||||
color: #1C1917;
|
||||
}
|
||||
|
||||
.atelier-italic {
|
||||
font-family: var(--font-fraunces);
|
||||
font-style: italic;
|
||||
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||
color: #786B53;
|
||||
}
|
||||
|
||||
/* Section label — monospace, small caps style */
|
||||
.atelier-section-label {
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #786B53;
|
||||
}
|
||||
|
||||
/* Section number badge */
|
||||
.atelier-section-num {
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
color: #CA8A04;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.atelier-section-num::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, #CA8A04, transparent);
|
||||
}
|
||||
|
||||
/* Editorial hairline rule with fade at edges */
|
||||
.atelier-rule {
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, rgba(180, 155, 100, 0.4) 20%, rgba(34, 78, 47, 0.3) 50%, rgba(180, 155, 100, 0.4) 80%, transparent);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Input fields — bottom border only, editorial */
|
||||
.atelier-input {
|
||||
font-family: var(--font-manrope);
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: #1C1917;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1.5px solid rgba(28, 25, 23, 0.12);
|
||||
border-radius: 0;
|
||||
padding: 10px 0 10px 0;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color 220ms cubic-bezier(0.4, 0, 0.2, 1), padding 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.atelier-input::placeholder {
|
||||
color: #A8A29E;
|
||||
font-style: italic;
|
||||
font-family: var(--font-fraunces);
|
||||
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||
}
|
||||
.atelier-input:hover {
|
||||
border-bottom-color: rgba(28, 25, 23, 0.28);
|
||||
}
|
||||
.atelier-input:focus {
|
||||
border-bottom-color: #224E2F;
|
||||
border-bottom-width: 2px;
|
||||
padding-bottom: 9.5px;
|
||||
}
|
||||
.atelier-input.is-error {
|
||||
border-bottom-color: #B91C1C;
|
||||
}
|
||||
|
||||
/* Large display input — name field */
|
||||
.atelier-input--display {
|
||||
font-family: var(--font-fraunces);
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
font-variation-settings: "opsz" 144, "SOFT" 50;
|
||||
letter-spacing: -0.02em;
|
||||
padding: 8px 0 14px 0;
|
||||
}
|
||||
.atelier-input--display::placeholder {
|
||||
color: #D6D3D1;
|
||||
font-style: italic;
|
||||
font-variation-settings: "opsz" 144, "SOFT" 100;
|
||||
}
|
||||
.atelier-input--display:focus {
|
||||
border-bottom-width: 2.5px;
|
||||
padding-bottom: 13px;
|
||||
}
|
||||
|
||||
/* Price input — large currency treatment */
|
||||
.atelier-price {
|
||||
font-family: var(--font-fraunces);
|
||||
font-variation-settings: "opsz" 144, "SOFT" 50;
|
||||
font-weight: 500;
|
||||
font-size: 38px;
|
||||
letter-spacing: -0.03em;
|
||||
color: #1C1917;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid #224E2F;
|
||||
border-radius: 0;
|
||||
padding: 4px 0 12px 36px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color 200ms ease;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.atelier-price::-webkit-outer-spin-button,
|
||||
.atelier-price::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.atelier-price:focus {
|
||||
border-bottom-color: #14532D;
|
||||
border-bottom-width: 2.5px;
|
||||
}
|
||||
.atelier-price-sigil {
|
||||
font-family: var(--font-fraunces);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 38px;
|
||||
color: #CA8A04;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
pointer-events: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Type selector — visual cards */
|
||||
.atelier-type-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 14px 12px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1.5px solid rgba(28, 25, 23, 0.10);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.atelier-type-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(34, 78, 47, 0.02) 0%, transparent 60%);
|
||||
opacity: 0;
|
||||
transition: opacity 220ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.atelier-type-card:hover {
|
||||
border-color: rgba(34, 78, 47, 0.25);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.atelier-type-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.atelier-type-card:active {
|
||||
transform: translateY(0) scale(0.985);
|
||||
}
|
||||
.atelier-type-card.is-selected {
|
||||
background: linear-gradient(135deg, #224E2F 0%, #14532D 100%);
|
||||
border-color: #14532D;
|
||||
box-shadow: 0 8px 24px -8px rgba(20, 83, 45, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
.atelier-type-card.is-selected::before {
|
||||
opacity: 0;
|
||||
}
|
||||
.atelier-type-card .atelier-type-icon {
|
||||
color: #786B53;
|
||||
transition: color 220ms ease, transform 220ms ease;
|
||||
}
|
||||
.atelier-type-card.is-selected .atelier-type-icon {
|
||||
color: #FCD34D;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.atelier-type-card .atelier-type-name {
|
||||
font-family: var(--font-fraunces);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-variation-settings: "opsz" 14, "SOFT" 30;
|
||||
letter-spacing: -0.01em;
|
||||
color: #1C1917;
|
||||
transition: color 220ms ease;
|
||||
}
|
||||
.atelier-type-card.is-selected .atelier-type-name {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.atelier-type-card .atelier-type-check {
|
||||
opacity: 0;
|
||||
color: #FCD34D;
|
||||
transform: scale(0.6);
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
}
|
||||
.atelier-type-card.is-selected .atelier-type-check {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Pill toggle — Active / Taxable */
|
||||
.atelier-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.atelier-toggle-track {
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
background: rgba(28, 25, 23, 0.12);
|
||||
border-radius: 999px;
|
||||
transition: background 220ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.atelier-toggle.is-on .atelier-toggle-track {
|
||||
background: linear-gradient(135deg, #224E2F 0%, #16A34A 100%);
|
||||
box-shadow: 0 2px 8px -2px rgba(34, 78, 47, 0.4);
|
||||
}
|
||||
.atelier-toggle.is-on.is-gold .atelier-toggle-track {
|
||||
background: linear-gradient(135deg, #CA8A04 0%, #EAB308 100%);
|
||||
box-shadow: 0 2px 8px -2px rgba(202, 138, 4, 0.4);
|
||||
}
|
||||
.atelier-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #FFFFFF;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.atelier-toggle.is-on .atelier-toggle-thumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
.atelier-toggle-label {
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #786B53;
|
||||
transition: color 220ms ease;
|
||||
}
|
||||
.atelier-toggle.is-on .atelier-toggle-label {
|
||||
color: #1C1917;
|
||||
}
|
||||
|
||||
/* Image drop zone — large square with botanical treatment */
|
||||
.atelier-drop {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1.5px dashed rgba(34, 78, 47, 0.25);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.atelier-drop::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 12px;
|
||||
border: 1px solid rgba(180, 155, 100, 0.18);
|
||||
border-radius: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.atelier-drop::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at center, rgba(34, 78, 47, 0.02) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.atelier-drop:hover {
|
||||
border-color: rgba(34, 78, 47, 0.45);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
transform: scale(1.005);
|
||||
}
|
||||
.atelier-drop.is-drag {
|
||||
border-color: #224E2F;
|
||||
border-style: solid;
|
||||
background: rgba(34, 78, 47, 0.04);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
.atelier-drop.is-error {
|
||||
border-color: #B91C1C;
|
||||
background: rgba(185, 28, 28, 0.03);
|
||||
}
|
||||
.atelier-drop.has-image {
|
||||
border-style: solid;
|
||||
border-color: rgba(34, 78, 47, 0.3);
|
||||
background: #FFFFFF;
|
||||
}
|
||||
.atelier-drop-eyebrow {
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: #A8A29E;
|
||||
margin-top: -4px;
|
||||
}
|
||||
.atelier-drop-title {
|
||||
font-family: var(--font-fraunces);
|
||||
font-style: italic;
|
||||
font-size: 18px;
|
||||
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||
color: #57534E;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.atelier-drop-hint {
|
||||
font-family: var(--font-manrope);
|
||||
font-size: 11px;
|
||||
color: #A8A29E;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Status pill in corner of image */
|
||||
.atelier-status-pill {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px 5px 8px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(28, 25, 23, 0.08);
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: #1C1917;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
z-index: 2;
|
||||
}
|
||||
.atelier-status-pill .atelier-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #16A34A;
|
||||
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
||||
}
|
||||
.atelier-status-pill.is-empty .atelier-dot {
|
||||
background: #D6D3D1;
|
||||
box-shadow: 0 0 0 2px rgba(214, 211, 209, 0.2);
|
||||
}
|
||||
.atelier-status-pill.is-empty {
|
||||
color: #A8A29E;
|
||||
}
|
||||
|
||||
/* Modal enter animation */
|
||||
@keyframes atelier-enter {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes atelier-backdrop {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes atelier-stagger {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.atelier-enter {
|
||||
animation: atelier-enter 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.atelier-backdrop {
|
||||
animation: atelier-backdrop 300ms ease both;
|
||||
}
|
||||
.atelier-stagger > * {
|
||||
animation: atelier-stagger 480ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.atelier-stagger > *:nth-child(1) { animation-delay: 80ms; }
|
||||
.atelier-stagger > *:nth-child(2) { animation-delay: 140ms; }
|
||||
.atelier-stagger > *:nth-child(3) { animation-delay: 200ms; }
|
||||
.atelier-stagger > *:nth-child(4) { animation-delay: 260ms; }
|
||||
.atelier-stagger > *:nth-child(5) { animation-delay: 320ms; }
|
||||
.atelier-stagger > *:nth-child(6) { animation-delay: 380ms; }
|
||||
.atelier-stagger > *:nth-child(7) { animation-delay: 440ms; }
|
||||
|
||||
/* Primary CTA — gradient with subtle inner highlight */
|
||||
.atelier-cta {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 12px 22px;
|
||||
font-family: var(--font-manrope);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
color: #FFFFFF;
|
||||
background: linear-gradient(135deg, #14532D 0%, #1F6B3E 50%, #14532D 100%);
|
||||
background-size: 200% 100%;
|
||||
background-position: 0% 50%;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 6px 18px -6px rgba(20, 83, 45, 0.55),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.10);
|
||||
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.atelier-cta:hover {
|
||||
background-position: 100% 50%;
|
||||
box-shadow:
|
||||
0 10px 28px -8px rgba(20, 83, 45, 0.65),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.20),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.10);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.atelier-cta:active {
|
||||
transform: translateY(0) scale(0.985);
|
||||
}
|
||||
.atelier-cta:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
background: #57534E;
|
||||
box-shadow: none;
|
||||
}
|
||||
.atelier-cta .atelier-cta-shimmer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background: linear-gradient(105deg, transparent 30%, rgba(255, 255, 255, 0.18) 50%, transparent 70%);
|
||||
pointer-events: none;
|
||||
transition: left 700ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.atelier-cta:hover .atelier-cta-shimmer {
|
||||
left: 130%;
|
||||
}
|
||||
|
||||
/* Discard button */
|
||||
.atelier-discard {
|
||||
font-family: var(--font-fraunces);
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||
color: #786B53;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 8px 6px;
|
||||
cursor: pointer;
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
.atelier-discard:hover {
|
||||
color: #1C1917;
|
||||
}
|
||||
|
||||
/* Brand selector */
|
||||
.atelier-select {
|
||||
font-family: var(--font-manrope);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #1C1917;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1.5px solid rgba(28, 25, 23, 0.12);
|
||||
border-radius: 0;
|
||||
padding: 10px 24px 10px 0;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23786B53' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right center;
|
||||
transition: border-color 220ms ease;
|
||||
}
|
||||
.atelier-select:hover { border-bottom-color: rgba(28, 25, 23, 0.28); }
|
||||
.atelier-select:focus { border-bottom-color: #224E2F; border-bottom-width: 2px; padding-bottom: 9.5px; }
|
||||
|
||||
/* Hint / help text */
|
||||
.atelier-hint {
|
||||
font-family: var(--font-fraunces);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
font-variation-settings: "opsz" 14, "SOFT" 100;
|
||||
color: #A8A29E;
|
||||
line-height: 1.4;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Error banner */
|
||||
.atelier-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(135deg, rgba(185, 28, 28, 0.06) 0%, rgba(185, 28, 28, 0.03) 100%);
|
||||
border: 1px solid rgba(185, 28, 28, 0.25);
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-manrope);
|
||||
font-size: 13px;
|
||||
color: #991B1B;
|
||||
}
|
||||
.atelier-error svg { flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
+23
-2
@@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Fraunces, Manrope, Fragment_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import ToastNotificationContainer from "@/components/notifications/ToastNotification";
|
||||
@@ -6,6 +7,26 @@ import CookieConsentBanner from "@/components/legal/CookieConsentBanner";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-fraunces",
|
||||
axes: ["SOFT", "WONK", "opsz"],
|
||||
});
|
||||
|
||||
const manrope = Manrope({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-manrope",
|
||||
});
|
||||
|
||||
const fragmentMono = Fragment_Mono({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-fragment-mono",
|
||||
weight: "400",
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
@@ -50,8 +71,8 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<html lang="en" suppressHydrationWarning className={`${fraunces.variable} ${manrope.variable} ${fragmentMono.variable}`}>
|
||||
<body className="font-sans">
|
||||
<Providers>{children}</Providers>
|
||||
<ToastNotificationContainer />
|
||||
<CookieConsentBanner />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin";
|
||||
|
||||
function LoginForm() {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -124,6 +125,82 @@ function LoginForm() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auth.js v5 — primary sign-in: Google OAuth */}
|
||||
<form action={signInWithGoogle} className="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full inline-flex items-center justify-center gap-3 rounded-xl border border-stone-200/80 bg-white px-6 py-3.5 text-sm font-semibold text-stone-900 shadow-sm transition-all hover:bg-stone-50 active:scale-[0.98]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||
aria-label="Sign in with Google"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0 0 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09a6.6 6.6 0 0 1 0-4.18V7.07H2.18a11 11 0 0 0 0 9.86l3.66-2.84z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A10.99 10.99 0 0 0 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Sign in with Google</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Dev login (only visible in development) */}
|
||||
{process.env.NODE_ENV !== "production" && (
|
||||
<form action={signInWithDev} className="space-y-3">
|
||||
<div className="rounded-xl bg-amber-50/70 border border-amber-200/60 px-3 py-2 text-xs text-amber-900">
|
||||
<strong>Dev login</strong> — only available in development.
|
||||
Set <code>ALLOW_DEV_LOGIN=false</code> in <code>.env.local</code> to hide.
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
defaultValue="admin"
|
||||
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
|
||||
placeholder="Username"
|
||||
aria-label="Dev username"
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
defaultValue="dev"
|
||||
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
|
||||
placeholder="Password"
|
||||
aria-label="Dev password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-xl bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||
>
|
||||
Dev sign in (no Google required)
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-stone-200/70" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase tracking-wider">
|
||||
<span className="bg-white/0 px-2 text-stone-400" style={{ background: "linear-gradient(to right, transparent, white 30%, white 70%, transparent)" }}>
|
||||
or sign in with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
|
||||
{globalError && (
|
||||
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { auth, signOut } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* /protected-example
|
||||
*
|
||||
* Smoke-test page that demonstrates the new Auth.js v5 pattern. Calling
|
||||
* `auth()` server-side returns the current session (null if not signed
|
||||
* in). The middleware in `../middleware.ts` already redirects
|
||||
* unauthenticated visitors to `/login`, so by the time this page renders
|
||||
* we always have a session.
|
||||
*
|
||||
* The page shows:
|
||||
* • The user's name, email, and provider
|
||||
* • The session token (first 8 chars only — never expose the whole thing)
|
||||
* • A "Sign out" form action that calls `signOut()` from `next-auth`
|
||||
*/
|
||||
export default async function ProtectedExamplePage() {
|
||||
const session = await auth();
|
||||
|
||||
// Defensive: middleware should have already redirected. Render a
|
||||
// friendly hint if we ever reach here unauthenticated.
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6">
|
||||
<div className="max-w-md rounded-2xl bg-white p-8 shadow ring-1 ring-stone-200">
|
||||
<h1 className="text-xl font-semibold text-stone-900">
|
||||
Not signed in
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
You should have been redirected to{" "}
|
||||
<a className="text-emerald-700 underline" href="/login">
|
||||
/login
|
||||
</a>
|
||||
. If you can see this, the middleware matcher needs adjusting.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const user = session.user;
|
||||
const expires = session.expires
|
||||
? new Date(session.expires).toLocaleString()
|
||||
: "(no expiry)";
|
||||
|
||||
// The raw session token isn't on the session object in v5 (only the
|
||||
// csrfToken is exposed client-side). We surface what we do have.
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6 py-12">
|
||||
<div className="w-full max-w-xl space-y-6">
|
||||
<header>
|
||||
<h1
|
||||
className="text-3xl font-semibold tracking-tight text-stone-900"
|
||||
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}
|
||||
>
|
||||
Protected example
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
You are signed in. This page is guarded by the Auth.js
|
||||
middleware in <code className="text-xs">middleware.ts</code>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-2xl bg-white p-6 shadow ring-1 ring-stone-200">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-stone-500">
|
||||
Session
|
||||
</h2>
|
||||
<dl className="mt-4 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-stone-500">Name</dt>
|
||||
<dd className="mt-1 font-medium text-stone-900">
|
||||
{user.name ?? "(none)"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-stone-500">Email</dt>
|
||||
<dd className="mt-1 font-medium text-stone-900 break-all">
|
||||
{user.email ?? "(none)"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-stone-500">User id</dt>
|
||||
<dd className="mt-1 font-mono text-xs text-stone-700 break-all">
|
||||
{(user as { id?: string }).id ?? "(none)"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-stone-500">Session expires</dt>
|
||||
<dd className="mt-1 font-medium text-stone-900">{expires}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-white p-6 shadow ring-1 ring-stone-200">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-stone-500">
|
||||
Try it
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
Use the form below to sign out, or navigate to{" "}
|
||||
<a className="text-emerald-700 underline" href="/admin">
|
||||
/admin
|
||||
</a>{" "}
|
||||
(the same session is shared).
|
||||
</p>
|
||||
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-xl bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Fraunces, JetBrains_Mono } from "next/font/google";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||
import SweetCornProductPage from "@/components/storefront/SweetCornProductPage";
|
||||
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-display",
|
||||
display: "swap",
|
||||
style: ["normal", "italic"],
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
display: "swap",
|
||||
weight: ["400", "500", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
// The Tuxedo layout's `template: "%s | Tuxedo Corn"` already appends the
|
||||
// brand — keep the bare title here so the rendered <title> is correct.
|
||||
title: "Fresh Sweet Corn Box – 12 Ears",
|
||||
description:
|
||||
"Our 12-Ear Corn Box is packed with peak-season, super-sweet Olathe Sweet corn picked that morning and rushed to your door. Hand-selected, non-GMO, and 100% sweetness guaranteed.",
|
||||
keywords: [
|
||||
"sweet corn",
|
||||
"Olathe Sweet",
|
||||
"fresh corn box",
|
||||
"12 ears of corn",
|
||||
"farm fresh corn",
|
||||
"Tuxedo Corn",
|
||||
],
|
||||
openGraph: {
|
||||
title: "Fresh Sweet Corn Box – 12 Ears",
|
||||
description:
|
||||
"Peak-season, super-sweet Olathe Sweet corn — picked that morning, rushed to your door. 100% sweetness guaranteed.",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "https://images.unsplash.com/photo-1551754655-cd27e38d2076?auto=format&fit=crop&w=1200&q=80",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Fresh sweet corn box — 12 ears of Olathe Sweet corn",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Fresh Sweet Corn Box – 12 Ears",
|
||||
description:
|
||||
"Peak-season, super-sweet Olathe Sweet corn. Hand-selected, non-GMO, 100% sweetness guaranteed.",
|
||||
},
|
||||
};
|
||||
|
||||
const BRAND_SLUG = "tuxedo";
|
||||
|
||||
type Brand = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Settings = {
|
||||
logo_url?: string | null;
|
||||
logo_url_dark?: string | null;
|
||||
};
|
||||
|
||||
export default async function SweetCornBoxPage() {
|
||||
// Fetch the brand record so we have a real brand_id to thread through
|
||||
// the cart system. Falls back to a placeholder if Supabase is unreachable
|
||||
// (so the page still renders in dev / disconnected previews).
|
||||
let brandId = "00000000-0000-0000-0000-000000000000";
|
||||
let brandName = "Tuxedo Corn";
|
||||
let logoUrl: string | null = null;
|
||||
let logoUrlDark: string | null = null;
|
||||
|
||||
try {
|
||||
const [brandRes, settingsRes] = await Promise.all([
|
||||
supabase.from("brands").select("id, name").eq("slug", BRAND_SLUG).single(),
|
||||
getBrandSettingsPublic(BRAND_SLUG),
|
||||
]);
|
||||
if (brandRes.data) {
|
||||
const b = brandRes.data as Brand;
|
||||
brandId = b.id;
|
||||
brandName = b.name;
|
||||
}
|
||||
if (settingsRes.success && settingsRes.settings) {
|
||||
const s = settingsRes.settings as Settings;
|
||||
logoUrl = s.logo_url ?? null;
|
||||
logoUrlDark = s.logo_url_dark ?? null;
|
||||
}
|
||||
} catch {
|
||||
// ignore — fall through with defaults
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${fraunces.variable} ${jetbrainsMono.variable} font-sans`}
|
||||
style={{
|
||||
// The product page uses editorial typography: a display serif
|
||||
// (Fraunces) for headlines and a mono (JetBrains Mono) for data
|
||||
// stamps. Body copy falls back to the global SF Pro stack.
|
||||
["--font-display" as never]: fraunces.style.fontFamily,
|
||||
["--font-mono" as never]: jetbrainsMono.style.fontFamily,
|
||||
}}
|
||||
>
|
||||
<SweetCornProductPage
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
logoUrl={logoUrl}
|
||||
logoUrlDark={logoUrlDark}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
import Google from "next-auth/providers/google";
|
||||
|
||||
/**
|
||||
* Edge-compatible Auth.js v5 configuration.
|
||||
*
|
||||
* This file is imported by `src/middleware.ts`, which runs in the Edge runtime.
|
||||
* It must NOT import the `@auth/pg-adapter` (which uses `pg`, a Node-only lib)
|
||||
* or any other Node-only module. Database wiring lives in `src/lib/auth.ts`.
|
||||
*
|
||||
* If you need to add a provider that uses Node-only APIs (e.g. an adapter
|
||||
* implementation), define it in `src/lib/auth.ts` instead and add a thin
|
||||
* placeholder here so the middleware can still reference it.
|
||||
*/
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const allowDevLogin = process.env.ALLOW_DEV_LOGIN !== "false"; // on by default in dev
|
||||
|
||||
export const authConfig = {
|
||||
// Custom sign-in page (must exist at /login)
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
|
||||
// Trust the host header in dev for callback URLs
|
||||
trustHost: true,
|
||||
|
||||
// Providers — referenced from middleware edge runtime.
|
||||
// The Google provider only needs the env vars at runtime; it does not pull
|
||||
// in any Node-only code. The dev Credentials provider is added in
|
||||
// `src/lib/auth.ts` (server-side only) — it's not safe to import
|
||||
// `next-auth/providers/credentials` from the edge runtime.
|
||||
providers: [
|
||||
Google({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID ?? process.env.AUTH_GOOGLE_ID,
|
||||
clientSecret:
|
||||
process.env.GOOGLE_CLIENT_SECRET ?? process.env.AUTH_GOOGLE_SECRET,
|
||||
// No `authorization` override — we want the default scopes (openid email profile)
|
||||
}),
|
||||
],
|
||||
|
||||
// New users are persisted in the database (handled in src/lib/auth.ts)
|
||||
// Default to JWT here so middleware can run in edge runtime; the full
|
||||
// server-side handler in src/lib/auth.ts switches this to "database".
|
||||
session: { strategy: "jwt" },
|
||||
|
||||
callbacks: {
|
||||
/**
|
||||
* Gate /admin routes. Anything not on the public list and not signed in
|
||||
* gets redirected to /login. This mirrors what the page-level checks do,
|
||||
* but runs first at the edge so unauthorized requests never hit the
|
||||
* server component tree.
|
||||
*/
|
||||
authorized({ auth, request: { nextUrl } }) {
|
||||
const isLoggedIn = !!auth?.user;
|
||||
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
|
||||
const isOnProtectedExample = nextUrl.pathname.startsWith(
|
||||
"/protected-example"
|
||||
);
|
||||
|
||||
if (isOnAdmin) {
|
||||
if (isLoggedIn) return true;
|
||||
return false; // Redirect to /login
|
||||
}
|
||||
|
||||
if (isOnProtectedExample) {
|
||||
if (isLoggedIn) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Forward the user id from the database user record into the JWT on
|
||||
* initial sign-in. With database sessions this is what populates
|
||||
* `session.user.id` for downstream server actions.
|
||||
*/
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = (user as { id?: string }).id ?? token.sub;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
async session({ session, token }) {
|
||||
if (session.user && token?.sub) {
|
||||
(session.user as { id?: string }).id = token.sub;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
// Cookie config — keep default names so legacy `rc_auth_uid` consumers
|
||||
// continue to work until they're migrated. New Auth.js cookies default to
|
||||
// `authjs.session-token` (dev) and `__Secure-authjs.session-token` (prod).
|
||||
} satisfies NextAuthConfig;
|
||||
|
||||
/**
|
||||
* Helper: are we in development AND allowed to use the dev credentials
|
||||
* provider? Exposed so server-side `src/lib/auth.ts` can decide whether to
|
||||
* include the provider in its provider list.
|
||||
*/
|
||||
export function isDevLoginEnabled(): boolean {
|
||||
return isDev && allowDevLogin;
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { createLocation } from "@/actions/locations";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
brandId: string;
|
||||
onSuccess?: (locationId: string) => void;
|
||||
};
|
||||
|
||||
export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [city, setCity] = useState("");
|
||||
const [stateVal, setStateVal] = useState("");
|
||||
const [zip, setZip] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [contactName, setContactName] = useState("");
|
||||
const [contactEmail, setContactEmail] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setName("");
|
||||
setAddress("");
|
||||
setCity("");
|
||||
setStateVal("");
|
||||
setZip("");
|
||||
setPhone("");
|
||||
setContactName("");
|
||||
setContactEmail("");
|
||||
setNotes("");
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (loading) return;
|
||||
reset();
|
||||
onClose();
|
||||
}, [loading, reset, onClose]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!name.trim()) {
|
||||
setError("Venue name is required.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await createLocation(brandId, {
|
||||
name: name.trim(),
|
||||
address: address.trim() || null,
|
||||
city: city.trim() || null,
|
||||
state: stateVal.trim().toUpperCase() || null,
|
||||
zip: zip.trim() || null,
|
||||
phone: phone.trim() || null,
|
||||
contact_name: contactName.trim() || null,
|
||||
contact_email: contactEmail.trim() || null,
|
||||
notes: notes.trim() || null,
|
||||
active: true,
|
||||
});
|
||||
if (result.success) {
|
||||
onSuccess?.(result.id);
|
||||
reset();
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error ?? "Failed to create location");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, reset, onClose]
|
||||
);
|
||||
|
||||
const inputStyle = {
|
||||
background: "rgba(0, 0, 0, 0.02)",
|
||||
border: "1px solid rgba(0, 0, 0, 0.06)",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
e.target.style.background = "rgba(16, 185, 129, 0.04)";
|
||||
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
|
||||
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
|
||||
};
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
e.target.style.background = "rgba(0, 0, 0, 0.02)";
|
||||
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
|
||||
e.target.style.boxShadow = "none";
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<GlassModal
|
||||
title="Add Venue"
|
||||
subtitle="Reusable pick-up location. Once saved, you can attach stops to it from the Stops tab."
|
||||
onClose={handleClose}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div
|
||||
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
|
||||
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Tractor Supply"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="13778 E I-25 Frontage Rd"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
<div className="col-span-3 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
||||
<input
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Wellington"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
||||
<input
|
||||
type="text"
|
||||
value={stateVal}
|
||||
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
|
||||
placeholder="CO"
|
||||
maxLength={2}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
||||
<input
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80549"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="(970) 555-1234"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={contactName}
|
||||
onChange={(e) => setContactName(e.target.value)}
|
||||
placeholder="Store manager"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={contactEmail}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
placeholder="manager@example.com"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Park on west side. Use side entrance after 9am."
|
||||
rows={2}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all resize-none"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
|
||||
style={{
|
||||
background: loading
|
||||
? "rgba(16, 185, 129, 0.4)"
|
||||
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
|
||||
boxShadow: loading
|
||||
? "none"
|
||||
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
opacity: loading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Creating…
|
||||
</span>
|
||||
) : (
|
||||
"Create Venue"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { createStop } from "@/actions/stops/create-stop";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
@@ -21,12 +21,20 @@ type Props = {
|
||||
onSuccess?: (stopId: string) => void;
|
||||
};
|
||||
|
||||
/* Pin icon for the modal header */
|
||||
const PinIcon = () => (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, onSuccess }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [city, setCity] = useState("");
|
||||
const [state, setState] = useState("");
|
||||
const [stateField, setStateField] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = useState("");
|
||||
@@ -36,11 +44,13 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
const [cutoffTime, setCutoffTime] = useState("");
|
||||
const [status, setStatus] = useState<"draft" | "active">("draft");
|
||||
|
||||
const cityRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Reset form when modal opens with optional duplicate source
|
||||
setCity(duplicateFrom?.city ?? "");
|
||||
setState(duplicateFrom?.state ?? "");
|
||||
setStateField(duplicateFrom?.state ?? "");
|
||||
setLocation(duplicateFrom?.location ?? "");
|
||||
setDate(duplicateFrom?.date ?? "");
|
||||
setTime(duplicateFrom?.time ?? "");
|
||||
@@ -49,272 +59,328 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
|
||||
setStatus("draft");
|
||||
setError(null);
|
||||
// Auto-focus city field for fast data entry
|
||||
requestAnimationFrame(() => cityRef.current?.focus());
|
||||
}
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, [isOpen, duplicateFrom]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!city.trim() || !state.trim() || !location.trim() || !date) {
|
||||
setError("City, state, location, and date are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await createStop(brandId, {
|
||||
city: city.trim(),
|
||||
state: state.trim(),
|
||||
location: location.trim(),
|
||||
date,
|
||||
time: time || "08:00",
|
||||
address: address || undefined,
|
||||
zip: zip || undefined,
|
||||
cutoff_time: cutoffTime || undefined,
|
||||
active: status === "active",
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.(result.id);
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error ?? "Failed to create stop");
|
||||
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
|
||||
setError("City, state, location, and date are required.");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [brandId, city, state, location, date, time, address, zip, cutoffTime, status, onSuccess, onClose]);
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await createStop(brandId, {
|
||||
city: city.trim(),
|
||||
state: stateField.trim(),
|
||||
location: location.trim(),
|
||||
date,
|
||||
time: time || "08:00",
|
||||
address: address || undefined,
|
||||
zip: zip || undefined,
|
||||
cutoff_time: cutoffTime || undefined,
|
||||
active: status === "active",
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.(result.id);
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error ?? "Failed to create stop");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
brandId,
|
||||
city,
|
||||
stateField,
|
||||
location,
|
||||
date,
|
||||
time,
|
||||
address,
|
||||
zip,
|
||||
cutoffTime,
|
||||
status,
|
||||
onSuccess,
|
||||
onClose,
|
||||
]
|
||||
);
|
||||
|
||||
const isDuplicate = Boolean(duplicateFrom);
|
||||
const title = isDuplicate ? "Duplicate Stop" : "Add New Stop";
|
||||
const subtitle = isDuplicate && duplicateFrom
|
||||
const title = isDuplicate ? "Duplicate Stop" : "Add Stop";
|
||||
const eyebrow = isDuplicate && duplicateFrom
|
||||
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
|
||||
: "Create a new tour stop for your route.";
|
||||
|
||||
const inputStyle = {
|
||||
background: 'rgba(0, 0, 0, 0.02)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
e.target.style.background = 'rgba(16, 185, 129, 0.04)';
|
||||
e.target.style.border = '1px solid rgba(16, 185, 129, 0.5)';
|
||||
e.target.style.boxShadow = '0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)';
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
e.target.style.background = 'rgba(0, 0, 0, 0.02)';
|
||||
e.target.style.border = '1px solid rgba(0, 0, 0, 0.06)';
|
||||
e.target.style.boxShadow = 'none';
|
||||
};
|
||||
: "New stop on the route";
|
||||
const submitLabel = loading
|
||||
? isDuplicate ? "Duplicating…" : "Creating…"
|
||||
: isDuplicate
|
||||
? "Duplicate Stop"
|
||||
: status === "active"
|
||||
? "Create & Publish"
|
||||
: "Save as Draft";
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<GlassModal title={title} subtitle={subtitle} onClose={onClose}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<GlassModal
|
||||
title={title}
|
||||
eyebrow={eyebrow}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-xl"
|
||||
compact
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
||||
{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
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{ background: "rgba(220, 38, 38, 0.06)", border: "1px solid rgba(220, 38, 38, 0.15)" }}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
||||
{/* Row 1 — Where: City + State */}
|
||||
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-city" className="ha-field-label">
|
||||
<PinIcon />
|
||||
<span>City</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
ref={cityRef}
|
||||
id="stop-city"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Denver"
|
||||
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}
|
||||
className="ha-field-input"
|
||||
required
|
||||
autoComplete="address-level2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-state" className="ha-field-label">
|
||||
<span>State</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-state"
|
||||
type="text"
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value.toUpperCase())}
|
||||
value={stateField}
|
||||
onChange={(e) => setStateField(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}
|
||||
className="ha-field-input ha-field-input-mono text-center"
|
||||
style={{ textTransform: "uppercase" }}
|
||||
required
|
||||
autoComplete="address-level1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Location</label>
|
||||
{/* Row 2 — Where at: Location (full) */}
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-location" className="ha-field-label">
|
||||
<span>Location / Venue</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-location"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Whole Foods Market"
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder="Whole Foods Market — Highlands"
|
||||
className="ha-field-input"
|
||||
required
|
||||
/>
|
||||
</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">Date</label>
|
||||
{/* Row 3 — When: Date + Time (with small clock icon) */}
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-date" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Date</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(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}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Pickup Time</label>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-time" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Time</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-time"
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(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}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Address</label>
|
||||
{/* Row 4 — Address + ZIP + Cutoff in 3 columns */}
|
||||
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-address" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
|
||||
<path d="M9 22V12h6v10" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>Street Address</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-address"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
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}
|
||||
className="ha-field-input"
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-zip" className="ha-field-label">
|
||||
<span>ZIP</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-zip"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80202"
|
||||
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}
|
||||
maxLength={10}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
autoComplete="postal-code"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-cutoff" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Cutoff</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-cutoff"
|
||||
type="time"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Cutoff Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(e.target.value)}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
|
||||
style={inputStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-stone-400 ml-1">Orders close this time the day before pickup</p>
|
||||
</div>
|
||||
|
||||
{/* Status toggle */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Status</label>
|
||||
<div className="flex gap-3">
|
||||
{/* Row 5 — Status: segmented control (compact, replaces two giant buttons) */}
|
||||
<div className="ha-field pt-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="ha-field-label">
|
||||
<span>Visibility</span>
|
||||
</label>
|
||||
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
|
||||
Draft is hidden from customers
|
||||
</span>
|
||||
</div>
|
||||
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "draft"}
|
||||
onClick={() => setStatus("draft")}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
|
||||
style={{
|
||||
border: status === "draft" ? '1px solid rgba(245, 158, 11, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
|
||||
background: status === "draft" ? 'rgba(245, 158, 11, 0.08)' : 'rgba(0, 0, 0, 0.02)',
|
||||
}}
|
||||
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
|
||||
>
|
||||
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
|
||||
background: status === "draft" ? 'rgba(245, 158, 11, 0.2)' : 'rgba(0, 0, 0, 0.05)',
|
||||
color: status === "draft" ? '#b45309' : 'rgba(0, 0, 0, 0.4)',
|
||||
}}>Draft</span>
|
||||
<span className="text-sm font-medium" style={{ color: status === "draft" ? '#92400e' : 'rgba(0, 0, 0, 0.4)' }}>Save as draft</span>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Save as Draft</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "active"}
|
||||
onClick={() => setStatus("active")}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
|
||||
style={{
|
||||
border: status === "active" ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
|
||||
background: status === "active" ? 'rgba(16, 185, 129, 0.08)' : 'rgba(0, 0, 0, 0.02)',
|
||||
}}
|
||||
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
|
||||
>
|
||||
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
|
||||
background: status === "active" ? 'rgba(16, 185, 129, 0.2)' : 'rgba(0, 0, 0, 0.05)',
|
||||
color: status === "active" ? '#047857' : 'rgba(0, 0, 0, 0.4)',
|
||||
}}>Active</span>
|
||||
<span className="text-sm font-medium" style={{ color: status === "active" ? '#065f46' : 'rgba(0, 0, 0, 0.4)' }}>Publish now</span>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Publish Now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
|
||||
style={{
|
||||
background: loading
|
||||
? 'rgba(16, 185, 129, 0.4)'
|
||||
: 'linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)',
|
||||
boxShadow: loading
|
||||
? 'none'
|
||||
: '0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)',
|
||||
opacity: loading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Creating...
|
||||
</span>
|
||||
) : (
|
||||
isDuplicate ? "Duplicate Stop" : "Create Stop"
|
||||
)}
|
||||
</button>
|
||||
<div className="ha-modal-footer">
|
||||
<span className="ha-modal-footer-hint">
|
||||
<kbd>Esc</kbd>
|
||||
to close
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ha-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="ha-btn-primary"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{status === "active" ? (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
@@ -338,4 +404,4 @@ export function useAddStopModal(brandId: string, duplicateFrom?: Props["duplicat
|
||||
), [isOpen, close, brandId, duplicateFrom]);
|
||||
|
||||
return { open, close, Modal };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import BrandSelector from "./BrandSelector";
|
||||
|
||||
// Elegant warm sidebar design
|
||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||
@@ -217,9 +218,12 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
|
||||
type SidebarProps = {
|
||||
userRole?: string | null;
|
||||
brandIds?: string[];
|
||||
activeBrandId?: string | null;
|
||||
brands?: Array<{ id: string; name: string; slug: string; logo_url: string | null }>;
|
||||
};
|
||||
|
||||
export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
@@ -230,9 +234,15 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
|
||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||
: userRole === "brand_admin" ? "Brand Admin"
|
||||
: userRole === "multi_brand_admin" ? "Multi-Brand Manager"
|
||||
: userRole === "store_employee" ? "Store Employee"
|
||||
: null;
|
||||
|
||||
const isMultiBrandAdmin = userRole === "multi_brand_admin";
|
||||
const showAllBrandsOption = userRole === "platform_admin";
|
||||
const showBrandSelector =
|
||||
brands && (showAllBrandsOption || (brandIds && brandIds.length >= 2));
|
||||
|
||||
const isActive = useCallback((href: string) => {
|
||||
if (href === "/admin") return pathname === "/admin";
|
||||
return pathname.startsWith(href);
|
||||
@@ -394,6 +404,24 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Brand selector (multi-brand admins + platform_admin) */}
|
||||
{showBrandSelector && (
|
||||
<div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
|
||||
<p
|
||||
className="text-[10px] font-semibold uppercase tracking-widest mb-1.5 px-1"
|
||||
style={{ color: "rgba(195, 195, 193, 0.6)" }}
|
||||
>
|
||||
Active Brand
|
||||
</p>
|
||||
<BrandSelector
|
||||
brands={brands!}
|
||||
activeBrandId={activeBrandId ?? null}
|
||||
showAllBrandsOption={showAllBrandsOption}
|
||||
isMultiBrandAdmin={isMultiBrandAdmin}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nav links with keyboard navigation */}
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
|
||||
<ul className="space-y-1" role="list">
|
||||
|
||||
@@ -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;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
/** Compact mode: tighter padding, smaller title, no accent bar — for dense forms. */
|
||||
compact?: boolean;
|
||||
/** Optional eyebrow text rendered above the title in compact mode. */
|
||||
eyebrow?: string;
|
||||
};
|
||||
|
||||
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg" }: Props) {
|
||||
export default function GlassModal({
|
||||
title,
|
||||
titleIcon,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
maxWidth = "max-w-lg",
|
||||
compact = false,
|
||||
eyebrow,
|
||||
}: Props) {
|
||||
// Lock body scroll
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
@@ -33,25 +46,36 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
||||
|
||||
return (
|
||||
<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}
|
||||
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
|
||||
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 */}
|
||||
<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" />
|
||||
{/* Accent bar — only for non-compact (compact forms have their own internal accent) */}
|
||||
{!compact && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 sm:px-6 md:px-8 py-4 sm:py-6 shrink-0"
|
||||
<div
|
||||
className={`flex items-center justify-between shrink-0 ${
|
||||
compact ? "px-5 pt-4 pb-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6"
|
||||
}`}
|
||||
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
{titleIcon && (
|
||||
<div
|
||||
{titleIcon && !compact && (
|
||||
<div
|
||||
className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-xl shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent-light)" }}
|
||||
>
|
||||
@@ -59,21 +83,39 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
className="text-lg sm:text-xl font-semibold text-[var(--admin-text-primary)] truncate"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
{compact && eyebrow && (
|
||||
<p className="ha-eyebrow mb-0.5 truncate">{eyebrow}</p>
|
||||
)}
|
||||
<h2
|
||||
className={`${
|
||||
compact
|
||||
? "ha-display text-lg truncate"
|
||||
: "text-lg sm:text-xl font-semibold truncate"
|
||||
} text-[var(--admin-text-primary)]`}
|
||||
style={compact ? undefined : { letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">{subtitle}</p>
|
||||
{subtitle && !compact && (
|
||||
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2"
|
||||
style={{ backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }}
|
||||
className={`flex shrink-0 items-center justify-center rounded-full transition-all duration-150 ml-2 ${
|
||||
compact
|
||||
? "h-7 w-7 text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)]"
|
||||
: "h-8 w-8 sm:h-9 sm:w-9"
|
||||
}`}
|
||||
style={
|
||||
compact
|
||||
? undefined
|
||||
: { backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }
|
||||
}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<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" />
|
||||
@@ -81,8 +123,12 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - scrollable */}
|
||||
<div className="relative px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8 overflow-y-auto flex-1">
|
||||
{/* Content */}
|
||||
<div
|
||||
className={`relative overflow-y-auto flex-1 ${
|
||||
compact ? "px-5 pb-5 pt-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</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";
|
||||
|
||||
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 { useRouter } from "next/navigation";
|
||||
import { createProduct } from "@/actions/products/create-product";
|
||||
import { updateProduct } from "@/actions/products/update-product";
|
||||
import { deleteProduct } from "@/actions/products";
|
||||
import { uploadProductImage } from "@/actions/products/upload-image";
|
||||
import 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";
|
||||
|
||||
type Product = {
|
||||
@@ -20,16 +21,8 @@ type Product = {
|
||||
image_url?: string | null;
|
||||
brand_id: string;
|
||||
is_taxable: boolean;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
description: string;
|
||||
price: string;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url: string;
|
||||
is_taxable: boolean;
|
||||
available_from?: string | null;
|
||||
available_until?: string | null;
|
||||
};
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
@@ -74,7 +67,17 @@ const PackageIconHeader = () => (
|
||||
</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 { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
@@ -83,28 +86,10 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: "",
|
||||
description: "",
|
||||
price: "",
|
||||
type: "pickup",
|
||||
active: true,
|
||||
image_url: "",
|
||||
is_taxable: true,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Image upload states
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [pendingImageUrl, setPendingImageUrl] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filtered = products.filter((p) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
@@ -147,155 +132,104 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("Image must be under 5MB.");
|
||||
return;
|
||||
}
|
||||
const handleUploadImage = useCallback(
|
||||
async (file: File): Promise<{ success: boolean; imageUrl?: string; error?: string }> => {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return { success: false, error: "Only PNG, JPEG, and WebP images are allowed." };
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return { success: false, error: "Image must be under 5MB." };
|
||||
}
|
||||
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
const resizedBuffer = await resizeImage(file, 1200);
|
||||
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
||||
|
||||
const resizedBuffer = await resizeImage(file, 1200);
|
||||
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
||||
|
||||
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
|
||||
const result = await uploadProductImage(productIdForUpload, resizedFile);
|
||||
|
||||
setUploading(false);
|
||||
|
||||
if (result.success) {
|
||||
setPendingImageUrl(result.imageUrl);
|
||||
setImagePreview(result.imageUrl);
|
||||
setFormData({ ...formData, image_url: result.imageUrl });
|
||||
} else {
|
||||
setUploadError(result.error ?? "Upload failed.");
|
||||
}
|
||||
}
|
||||
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
|
||||
const result = await uploadProductImage(productIdForUpload, resizedFile);
|
||||
if (result.success) {
|
||||
return { success: true, imageUrl: result.imageUrl };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
[editingProduct]
|
||||
);
|
||||
|
||||
const openAddModal = useCallback(() => {
|
||||
setEditingProduct(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
description: "",
|
||||
price: "",
|
||||
type: "pickup",
|
||||
active: true,
|
||||
image_url: "",
|
||||
is_taxable: true,
|
||||
});
|
||||
setImagePreview(null);
|
||||
setPendingImageUrl(null);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const openEditModal = useCallback((product: Product) => {
|
||||
setEditingProduct(product);
|
||||
setFormData({
|
||||
name: product.name,
|
||||
description: product.description || "",
|
||||
price: String(product.price),
|
||||
type: product.type,
|
||||
active: product.active,
|
||||
image_url: product.image_url || "",
|
||||
is_taxable: product.is_taxable,
|
||||
});
|
||||
setImagePreview(product.image_url || null);
|
||||
setPendingImageUrl(null);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setShowModal(false);
|
||||
setEditingProduct(null);
|
||||
setImagePreview(null);
|
||||
setPendingImageUrl(null);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
setIsLoading(true);
|
||||
const handleModalSubmit = useCallback(
|
||||
async (values: ProductFormValues): Promise<{ success: boolean; error?: string }> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const price = parseFloat(values.price);
|
||||
if (isNaN(price) || price < 0) {
|
||||
return { success: false, error: "Please enter a valid price" };
|
||||
}
|
||||
if (!values.name.trim()) {
|
||||
return { success: false, error: "Product name is required" };
|
||||
}
|
||||
|
||||
const price = parseFloat(formData.price);
|
||||
if (isNaN(price) || price < 0) {
|
||||
setError("Please enter a valid price");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Platform admins pick a brand in the modal; everyone else is locked.
|
||||
const effectiveBrandId = isPlatformAdmin ? values.brand_id : brandId;
|
||||
if (!effectiveBrandId) {
|
||||
return { success: false, error: "Please choose a brand for this product." };
|
||||
}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
setError("Product name is required");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
if (editingProduct) {
|
||||
result = await updateProduct(editingProduct.id, effectiveBrandId, {
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
price,
|
||||
type: values.type,
|
||||
active: values.active,
|
||||
image_url: values.image_url,
|
||||
is_taxable: values.is_taxable,
|
||||
});
|
||||
} else {
|
||||
result = await createProduct(effectiveBrandId, {
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
price,
|
||||
type: values.type,
|
||||
active: values.active,
|
||||
image_url: values.image_url,
|
||||
is_taxable: values.is_taxable,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!brandId) {
|
||||
setError("Brand ID is required");
|
||||
setSaving(false);
|
||||
if (!result.success) {
|
||||
showError("Failed to save product", result.error ?? "Please try again");
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
showSuccess(
|
||||
editingProduct ? "Product updated" : "Product created",
|
||||
`${values.name} has been saved`
|
||||
);
|
||||
startTransition(() => router.refresh());
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Network error. Please try again." };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
const imageUrl = pendingImageUrl || formData.image_url || null;
|
||||
|
||||
if (editingProduct) {
|
||||
result = await updateProduct(editingProduct.id, brandId, {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
price,
|
||||
type: formData.type,
|
||||
active: formData.active,
|
||||
image_url: imageUrl,
|
||||
is_taxable: formData.is_taxable,
|
||||
});
|
||||
} else {
|
||||
result = await createProduct(brandId, {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
price,
|
||||
type: formData.type,
|
||||
active: formData.active,
|
||||
image_url: imageUrl,
|
||||
is_taxable: formData.is_taxable,
|
||||
});
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save product");
|
||||
showError("Failed to save product", result.error ?? "Please try again");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccess(editingProduct ? "Product updated" : "Product created", `${formData.name} has been saved`);
|
||||
closeModal();
|
||||
setIsLoading(false);
|
||||
startTransition(() => router.refresh());
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
showError("Network error", "Please check your connection and try again");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
},
|
||||
[editingProduct, brandId, isPlatformAdmin, router, showError, showSuccess]
|
||||
);
|
||||
|
||||
const handleDelete = async (productId: string) => {
|
||||
setDeletingId(productId);
|
||||
@@ -399,203 +333,38 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<GlassModal
|
||||
title={editingProduct ? "Edit Product" : "Add Product"}
|
||||
subtitle={editingProduct ? `Editing ${editingProduct.name}` : "Create a new product"}
|
||||
onClose={closeModal}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Product name"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] transition-colors ${
|
||||
error && !formData.name.trim()
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Product description"
|
||||
rows={2}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Price <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
|
||||
placeholder="0.00"
|
||||
className={`w-full rounded-xl border pl-8 pr-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
error && (!formData.price || parseFloat(formData.price) < 0)
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Type</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
<option value="pickup">Pickup</option>
|
||||
<option value="wholesale">Wholesale</option>
|
||||
<option value="subscription">Subscription</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Product Image</label>
|
||||
<p className="text-[10px] text-stone-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB</p>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||
${uploadError ? "border-red-400" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)] hover:bg-[var(--admin-accent)]/5"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-stone-500">Uploading...</span>
|
||||
</>
|
||||
) : imagePreview ? (
|
||||
<div className="relative">
|
||||
<div className="relative h-32 sm:h-40 w-auto">
|
||||
<Image src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
||||
</div>
|
||||
<span className="block text-center text-xs text-stone-500 mt-2">Click or drop to replace</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Icons.upload("h-8 w-8 text-stone-400")}
|
||||
<span className="text-sm text-stone-500">Drag & drop or click to upload</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileSelect(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
|
||||
)}
|
||||
|
||||
{(imagePreview || formData.image_url) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImagePreview(null);
|
||||
setPendingImageUrl(null);
|
||||
setFormData({ ...formData, image_url: "" });
|
||||
}}
|
||||
className="mt-2 text-xs text-red-500 hover:underline"
|
||||
>
|
||||
Remove image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-stone-700">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_taxable}
|
||||
onChange={(e) => setFormData({ ...formData, is_taxable: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-stone-700">Taxable</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-stone-100">
|
||||
<AdminButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={closeModal}
|
||||
>
|
||||
Cancel
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isLoading={saving}
|
||||
disabled={!formData.name.trim() || !formData.price}
|
||||
>
|
||||
{editingProduct ? "Save Changes" : "Create Product"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
)}
|
||||
{/* Modal — Atelier des Récoltes (editorial product form) */}
|
||||
<ProductFormModal
|
||||
open={showModal}
|
||||
mode={editingProduct ? "edit" : "add"}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleModalSubmit}
|
||||
onUploadImage={handleUploadImage}
|
||||
brands={brands}
|
||||
lockBrand={!isPlatformAdmin}
|
||||
lockedBrandId={brandId ?? ""}
|
||||
initial={
|
||||
editingProduct
|
||||
? {
|
||||
name: editingProduct.name,
|
||||
description: editingProduct.description || "",
|
||||
price: String(editingProduct.price),
|
||||
type: (editingProduct.type as "pickup" | "wholesale" | "subscription") ?? "pickup",
|
||||
brand_id: editingProduct.brand_id,
|
||||
active: editingProduct.active,
|
||||
is_taxable: editingProduct.is_taxable,
|
||||
available_from: editingProduct.available_from ?? null,
|
||||
available_until: editingProduct.available_until ?? null,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
initialImageUrl={editingProduct?.image_url ?? null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── Table View ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TableView({
|
||||
@@ -615,8 +384,51 @@ function TableView({
|
||||
onDeleteCancel: () => void;
|
||||
deletingId: string | null;
|
||||
}) {
|
||||
// Track the position of the open row's three-dots button so the popup can be
|
||||
// rendered via portal at body level (escapes any overflow:hidden ancestors
|
||||
// and any table-row stacking-context quirks).
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteConfirm) {
|
||||
setMenuPos(null);
|
||||
return;
|
||||
}
|
||||
const btn = buttonRefs.current[deleteConfirm];
|
||||
if (btn) {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
|
||||
}
|
||||
}, [deleteConfirm]);
|
||||
|
||||
// Reposition on scroll/resize so the popup stays anchored to its button.
|
||||
useEffect(() => {
|
||||
if (!deleteConfirm) return;
|
||||
const updatePos = () => {
|
||||
const btn = buttonRefs.current[deleteConfirm];
|
||||
if (btn) {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
|
||||
}
|
||||
};
|
||||
window.addEventListener("scroll", updatePos, true);
|
||||
window.addEventListener("resize", updatePos);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", updatePos, true);
|
||||
window.removeEventListener("resize", updatePos);
|
||||
};
|
||||
}, [deleteConfirm]);
|
||||
|
||||
const openProduct = deleteConfirm ? products.find((p) => p.id === deleteConfirm) : null;
|
||||
|
||||
return (
|
||||
<div className="overflow-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">
|
||||
<thead className="bg-stone-50">
|
||||
<tr>
|
||||
@@ -700,44 +512,15 @@ function TableView({
|
||||
Edit
|
||||
</AdminButton>
|
||||
<button
|
||||
ref={(el) => { buttonRefs.current[product.id] = el; }}
|
||||
onClick={() => onDelete(product.id)}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
aria-label="Product actions"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{deleteConfirm === product.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={onDeleteCancel} />
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-stone-900">
|
||||
Delete "{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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -745,6 +528,50 @@ function TableView({
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Delete confirmation popup — rendered via portal at body level with
|
||||
position: fixed so it sits above all table rows and escapes the
|
||||
overflow-visible container. */}
|
||||
{mounted && deleteConfirm && openProduct && menuPos &&
|
||||
createPortal(
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[60]"
|
||||
onClick={onDeleteCancel}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Confirm delete"
|
||||
className="fixed z-[70] w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4"
|
||||
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
|
||||
>
|
||||
<p className="text-sm font-semibold text-stone-900">
|
||||
Delete "{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
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 { success: showSuccess, error: showError } = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -99,6 +101,7 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) {
|
||||
showSuccess("Stop updated", `${city}, ${state} has been saved`);
|
||||
setSaved(true);
|
||||
setSaving(false);
|
||||
onSaved?.();
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { logAuditEvent } from "@/actions/audit";
|
||||
|
||||
type Product = {
|
||||
@@ -8,12 +9,13 @@ type Product = {
|
||||
name: string;
|
||||
type: string;
|
||||
price: number;
|
||||
image_url?: string | null;
|
||||
};
|
||||
|
||||
type AssignedProduct = {
|
||||
id: string;
|
||||
product_id: string;
|
||||
products: { name: string; type: string; price: number } | null;
|
||||
products: { name: string; type: string; price: number; image_url?: string | null } | null;
|
||||
};
|
||||
|
||||
type StopProductAssignmentProps = {
|
||||
@@ -23,6 +25,20 @@ type StopProductAssignmentProps = {
|
||||
callerUid: string;
|
||||
};
|
||||
|
||||
type Filter = "all" | "available" | "assigned";
|
||||
|
||||
/* Helpers — monospace price + initial-letter avatar */
|
||||
const formatPrice = (n: number) =>
|
||||
`$${Number(n ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
|
||||
const initialOf = (name: string) => (name?.trim()?.charAt(0) ?? "·").toUpperCase();
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
pickup: "Pickup",
|
||||
wholesale: "Wholesale",
|
||||
subscription: "Sub",
|
||||
};
|
||||
|
||||
export default function StopProductAssignment({
|
||||
stopId,
|
||||
allProducts,
|
||||
@@ -30,209 +46,398 @@ export default function StopProductAssignment({
|
||||
callerUid,
|
||||
}: StopProductAssignmentProps) {
|
||||
const [products, setProducts] = useState(assignedProducts);
|
||||
const [selected, setSelected] = useState("");
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const assignedIds = new Set(products.map((p) => p.product_id));
|
||||
const availableProducts = allProducts.filter((p) => !assignedIds.has(p.id));
|
||||
const assignedIds = useMemo(
|
||||
() => new Set(products.map((p) => p.product_id)),
|
||||
[products]
|
||||
);
|
||||
|
||||
async function handleAssign(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selected) return;
|
||||
// Counters for the filter chips
|
||||
const counts = useMemo(() => {
|
||||
const assignedCount = products.length;
|
||||
const availableCount = allProducts.filter((p) => !assignedIds.has(p.id)).length;
|
||||
return {
|
||||
all: allProducts.length,
|
||||
available: availableCount,
|
||||
assigned: assignedCount,
|
||||
};
|
||||
}, [allProducts, products, assignedIds]);
|
||||
|
||||
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);
|
||||
|
||||
// First run diagnostic to understand the actual state
|
||||
const diagRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/debug_stop_product_assignment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: selected,
|
||||
p_caller_uid: callerUid,
|
||||
}),
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: productId,
|
||||
p_caller_uid: callerUid,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? "";
|
||||
setError(`Failed to assign: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
|
||||
setPendingId(null);
|
||||
return;
|
||||
}
|
||||
);
|
||||
const diag = await diagRes.json();
|
||||
|
||||
// Now attempt the actual assignment
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: selected,
|
||||
p_caller_uid: callerUid,
|
||||
}),
|
||||
}
|
||||
);
|
||||
// Optimistic insert: build the entry from the product we already have
|
||||
// locally, keyed by the row id returned from the RPC. Use functional
|
||||
// setState so concurrent calls compose correctly.
|
||||
setProducts((prev) => {
|
||||
if (prev.some((p) => p.product_id === productId)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: data.id,
|
||||
product_id: productId,
|
||||
products: {
|
||||
name: product.name,
|
||||
type: product.type,
|
||||
price: product.price,
|
||||
image_url: product.image_url ?? null,
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
setPendingId(null);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
const diagInfo = diag && typeof diag === 'object' && 'reason' in diag
|
||||
? `[${(diag as any).reason}] (admin_found=${(diag as any).admin_found}, role=${(diag as any).admin_role}, admin_brand=${(diag as any).admin_brand_id}, stop_brand=${(diag as any).stop_brand_id}, product_brand=${(diag as any).product_brand_id})`
|
||||
: '';
|
||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
|
||||
const fullError = diagInfo
|
||||
? `Failed to assign: ${errMsg} ${diagInfo}${errDetail ? ' | ' + errDetail : ''}`
|
||||
: `Failed to assign: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`;
|
||||
setError(fullError);
|
||||
setAssigning(false);
|
||||
return;
|
||||
logAuditEvent({
|
||||
table_name: "product_stops",
|
||||
record_id: data.id,
|
||||
action: "INSERT",
|
||||
old_data: null,
|
||||
new_data: { stop_id: stopId, product_id: productId },
|
||||
brand_id: null,
|
||||
});
|
||||
} catch {
|
||||
setError("Network error while assigning product.");
|
||||
setPendingId(null);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (!entry) return;
|
||||
|
||||
setRemoving(productId);
|
||||
setRemovingId(productId);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: productId,
|
||||
p_caller_uid: callerUid,
|
||||
}),
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_product_id: productId,
|
||||
p_caller_uid: callerUid,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? "";
|
||||
setError(`Failed to remove: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
|
||||
setRemovingId(null);
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
setProducts((prev) => prev.filter((p) => p.product_id !== productId));
|
||||
setRemovingId(null);
|
||||
|
||||
if (!res.ok || data.success === false) {
|
||||
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
||||
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
|
||||
setError(`Failed to remove: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`);
|
||||
setRemoving(null);
|
||||
return;
|
||||
logAuditEvent({
|
||||
table_name: "product_stops",
|
||||
record_id: entry.id,
|
||||
action: "DELETE",
|
||||
old_data: { stop_id: stopId, product_id: productId },
|
||||
new_data: null,
|
||||
brand_id: null,
|
||||
});
|
||||
} catch {
|
||||
setError("Network error while removing product.");
|
||||
setRemovingId(null);
|
||||
}
|
||||
|
||||
setProducts(products.filter((p) => p.product_id !== productId));
|
||||
setRemoving(null);
|
||||
|
||||
logAuditEvent({
|
||||
table_name: "product_stops",
|
||||
record_id: entry.id,
|
||||
action: "DELETE",
|
||||
old_data: { stop_id: stopId, product_id: productId },
|
||||
new_data: null,
|
||||
brand_id: null,
|
||||
});
|
||||
}
|
||||
|
||||
function toggle(productId: string) {
|
||||
if (assignedIds.has(productId)) {
|
||||
remove(productId);
|
||||
} else {
|
||||
assign(productId);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!products.length || removingId || pendingId) return;
|
||||
// Snapshot ids at click time, then remove each one sequentially so the
|
||||
// server sees one request at a time.
|
||||
const idsToRemove = products.map((p) => p.product_id);
|
||||
(async () => {
|
||||
for (const id of idsToRemove) {
|
||||
// Stop if a per-item failure already surfaced an error
|
||||
if (error) break;
|
||||
await remove(id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Keyboard: pressing / focuses search
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById("stop-product-search") as HTMLInputElement | null;
|
||||
el?.focus();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const hasAssigned = products.length > 0;
|
||||
const hasProducts = allProducts.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="ha-picker">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{
|
||||
background: "rgba(220, 38, 38, 0.06)",
|
||||
border: "1px solid rgba(220, 38, 38, 0.15)",
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="font-mono text-[11px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-300">
|
||||
Assigned Products ({products.length})
|
||||
{/* Header: editorial title + search */}
|
||||
<div className="ha-picker-header">
|
||||
<div className="ha-picker-title">
|
||||
<span className="ha-picker-title-num font-display">
|
||||
{String(products.length).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="ha-picker-title-label">
|
||||
{products.length === 1 ? "Product on this stop" : "Products on this stop"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ha-picker-search">
|
||||
<span className="ha-picker-search-icon" aria-hidden="true">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
id="stop-product-search"
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search products…"
|
||||
className="ha-picker-search-input"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
<div className="ha-picker-filterbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("all")}
|
||||
className={`ha-picker-chip ${filter === "all" ? "ha-picker-chip--active" : ""}`}
|
||||
>
|
||||
All
|
||||
<span className="ha-picker-chip-count">{counts.all}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("available")}
|
||||
className={`ha-picker-chip ${filter === "available" ? "ha-picker-chip--active" : ""}`}
|
||||
>
|
||||
Available
|
||||
<span className="ha-picker-chip-count">{counts.available}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("assigned")}
|
||||
className={`ha-picker-chip ${filter === "assigned" ? "ha-picker-chip--active" : ""}`}
|
||||
>
|
||||
On this stop
|
||||
<span className="ha-picker-chip-count">{counts.assigned}</span>
|
||||
</button>
|
||||
<span className="ha-modal-footer-hint ml-auto">
|
||||
Press <kbd>/</kbd> to search
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Product grid */}
|
||||
{!hasProducts ? (
|
||||
<div className="ha-picker-empty">
|
||||
<p className="ha-picker-empty-mark">No products yet</p>
|
||||
<p className="ha-picker-empty-text">
|
||||
Create products in the catalog first, then come back here to assign them to this stop.
|
||||
</p>
|
||||
</div>
|
||||
) : visibleProducts.length === 0 ? (
|
||||
<div className="ha-picker-empty">
|
||||
<p className="ha-picker-empty-mark">
|
||||
{filter === "assigned" ? "Nothing assigned" : "No matches"}
|
||||
</p>
|
||||
<p className="ha-picker-empty-text">
|
||||
{search.trim()
|
||||
? `No products match "${search.trim()}". Try a different term.`
|
||||
: filter === "assigned"
|
||||
? "This stop has no products assigned yet. Click a card in the All tab to add one."
|
||||
: "All products are already assigned to this stop."}
|
||||
</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>
|
||||
) : (
|
||||
<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 && (
|
||||
<form onSubmit={handleAssign} className="flex gap-3">
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">Select a product...</option>
|
||||
{availableProducts.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Summary footer when assigned items exist */}
|
||||
{hasAssigned && (
|
||||
<div className="ha-picker-summary">
|
||||
<span className="ha-picker-summary-left">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>
|
||||
<span className="ha-picker-summary-count">{products.length}</span>{" "}
|
||||
{products.length === 1 ? "product is" : "products are"} live at this stop
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selected || assigning}
|
||||
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
disabled={!!removingId}
|
||||
className="ha-picker-summary-clear"
|
||||
>
|
||||
{assigning ? "..." : "Assign"}
|
||||
Remove all
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
"use client";
|
||||
/* 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 { useRouter } from "next/navigation";
|
||||
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 = {
|
||||
id: string;
|
||||
@@ -18,14 +28,27 @@ type Stop = {
|
||||
deleted_at?: string | null;
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
brands: { name: string } | { name: string }[];
|
||||
address?: string | null;
|
||||
brands: { name: string } | { name: string }[] | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
/**
|
||||
* Hide the internal search/filter bar at the top of the table. Use when
|
||||
* the parent component already renders shared filters (e.g. StopsViewClient).
|
||||
*/
|
||||
hideInternalFilterBar?: boolean;
|
||||
};
|
||||
|
||||
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 { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
@@ -36,57 +59,66 @@ export default function StopTableClient({ stops }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
|
||||
const [bulkPublishing, setBulkPublishing] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [openStopId, setOpenStopId] = useState<string | null>(null);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// Simulate loading when filters change
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const timer = setTimeout(() => setIsLoading(false), 300);
|
||||
const timer = setTimeout(() => setIsLoading(false), 220);
|
||||
return () => clearTimeout(timer);
|
||||
}, [page, statusFilter, search]);
|
||||
|
||||
const filtered = stops.filter((s) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
s.city.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.location.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && s.active && s.status !== "draft") ||
|
||||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
|
||||
(statusFilter === "draft" && s.status === "draft");
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return stops.filter((s) => {
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
s.city.toLowerCase().includes(q) ||
|
||||
s.location.toLowerCase().includes(q);
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && s.active && s.status !== "draft") ||
|
||||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
|
||||
(statusFilter === "draft" && s.status === "draft");
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [stops, search, statusFilter]);
|
||||
|
||||
const statusCounts = {
|
||||
all: stops.length,
|
||||
active: stops.filter(s => s.active && s.status !== "draft").length,
|
||||
inactive: stops.filter(s => !s.active && s.status !== "draft").length,
|
||||
draft: stops.filter(s => s.status === "draft").length,
|
||||
};
|
||||
const statusCounts = useMemo(
|
||||
() => ({
|
||||
all: stops.length,
|
||||
active: stops.filter((s) => s.active && s.status !== "draft").length,
|
||||
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
|
||||
draft: stops.filter((s) => s.status === "draft").length,
|
||||
}),
|
||||
[stops]
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ value: "all", label: "All", count: statusCounts.all },
|
||||
{ value: "active", label: "Active", count: statusCounts.active },
|
||||
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
|
||||
{ value: "draft", label: "Draft", count: statusCounts.draft },
|
||||
];
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ value: "all", label: "All", count: statusCounts.all },
|
||||
{ value: "active", label: "Active", count: statusCounts.active },
|
||||
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
|
||||
{ value: "draft", label: "Draft", count: statusCounts.draft },
|
||||
],
|
||||
[statusCounts]
|
||||
);
|
||||
|
||||
const 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 draftStops = stops.filter(s => s.status === "draft" && s.active);
|
||||
|
||||
// Bulk selection
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedStops.size === paginatedStops.length) {
|
||||
setSelectedStops(new Set());
|
||||
} else {
|
||||
setSelectedStops(new Set(paginatedStops.map(s => s.id)));
|
||||
setSelectedStops(new Set(paginatedStops.map((s) => s.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStopSelection = (stopId: string) => {
|
||||
setSelectedStops(prev => {
|
||||
setSelectedStops((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(stopId)) {
|
||||
next.delete(stopId);
|
||||
@@ -99,13 +131,11 @@ export default function StopTableClient({ stops }: Props) {
|
||||
|
||||
async function handleBulkPublish() {
|
||||
if (selectedStops.size === 0) return;
|
||||
|
||||
setBulkPublishing(true);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const stopId of selectedStops) {
|
||||
const stop = stops.find(s => s.id === stopId);
|
||||
const stop = stops.find((s) => s.id === stopId);
|
||||
if (stop && stop.status === "draft") {
|
||||
const result = await publishStop(stopId, stop.brand_id);
|
||||
if (result.success) {
|
||||
@@ -115,83 +145,125 @@ export default function StopTableClient({ stops }: Props) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBulkPublishing(false);
|
||||
setSelectedStops(new Set());
|
||||
|
||||
if (failCount === 0) {
|
||||
showSuccess(`${successCount} stop${successCount !== 1 ? 's' : ''} published`);
|
||||
showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
|
||||
} else {
|
||||
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
|
||||
}
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
function handleDeleted() {
|
||||
setDeleteError(null);
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 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
|
||||
placeholder="Search stops..."
|
||||
placeholder="Search by city or venue…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
onClear={() => { setSearch(""); setPage(0); }}
|
||||
showClear={true}
|
||||
className="flex-1 min-w-48 max-w-64"
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
onClear={() => {
|
||||
setSearch("");
|
||||
setPage(0);
|
||||
}}
|
||||
showClear
|
||||
className="flex-1 min-w-[180px] max-w-[280px]"
|
||||
/>
|
||||
<AdminFilterTabs
|
||||
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}
|
||||
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 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Previous page"
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0 || isLoading}
|
||||
className="!rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)] px-1 tabular-nums">
|
||||
{page + 1}/{totalPages}
|
||||
</span>
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Next page"
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1 || isLoading}
|
||||
className="!rounded-lg border border-[var(--admin-border)]"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
</div>
|
||||
)}
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowImport(true)}
|
||||
icon={
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Upload Schedule
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowAdd(true)}
|
||||
icon={
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Add Stop
|
||||
</AdminButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedStops.size > 0 && (
|
||||
<div className="mx-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">
|
||||
<span className="text-sm font-semibold text-[var(--admin-accent-text)]">
|
||||
{selectedStops.size} stop{selectedStops.size !== 1 ? 's' : ''} selected
|
||||
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
|
||||
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -209,7 +281,7 @@ export default function StopTableClient({ stops }: Props) {
|
||||
|
||||
{/* 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)]">
|
||||
<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}{" "}
|
||||
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
|
||||
Dismiss
|
||||
@@ -219,45 +291,59 @@ export default function StopTableClient({ stops }: Props) {
|
||||
|
||||
{/* Table */}
|
||||
<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>
|
||||
<th className="w-10 px-5 py-4">
|
||||
<th className="w-10 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0}
|
||||
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 className="px-5 py-4 font-semibold">City</th>
|
||||
<th className="px-5 py-4 font-semibold">Location</th>
|
||||
<th className="px-5 py-4 font-semibold">Date</th>
|
||||
<th className="px-5 py-4 font-semibold">Time</th>
|
||||
<th className="px-5 py-4 font-semibold">Brand</th>
|
||||
<th className="px-5 py-4 font-semibold">Status</th>
|
||||
<th className="px-5 py-4 font-semibold" />
|
||||
<th className="px-4 py-3 font-semibold">When</th>
|
||||
<th className="px-4 py-3 font-semibold">Where</th>
|
||||
<th className="px-4 py-3 font-semibold">Venue</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
<th className="w-12 px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||
<td className="px-5 py-4"><Skeleton variant="rect" className="h-5 w-5" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-24 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-20 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="h-4 w-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-24 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-40 h-4" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-16 h-5 rounded-full" /></td>
|
||||
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-6 h-6" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
|
||||
{search || statusFilter !== "all"
|
||||
? "No stops match your search."
|
||||
: "No stops found. Create one to get started."}
|
||||
<td colSpan={6} className="px-4 py-14 text-center">
|
||||
<div className="flex flex-col items-center gap-3 text-[var(--admin-text-muted)]">
|
||||
<div className="h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-700">
|
||||
{search || statusFilter !== "all"
|
||||
? "No stops match your filters"
|
||||
: "No stops yet"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-stone-500">
|
||||
{search || statusFilter !== "all"
|
||||
? "Try a different search or clear the filters above."
|
||||
: "Use the buttons above to upload a schedule or add your first stop."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -268,52 +354,101 @@ export default function StopTableClient({ stops }: Props) {
|
||||
onDeleted={handleDeleted}
|
||||
onDeleteError={setDeleteError}
|
||||
isSelected={selectedStops.has(stop.id)}
|
||||
onToggleSelect={() => toggleStopSelection(stop.id)}
|
||||
onToggleSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleStopSelection(stop.id);
|
||||
}}
|
||||
onOpen={() => setOpenStopId(stop.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{showImport && (
|
||||
<ScheduleImportModal
|
||||
brandId={stops[0]?.brand_id ?? ""}
|
||||
onClose={() => setShowImport(false)}
|
||||
onComplete={refresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddStopModal
|
||||
isOpen={showAdd}
|
||||
onClose={() => setShowAdd(false)}
|
||||
brandId={stops[0]?.brand_id ?? ""}
|
||||
onSuccess={refresh}
|
||||
/>
|
||||
|
||||
{openStopId && (
|
||||
<StopDetailModal
|
||||
stopId={openStopId}
|
||||
onClose={() => setOpenStopId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): { date: string; weekday: string } {
|
||||
// Stop date is stored as YYYY-MM-DD; parse without TZ shift
|
||||
const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10));
|
||||
if (!y || !m || !d) return { date: iso, weekday: "" };
|
||||
const dt = new Date(y, m - 1, d);
|
||||
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
|
||||
const month = dt.toLocaleDateString("en-US", { month: "short" });
|
||||
return { date: `${month} ${d}`, weekday };
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
// Stored as HH:MM (24h). Display as 10:00 AM.
|
||||
if (!t) return "";
|
||||
const [hStr, mStr] = t.split(":");
|
||||
const h = parseInt(hStr, 10);
|
||||
if (Number.isNaN(h)) return t;
|
||||
const period = h >= 12 ? "PM" : "AM";
|
||||
const hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `${hh}:${mStr} ${period}`;
|
||||
}
|
||||
|
||||
function StopRowBase({
|
||||
stop,
|
||||
onDeleted,
|
||||
onDeleteError,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
onOpen,
|
||||
}: {
|
||||
stop: Stop;
|
||||
onDeleted: () => void;
|
||||
onDeleteError: (msg: string) => void;
|
||||
isSelected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
onToggleSelect: (e: React.MouseEvent) => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
|
||||
async function handlePublish(stopId: string) {
|
||||
setPublishingId(stopId);
|
||||
setOpenMenu(null);
|
||||
const result = await publishStop(stopId, stop.brand_id);
|
||||
const { date, weekday } = formatDate(stop.date);
|
||||
const time = formatTime(stop.time);
|
||||
|
||||
async function handlePublish() {
|
||||
setPublishingId(stop.id);
|
||||
const result = await publishStop(stop.id, stop.brand_id);
|
||||
setPublishingId(null);
|
||||
if (result.success) {
|
||||
showSuccess("Stop published", "The stop is now visible on the storefront");
|
||||
startTransition(() => router.refresh());
|
||||
onDeleted();
|
||||
} else {
|
||||
showError("Failed to publish", result.error ?? "Please try again");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(stopId: string) {
|
||||
setDeletingId(stopId);
|
||||
async function handleDelete() {
|
||||
setDeletingId(stop.id);
|
||||
const res = await fetch(
|
||||
`${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!,
|
||||
"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();
|
||||
@@ -338,69 +473,88 @@ function StopRowBase({
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className={`hover:bg-[var(--admin-bg-subtle)] transition-colors relative ${isSelected ? 'bg-[var(--admin-accent-light)]/30' : ''}`}>
|
||||
<td className="px-5 py-4">
|
||||
<tr
|
||||
onClick={onOpen}
|
||||
className={`group cursor-pointer transition-colors ${isSelected ? "bg-emerald-50/60" : "hover:bg-[var(--admin-bg-subtle)]/60"}`}
|
||||
>
|
||||
<td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={onToggleSelect}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
onChange={() => {}}
|
||||
onClick={onToggleSelect}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
|
||||
aria-label={`Select stop in ${stop.city}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] transition-colors"
|
||||
>
|
||||
{stop.city}, {stop.state}
|
||||
</Link>
|
||||
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)] tabular-nums">{date}</span>
|
||||
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">
|
||||
{weekday} {time && `· ${time}`}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">{stop.location}</td>
|
||||
|
||||
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.date}</td>
|
||||
|
||||
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.time}</td>
|
||||
|
||||
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">
|
||||
{Array.isArray(stop.brands)
|
||||
? stop.brands[0]?.name
|
||||
: stop.brands?.name}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{stop.city}</span>
|
||||
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">{stop.state}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<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
|
||||
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"
|
||||
? "bg-amber-100 text-amber-700 border border-amber-200"
|
||||
? "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
: stop.active
|
||||
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]"
|
||||
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
|
||||
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
: "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"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="relative inline-flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<td className="px-4 py-3.5 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="relative inline-flex items-center justify-end gap-1">
|
||||
<AdminIconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="More options"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenMenu(openMenu === stop.id ? null : stop.id);
|
||||
}}
|
||||
className="opacity-60 group-hover:opacity-100"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
|
||||
<circle cx="10" cy="4" r="1.5" />
|
||||
<circle cx="10" cy="10" r="1.5" />
|
||||
<circle cx="10" cy="16" r="1.5" />
|
||||
</svg>
|
||||
</AdminIconButton>
|
||||
|
||||
@@ -408,36 +562,42 @@ function StopRowBase({
|
||||
<>
|
||||
<div
|
||||
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">
|
||||
{stop.status === "draft" && (
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={() => handlePublish(stop.id)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePublish();
|
||||
}}
|
||||
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"}
|
||||
</AdminButton>
|
||||
{publishingId === stop.id ? "Publishing…" : "Publish"}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
<Link
|
||||
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
|
||||
</a>
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
|
||||
className="!justify-start !text-[var(--admin-danger)] hover:!bg-[var(--admin-danger)]/10"
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenMenu(null);
|
||||
setConfirmDelete(stop.id);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</AdminButton>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -446,7 +606,11 @@ function StopRowBase({
|
||||
<>
|
||||
<div
|
||||
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">
|
||||
<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)]">
|
||||
This will remove the stop. If it has active orders, you must resolve those first.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(stop.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
disabled={deletingId === stop.id}
|
||||
isLoading={deletingId === stop.id}
|
||||
>
|
||||
{deletingId === stop.id ? "..." : "Delete"}
|
||||
{deletingId === stop.id ? "Deleting…" : "Delete"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -482,4 +653,4 @@ function StopRowBase({
|
||||
);
|
||||
}
|
||||
|
||||
const StopRow = React.memo(StopRowBase);
|
||||
const StopRow = React.memo(StopRowBase);
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect, useRef, useCallback, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { publishStop } from "@/actions/stops";
|
||||
import { useToast } from "@/components/admin/design-system";
|
||||
import type { StopForView } from "./StopsViewClient";
|
||||
|
||||
type Props = {
|
||||
stops: StopForView[];
|
||||
};
|
||||
|
||||
/* ================================================================== */
|
||||
/* Date helpers — work with YYYY-MM-DD strings to avoid TZ drift */
|
||||
/* ================================================================== */
|
||||
|
||||
function ymd(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function parseYmd(s: string): Date | null {
|
||||
// Treat YYYY-MM-DD as a local date (no UTC shift)
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||
if (!m) return null;
|
||||
const [, y, mo, d] = m;
|
||||
return new Date(Number(y), Number(mo) - 1, Number(d));
|
||||
}
|
||||
|
||||
function todayYmd(): string {
|
||||
return ymd(new Date());
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
const MONTH_NAMES_ITALIC = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
function buildMonthGrid(month: Date): Date[] {
|
||||
// Start at the Sunday on or before the 1st of the month
|
||||
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const startDayOfWeek = first.getDay(); // 0 = Sun
|
||||
const start = new Date(first);
|
||||
start.setDate(1 - startDayOfWeek);
|
||||
|
||||
// 6 weeks = 42 cells
|
||||
const cells: Date[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(start.getDate() + i);
|
||||
cells.push(d);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function statusOf(stop: StopForView): "active" | "draft" | "inactive" {
|
||||
if (stop.status === "draft") return "draft";
|
||||
if (stop.active) return "active";
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
function statusLabel(s: "active" | "draft" | "inactive"): string {
|
||||
return s === "draft" ? "Draft" : s === "active" ? "Active" : "Inactive";
|
||||
}
|
||||
|
||||
function brandName(s: StopForView): string {
|
||||
return Array.isArray(s.brands) ? s.brands[0]?.name ?? "—" : s.brands?.name ?? "—";
|
||||
}
|
||||
|
||||
function formatTime12(t: string | null | undefined): string {
|
||||
if (!t) return "—";
|
||||
// t is "HH:MM" or "HH:MM:SS"
|
||||
const [hStr = "0", mStr = "00"] = t.split(":");
|
||||
const h = Number(hStr);
|
||||
const ampm = h >= 12 ? "pm" : "am";
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `${h12}:${mStr} ${ampm}`;
|
||||
}
|
||||
|
||||
function formatTimeCompact(t: string | null | undefined): string {
|
||||
if (!t) return "—";
|
||||
const [hStr = "0", mStr = "00"] = t.split(":");
|
||||
const h = Number(hStr);
|
||||
const ampm = h >= 12 ? "p" : "a";
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `${h12}:${mStr}${ampm}`;
|
||||
}
|
||||
|
||||
function formatDateLong(d: Date): string {
|
||||
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
|
||||
const month = MONTH_NAMES[d.getMonth()];
|
||||
return `${weekday}, ${month} ${d.getDate()}`;
|
||||
}
|
||||
|
||||
function formatDateLongSpelled(d: Date): string {
|
||||
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
|
||||
const month = MONTH_NAMES[d.getMonth()];
|
||||
return `${weekday}, ${month} ${d.getDate()}, ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Component */
|
||||
/* ================================================================== */
|
||||
|
||||
const MAX_EVENTS_PER_CELL = 3;
|
||||
|
||||
export default function StopsCalendarClient({ stops }: Props) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
const today = new Date();
|
||||
return new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
});
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [activePopover, setActivePopover] = useState<{
|
||||
stopId: string;
|
||||
cellRect: DOMRect;
|
||||
} | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
|
||||
// Bucket stops by YYYY-MM-DD
|
||||
const stopsByDate = useMemo(() => {
|
||||
const map = new Map<string, StopForView[]>();
|
||||
for (const s of stops) {
|
||||
if (!s.date) continue;
|
||||
const list = map.get(s.date) ?? [];
|
||||
list.push(s);
|
||||
map.set(s.date, list);
|
||||
}
|
||||
// Sort each day by time
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
|
||||
}
|
||||
return map;
|
||||
}, [stops]);
|
||||
|
||||
const todayStr = useMemo(() => todayYmd(), []);
|
||||
|
||||
const monthCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
|
||||
|
||||
// Earliest/latest stop dates — used to enable/disable nav
|
||||
const dateRange = useMemo(() => {
|
||||
if (stops.length === 0) return null;
|
||||
const dates = stops.map((s) => s.date).filter(Boolean).sort();
|
||||
return { min: dates[0], max: dates[dates.length - 1] };
|
||||
}, [stops]);
|
||||
|
||||
const isFirstMonth = useMemo(() => {
|
||||
if (!dateRange?.min) return false;
|
||||
const minDate = parseYmd(dateRange.min);
|
||||
if (!minDate) return false;
|
||||
return viewMonth.getFullYear() === minDate.getFullYear() && viewMonth.getMonth() === minDate.getMonth();
|
||||
}, [dateRange, viewMonth]);
|
||||
|
||||
const isLastMonth = useMemo(() => {
|
||||
if (!dateRange?.max) return false;
|
||||
const maxDate = parseYmd(dateRange.max);
|
||||
if (!maxDate) return false;
|
||||
return viewMonth.getFullYear() === maxDate.getFullYear() && viewMonth.getMonth() === maxDate.getMonth();
|
||||
}, [dateRange, viewMonth]);
|
||||
|
||||
function goPrevMonth() {
|
||||
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1));
|
||||
}
|
||||
function goNextMonth() {
|
||||
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1));
|
||||
}
|
||||
function goToday() {
|
||||
const today = new Date();
|
||||
setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
setSelectedDate(todayYmd());
|
||||
}
|
||||
|
||||
// Close popover on outside click / Escape
|
||||
useEffect(() => {
|
||||
if (!activePopover) return;
|
||||
function onDown(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("[data-event-popover]") || target.closest("[data-event-chip]")) return;
|
||||
setActivePopover(null);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setActivePopover(null);
|
||||
}
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [activePopover]);
|
||||
|
||||
// Close drawer on Escape
|
||||
useEffect(() => {
|
||||
if (!selectedDate) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setSelectedDate(null);
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [selectedDate]);
|
||||
|
||||
// Lock body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
const openStop = useCallback((stop: StopForView, anchorRect: DOMRect) => {
|
||||
setActivePopover({ stopId: stop.id, cellRect: anchorRect });
|
||||
}, []);
|
||||
|
||||
const handlePublish = useCallback(
|
||||
async (stop: StopForView) => {
|
||||
setActivePopover(null);
|
||||
setPublishingId(stop.id);
|
||||
const result = await publishStop(stop.id, stop.brand_id);
|
||||
setPublishingId(null);
|
||||
if (result.success) {
|
||||
showSuccess("Stop published", `${stop.city}, ${stop.state} is now visible to customers`);
|
||||
startTransition(() => router.refresh());
|
||||
} else {
|
||||
showError("Failed to publish", result.error ?? "Please try again");
|
||||
}
|
||||
},
|
||||
[router, showSuccess, showError]
|
||||
);
|
||||
|
||||
const activeStop = useMemo(
|
||||
() => (activePopover ? stops.find((s) => s.id === activePopover.stopId) ?? null : null),
|
||||
[activePopover, stops]
|
||||
);
|
||||
|
||||
const selectedDayStops = useMemo(() => {
|
||||
if (!selectedDate) return [];
|
||||
return stopsByDate.get(selectedDate) ?? [];
|
||||
}, [selectedDate, stopsByDate]);
|
||||
|
||||
const selectedDayDate = useMemo(() => (selectedDate ? parseYmd(selectedDate) : null), [selectedDate]);
|
||||
|
||||
// Compute popover position with viewport edge detection
|
||||
const popoverStyle = useMemo<React.CSSProperties | null>(() => {
|
||||
if (!activePopover) return null;
|
||||
const POPOVER_W = 288; // 18rem
|
||||
const MARGIN = 8;
|
||||
const rect = activePopover.cellRect;
|
||||
const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
|
||||
// Default: place below the chip, left-aligned
|
||||
let left = rect.left + rect.width / 2 - POPOVER_W / 2;
|
||||
const top = rect.bottom + MARGIN;
|
||||
if (left + POPOVER_W > vw - 8) left = vw - POPOVER_W - 8;
|
||||
if (left < 8) left = 8;
|
||||
return { left, top };
|
||||
}, [activePopover]);
|
||||
|
||||
const popoverArrowStyle = useMemo<React.CSSProperties | null>(() => {
|
||||
if (!activePopover || !popoverStyle) return null;
|
||||
const rect = activePopover.cellRect;
|
||||
const arrowLeft = rect.left + rect.width / 2 - (popoverStyle.left as number) - 5;
|
||||
return { left: Math.max(8, Math.min(arrowLeft, 270)) };
|
||||
}, [activePopover, popoverStyle]);
|
||||
|
||||
// === Render ===
|
||||
return (
|
||||
<>
|
||||
<div className="ha-calendar">
|
||||
{/* Header */}
|
||||
<div className="ha-calendar-header">
|
||||
<div className="ha-calendar-title-block">
|
||||
<span className="ha-calendar-eyebrow">
|
||||
Tour Almanac · {stops.length} {stops.length === 1 ? "stop" : "stops"} on file
|
||||
</span>
|
||||
<h2 className="ha-calendar-title">
|
||||
<span className="ha-calendar-title-italic">{MONTH_NAMES_ITALIC[viewMonth.getMonth()]}</span>
|
||||
<span className="ha-calendar-title-year">{viewMonth.getFullYear()}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="ha-calendar-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="ha-calendar-nav-btn"
|
||||
onClick={goPrevMonth}
|
||||
disabled={isFirstMonth}
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" className="ha-calendar-today" onClick={goToday}>
|
||||
<span className="ha-calendar-today-dot" style={{ background: "currentColor" }} />
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ha-calendar-nav-btn"
|
||||
onClick={goNextMonth}
|
||||
disabled={isLastMonth}
|
||||
aria-label="Next month"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DOW header */}
|
||||
<div className="ha-calendar-dow" role="row">
|
||||
{DOW_LABELS.map((label) => (
|
||||
<div key={label} className="ha-calendar-dow-cell" role="columnheader">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="ha-calendar-grid" role="grid">
|
||||
{monthCells.map((d) => {
|
||||
const key = ymd(d);
|
||||
const dayStops = stopsByDate.get(key) ?? [];
|
||||
const isOut = d.getMonth() !== viewMonth.getMonth();
|
||||
const isToday = key === todayStr;
|
||||
const hasStops = dayStops.length > 0;
|
||||
const dow = d.getDay();
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
const isSelected = selectedDate === key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="gridcell"
|
||||
tabIndex={hasStops ? 0 : -1}
|
||||
onClick={() => hasStops && setSelectedDate(key)}
|
||||
onKeyDown={(e) => {
|
||||
if (hasStops && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
setSelectedDate(key);
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
"ha-calendar-cell",
|
||||
isOut ? "ha-calendar-cell--out" : "",
|
||||
isWeekend ? "ha-calendar-cell--weekend" : "",
|
||||
hasStops ? "ha-calendar-cell--has-stops" : "",
|
||||
isToday ? "ha-calendar-cell--today" : "",
|
||||
isSelected ? "ha-calendar-cell--selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-label={hasStops ? `${formatDateLong(d)} — ${dayStops.length} stop${dayStops.length !== 1 ? "s" : ""}` : formatDateLong(d)}
|
||||
>
|
||||
<div className="ha-calendar-daynum">
|
||||
<span>{d.getDate()}</span>
|
||||
{isToday && <span className="ha-calendar-today-dot" />}
|
||||
</div>
|
||||
<div className="ha-calendar-events">
|
||||
{dayStops.slice(0, MAX_EVENTS_PER_CELL).map((stop) => (
|
||||
<EventChip
|
||||
key={stop.id}
|
||||
stop={stop}
|
||||
isPublishing={publishingId === stop.id}
|
||||
onOpen={(rect) => openStop(stop, rect)}
|
||||
isActive={activePopover?.stopId === stop.id}
|
||||
/>
|
||||
))}
|
||||
{dayStops.length > MAX_EVENTS_PER_CELL && (
|
||||
<button
|
||||
type="button"
|
||||
className="ha-calendar-event-more"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedDate(key);
|
||||
}}
|
||||
>
|
||||
+{dayStops.length - MAX_EVENTS_PER_CELL} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="ha-calendar-legend">
|
||||
<span className="ha-calendar-legend-item">
|
||||
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-accent)" }} />
|
||||
Active
|
||||
</span>
|
||||
<span className="ha-calendar-legend-item">
|
||||
<span className="ha-calendar-legend-swatch" style={{ background: "#f59e0b" }} />
|
||||
Draft
|
||||
</span>
|
||||
<span className="ha-calendar-legend-item">
|
||||
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-text-muted)" }} />
|
||||
Inactive
|
||||
</span>
|
||||
<span style={{ marginLeft: "auto" }} className="hidden sm:inline">
|
||||
Tip — click any day with stops to see the full route · click an event for details
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event popover */}
|
||||
{activeStop && activePopover && popoverStyle && (
|
||||
<EventPopover
|
||||
stop={activeStop}
|
||||
isPublishing={publishingId === activeStop.id}
|
||||
onPublish={() => handlePublish(activeStop)}
|
||||
onOpenRoute={() => {
|
||||
setActivePopover(null);
|
||||
setSelectedDate(activeStop.date);
|
||||
}}
|
||||
style={popoverStyle}
|
||||
arrowStyle={popoverArrowStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Day-route drawer */}
|
||||
{selectedDate && selectedDayDate && (
|
||||
<DayRouteDrawer
|
||||
date={selectedDayDate}
|
||||
dateStr={selectedDate}
|
||||
stops={selectedDayStops}
|
||||
onClose={() => setSelectedDate(null)}
|
||||
onPublish={handlePublish}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* EventChip — single stop on a day cell */
|
||||
/* ================================================================== */
|
||||
|
||||
function EventChip({
|
||||
stop,
|
||||
onOpen,
|
||||
isActive,
|
||||
isPublishing,
|
||||
}: {
|
||||
stop: StopForView;
|
||||
onOpen: (rect: DOMRect) => void;
|
||||
isActive: boolean;
|
||||
isPublishing: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const status = statusOf(stop);
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-event-chip
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isPublishing && ref.current) onOpen(ref.current.getBoundingClientRect());
|
||||
}}
|
||||
className={`ha-calendar-event ha-calendar-event--${status} ${isActive ? "!bg-[var(--admin-accent)]/20" : ""}`}
|
||||
title={`${stop.city}, ${stop.state} — ${stop.location}`}
|
||||
>
|
||||
<span className="ha-calendar-event-time">{formatTimeCompact(stop.time)}</span>
|
||||
<span className="ha-calendar-event-text">{stop.city}, {stop.state}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* EventPopover — floating detail card */
|
||||
/* ================================================================== */
|
||||
|
||||
function EventPopover({
|
||||
stop,
|
||||
isPublishing,
|
||||
onPublish,
|
||||
onOpenRoute,
|
||||
style,
|
||||
arrowStyle,
|
||||
}: {
|
||||
stop: StopForView;
|
||||
isPublishing: boolean;
|
||||
onPublish: () => void;
|
||||
onOpenRoute: () => void;
|
||||
style: React.CSSProperties;
|
||||
arrowStyle: React.CSSProperties | null;
|
||||
}) {
|
||||
const status = statusOf(stop);
|
||||
return (
|
||||
<div
|
||||
data-event-popover
|
||||
className="ha-event-popover"
|
||||
style={style}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{arrowStyle && <div className="ha-event-popover-arrow" style={{ ...arrowStyle, top: -5 }} />}
|
||||
<div className="ha-event-popover-eyebrow">
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
status === "active" ? "var(--admin-accent)" : status === "draft" ? "#f59e0b" : "var(--admin-text-muted)",
|
||||
}}
|
||||
/>
|
||||
{statusLabel(status)} · {brandName(stop)}
|
||||
</div>
|
||||
<h3 className="ha-event-popover-title">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="ha-event-popover-location">{stop.location}</p>
|
||||
|
||||
<div className="ha-event-popover-grid">
|
||||
<div className="ha-event-popover-field">
|
||||
<span className="ha-event-popover-field-label">Pickup</span>
|
||||
<span className="ha-event-popover-field-value">{formatTime12(stop.time)}</span>
|
||||
</div>
|
||||
<div className="ha-event-popover-field">
|
||||
<span className="ha-event-popover-field-label">Cutoff</span>
|
||||
<span className="ha-event-popover-field-value">{stop.cutoff_time ? formatTime12(stop.cutoff_time) : "—"}</span>
|
||||
</div>
|
||||
{stop.address && (
|
||||
<div className="ha-event-popover-field" style={{ gridColumn: "1 / -1" }}>
|
||||
<span className="ha-event-popover-field-label">Address</span>
|
||||
<span className="ha-event-popover-field-value" style={{ fontFamily: "var(--font-geist), sans-serif", fontSize: "0.75rem" }}>
|
||||
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ha-event-popover-actions">
|
||||
{status === "draft" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPublish}
|
||||
disabled={isPublishing}
|
||||
className="ha-event-popover-btn ha-event-popover-btn--primary"
|
||||
>
|
||||
{isPublishing ? "Publishing…" : "Publish"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenRoute}
|
||||
className="ha-event-popover-btn"
|
||||
>
|
||||
View route
|
||||
</button>
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="ha-event-popover-btn ha-event-popover-btn--primary"
|
||||
>
|
||||
Edit
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* DayRouteDrawer — full day's route on the side */
|
||||
/* ================================================================== */
|
||||
|
||||
function DayRouteDrawer({
|
||||
date,
|
||||
dateStr,
|
||||
stops,
|
||||
onClose,
|
||||
onPublish,
|
||||
}: {
|
||||
date: Date;
|
||||
dateStr: string;
|
||||
stops: StopForView[];
|
||||
onClose: () => void;
|
||||
onPublish: (stop: StopForView) => void;
|
||||
}) {
|
||||
const sorted = useMemo(() => [...stops].sort((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
|
||||
const counts = useMemo(() => {
|
||||
const active = sorted.filter((s) => s.status !== "draft" && s.active).length;
|
||||
const draft = sorted.filter((s) => s.status === "draft").length;
|
||||
const total = sorted.length;
|
||||
return { active, draft, total };
|
||||
}, [sorted]);
|
||||
const brands = useMemo(() => Array.from(new Set(sorted.map(brandName))), [sorted]);
|
||||
const routeNumber = useMemo(() => {
|
||||
// Editorial "Route 03" — count of stops with status badge
|
||||
return String(sorted.length).padStart(2, "0");
|
||||
}, [sorted.length]);
|
||||
|
||||
const earliest = sorted[0]?.time;
|
||||
const latest = sorted[sorted.length - 1]?.time;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ha-drawer-backdrop" onClick={onClose} aria-hidden="true" />
|
||||
<aside className="ha-drawer" role="dialog" aria-label={`Route for ${formatDateLongSpelled(date)}`}>
|
||||
<header className="ha-drawer-header">
|
||||
<div>
|
||||
<div className="ha-drawer-eyebrow">Route {routeNumber} · {dateStr}</div>
|
||||
<h2 className="ha-drawer-title">{formatDateLongSpelled(date)}</h2>
|
||||
<p className="ha-drawer-subtitle">
|
||||
{sorted.length} {sorted.length === 1 ? "stop" : "stops"} on the route
|
||||
{earliest && latest && sorted.length > 1 && (
|
||||
<> · {formatTime12(earliest)} – {formatTime12(latest)}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="ha-drawer-close" aria-label="Close">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="ha-drawer-body">
|
||||
{sorted.length === 0 ? (
|
||||
<div className="ha-drawer-empty">
|
||||
<p className="ha-drawer-empty-mark">No route on this day</p>
|
||||
<p className="ha-drawer-empty-text">
|
||||
No stops are scheduled for {formatDateLongSpelled(date)}.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<div className="ha-route-summary">
|
||||
<div className="ha-route-summary-cell">
|
||||
<span className="ha-route-summary-num">{counts.total}</span>
|
||||
<span className="ha-route-summary-label">Stops</span>
|
||||
</div>
|
||||
<div className="ha-route-summary-cell">
|
||||
<span className="ha-route-summary-num">{counts.active}</span>
|
||||
<span className="ha-route-summary-label">Live</span>
|
||||
</div>
|
||||
<div className="ha-route-summary-cell">
|
||||
<span className="ha-route-summary-num">{brands.length}</span>
|
||||
<span className="ha-route-summary-label">Brands</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route spine */}
|
||||
<div className="ha-route-list">
|
||||
{sorted.map((stop, idx) => {
|
||||
const status = statusOf(stop);
|
||||
return (
|
||||
<article key={stop.id} className="ha-route-stop">
|
||||
<div className="ha-route-stop-spine">
|
||||
<div className={`ha-route-stop-marker ha-route-stop-marker--${status}`}>
|
||||
{String(idx + 1).padStart(2, "0")}
|
||||
</div>
|
||||
<div className="ha-route-stop-line" />
|
||||
</div>
|
||||
<div className="ha-route-stop-content">
|
||||
<div className="ha-route-stop-time">{formatTime12(stop.time)}</div>
|
||||
<h3 className="ha-route-stop-name">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="ha-route-stop-location">{stop.location}</p>
|
||||
<div className="ha-route-stop-meta">
|
||||
<span className="ha-route-stop-meta-pill">{brandName(stop)}</span>
|
||||
<span
|
||||
className="ha-route-stop-meta-pill"
|
||||
style={{
|
||||
background:
|
||||
status === "active"
|
||||
? "var(--admin-accent-light)"
|
||||
: status === "draft"
|
||||
? "rgba(245, 158, 11, 0.1)"
|
||||
: "var(--admin-bg-subtle)",
|
||||
color:
|
||||
status === "active"
|
||||
? "var(--admin-accent-text)"
|
||||
: status === "draft"
|
||||
? "#92400e"
|
||||
: "var(--admin-text-secondary)",
|
||||
borderColor:
|
||||
status === "active"
|
||||
? "var(--admin-accent)"
|
||||
: status === "draft"
|
||||
? "rgba(245, 158, 11, 0.3)"
|
||||
: "var(--admin-border-light)",
|
||||
}}
|
||||
>
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
{stop.cutoff_time && (
|
||||
<span className="ha-route-stop-meta-pill">
|
||||
Cutoff {formatTime12(stop.cutoff_time)}
|
||||
</span>
|
||||
)}
|
||||
{stop.address && (
|
||||
<span className="ha-route-stop-meta-pill">
|
||||
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ha-route-stop-actions">
|
||||
{status === "draft" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPublish(stop)}
|
||||
className="ha-route-stop-action ha-route-stop-action--primary"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="ha-route-stop-action ha-route-stop-action--primary"
|
||||
>
|
||||
Edit
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||
className="ha-route-stop-action"
|
||||
>
|
||||
Duplicate
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,13 +8,17 @@ import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
/** Hide on the Locations tab — actions belong to the Stops tab only. */
|
||||
tab?: "stops" | "locations";
|
||||
};
|
||||
|
||||
export default function StopsHeaderActions({ brandId }: Props) {
|
||||
export default function StopsHeaderActions({ brandId, tab = "stops" }: Props) {
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
if (tab !== "stops") return null;
|
||||
|
||||
function handleImportComplete(count: number) {
|
||||
router.refresh();
|
||||
}
|
||||
@@ -66,4 +70,4 @@ export default function StopsHeaderActions({ brandId }: Props) {
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
active: "stops" | "locations";
|
||||
stopCount: number;
|
||||
locationCount: number;
|
||||
stopsSublabel?: string; // e.g. "269 · 7 cities"
|
||||
locationsSublabel?: string; // e.g. "41 · 8 cities"
|
||||
};
|
||||
|
||||
const StopIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PinIcon = () => (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function StopsLocationsTabs({
|
||||
active,
|
||||
stopCount,
|
||||
locationCount,
|
||||
stopsSublabel,
|
||||
locationsSublabel,
|
||||
}: Props) {
|
||||
const tabs = [
|
||||
{
|
||||
value: "stops" as const,
|
||||
label: "Stops",
|
||||
count: stopCount,
|
||||
sublabel: stopsSublabel,
|
||||
icon: <StopIcon />,
|
||||
href: "/admin/stops",
|
||||
},
|
||||
{
|
||||
value: "locations" as const,
|
||||
label: "Locations",
|
||||
count: locationCount,
|
||||
sublabel: locationsSublabel,
|
||||
icon: <PinIcon />,
|
||||
href: "/admin/stops?tab=locations",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-stretch gap-1.5 px-3 py-2.5 border-b border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]"
|
||||
role="tablist"
|
||||
aria-label="Stops and Locations tabs"
|
||||
>
|
||||
{tabs.map((t) => {
|
||||
const isActive = t.value === active;
|
||||
return (
|
||||
<Link
|
||||
key={t.value}
|
||||
href={t.href}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={`
|
||||
group relative flex items-center gap-2.5 rounded-xl px-3.5 py-2
|
||||
text-sm font-semibold transition-all duration-200
|
||||
${isActive
|
||||
? "bg-white text-emerald-700 border border-emerald-200 shadow-sm"
|
||||
: "text-stone-600 border border-transparent hover:text-emerald-700 hover:bg-emerald-50/40"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`flex-shrink-0 transition-colors ${isActive ? "text-emerald-600" : "text-stone-400 group-hover:text-emerald-500"}`}
|
||||
aria-hidden
|
||||
>
|
||||
{t.icon}
|
||||
</span>
|
||||
<span>{t.label}</span>
|
||||
<span
|
||||
className={`
|
||||
inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5
|
||||
text-[11px] font-bold tabular-nums
|
||||
${isActive
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-stone-200/70 text-stone-600 group-hover:bg-emerald-100 group-hover:text-emerald-700"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{t.count}
|
||||
</span>
|
||||
{t.sublabel && (
|
||||
<span
|
||||
className={`hidden sm:inline text-[11px] font-medium tabular-nums ${isActive ? "text-stone-500" : "text-stone-400"}`}
|
||||
>
|
||||
· {t.sublabel}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import StopTableClient from "@/components/admin/StopTableClient";
|
||||
import StopsCalendarClient from "@/components/admin/StopsCalendarClient";
|
||||
import { AdminSearchInput, AdminFilterTabs } from "@/components/admin/design-system";
|
||||
|
||||
export type StopStatusFilter = "all" | "active" | "inactive" | "draft";
|
||||
export type StopsViewMode = "calendar" | "table";
|
||||
|
||||
export type StopForView = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
active: boolean;
|
||||
deleted_at?: string | null;
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
brands: { name: string } | { name: string }[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
stops: StopForView[];
|
||||
};
|
||||
|
||||
export default function StopsViewClient({ stops }: Props) {
|
||||
const [view, setView] = useState<StopsViewMode>("calendar");
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StopStatusFilter>("all");
|
||||
|
||||
// Apply shared filter so both views agree on what's visible
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return stops.filter((s) => {
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
s.city.toLowerCase().includes(q) ||
|
||||
s.state.toLowerCase().includes(q) ||
|
||||
s.location.toLowerCase().includes(q);
|
||||
const isDraft = s.status === "draft";
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && s.active && !isDraft) ||
|
||||
(statusFilter === "inactive" && !s.active && !isDraft) ||
|
||||
(statusFilter === "draft" && isDraft);
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [stops, search, statusFilter]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: stops.length,
|
||||
active: stops.filter((s) => s.active && s.status !== "draft").length,
|
||||
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
|
||||
draft: stops.filter((s) => s.status === "draft").length,
|
||||
}),
|
||||
[stops]
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ value: "all", label: "All", count: counts.all },
|
||||
{ value: "active", label: "Active", count: counts.active },
|
||||
{ value: "inactive", label: "Inactive", count: counts.inactive },
|
||||
{ value: "draft", label: "Draft", count: counts.draft },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filter / search / view-toggle bar — shared across both views */}
|
||||
<div className="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center">
|
||||
<AdminFilterTabs
|
||||
activeTab={statusFilter}
|
||||
onTabChange={(v) => setStatusFilter(v as StopStatusFilter)}
|
||||
tabs={tabs}
|
||||
size="sm"
|
||||
showCounts
|
||||
/>
|
||||
<AdminSearchInput
|
||||
placeholder="Search city, state, or venue…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch("")}
|
||||
showClear
|
||||
className="flex-1 min-w-48 max-w-72"
|
||||
/>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{filtered.length} {filtered.length === 1 ? "stop" : "stops"}
|
||||
</span>
|
||||
<div className="sm:ml-auto">
|
||||
<div className="ha-viewtoggle" role="tablist" aria-label="Stops view mode">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === "calendar"}
|
||||
onClick={() => setView("calendar")}
|
||||
className={`ha-viewtoggle-btn ${view === "calendar" ? "ha-viewtoggle-btn--active" : ""}`}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
Calendar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === "table"}
|
||||
onClick={() => setView("table")}
|
||||
className={`ha-viewtoggle-btn ${view === "table" ? "ha-viewtoggle-btn--active" : ""}`}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "calendar" ? (
|
||||
<StopsCalendarClient stops={filtered} />
|
||||
) : (
|
||||
<StopTableClient stops={filtered} hideInternalFilterBar />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
type Props = {
|
||||
tabKey: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fades the content of a tabbed panel when the active tab changes.
|
||||
* The tab key drives the animation, so switching tabs triggers a quick
|
||||
* fade-in of the new content. No motion when the tab key is stable
|
||||
* (i.e. on first render of a given tab).
|
||||
*/
|
||||
export function TabSwitcher({ tabKey, children }: Props) {
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={tabKey}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
type Stop,
|
||||
type StopStatus,
|
||||
formatMonthYear,
|
||||
formatTime12,
|
||||
getStopDate,
|
||||
getStopStatus,
|
||||
isSameDay,
|
||||
} from "./types";
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
};
|
||||
|
||||
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
const STATUS_STYLES: Record<StopStatus, { bg: string; text: string; dot: string; border: string }> = {
|
||||
active: {
|
||||
bg: "bg-[var(--admin-accent-light)]",
|
||||
text: "text-[var(--admin-accent-text)]",
|
||||
dot: "bg-[var(--admin-accent-dot)]",
|
||||
border: "border-[var(--admin-accent)]/40",
|
||||
},
|
||||
draft: {
|
||||
bg: "bg-amber-50",
|
||||
text: "text-amber-800",
|
||||
dot: "bg-amber-500",
|
||||
border: "border-amber-300",
|
||||
},
|
||||
inactive: {
|
||||
bg: "bg-stone-100",
|
||||
text: "text-stone-500",
|
||||
dot: "bg-stone-400",
|
||||
border: "border-stone-300",
|
||||
},
|
||||
};
|
||||
|
||||
function buildMonthGrid(viewYear: number, viewMonth: number) {
|
||||
// Sunday-first 6-row grid
|
||||
const first = new Date(viewYear, viewMonth, 1);
|
||||
const startWeekday = first.getDay();
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
|
||||
const cells: { date: Date; inMonth: boolean }[] = [];
|
||||
// Leading days from previous month
|
||||
for (let i = startWeekday - 1; i >= 0; i--) {
|
||||
const d = new Date(viewYear, viewMonth, -i);
|
||||
cells.push({ date: d, inMonth: false });
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
cells.push({ date: new Date(viewYear, viewMonth, d), inMonth: true });
|
||||
}
|
||||
// Trailing days to fill 6 rows = 42 cells
|
||||
while (cells.length < 42) {
|
||||
const last = cells[cells.length - 1].date;
|
||||
const next = new Date(last);
|
||||
next.setDate(last.getDate() + 1);
|
||||
cells.push({ date: next, inMonth: false });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function ymd(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export default function StopsCalendar({ stops }: Props) {
|
||||
const today = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}, []);
|
||||
|
||||
const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(ymd(today));
|
||||
|
||||
const cells = useMemo(() => buildMonthGrid(view.year, view.month), [view]);
|
||||
|
||||
const stopsByDate = useMemo(() => {
|
||||
const map = new Map<string, Stop[]>();
|
||||
for (const s of stops) {
|
||||
const d = getStopDate(s);
|
||||
if (!d) continue;
|
||||
const k = ymd(d);
|
||||
const arr = map.get(k) ?? [];
|
||||
arr.push(s);
|
||||
map.set(k, arr);
|
||||
}
|
||||
// sort each bucket by time
|
||||
for (const arr of map.values()) {
|
||||
arr.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
|
||||
}
|
||||
return map;
|
||||
}, [stops]);
|
||||
|
||||
const visibleStopsByDate = useMemo(() => {
|
||||
const map = new Map<string, Stop[]>();
|
||||
for (const [k, arr] of stopsByDate.entries()) {
|
||||
map.set(
|
||||
k,
|
||||
arr.filter((s) => getStopStatus(s) !== "inactive" || arr.length <= 6)
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [stopsByDate]);
|
||||
|
||||
const monthStopsCount = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const cell of cells) {
|
||||
if (!cell.inMonth) continue;
|
||||
count += stopsByDate.get(ymd(cell.date))?.length ?? 0;
|
||||
}
|
||||
return count;
|
||||
}, [cells, stopsByDate]);
|
||||
|
||||
const upcomingWeek = useMemo(() => {
|
||||
const end = new Date(today);
|
||||
end.setDate(end.getDate() + 7);
|
||||
let count = 0;
|
||||
for (const s of stops) {
|
||||
const d = getStopDate(s);
|
||||
if (!d) continue;
|
||||
if (d >= today && d < end && getStopStatus(s) !== "inactive") count++;
|
||||
}
|
||||
return count;
|
||||
}, [stops, today]);
|
||||
|
||||
const goPrev = () => {
|
||||
setView((v) => {
|
||||
const m = v.month - 1;
|
||||
if (m < 0) return { year: v.year - 1, month: 11 };
|
||||
return { ...v, month: m };
|
||||
});
|
||||
setSelectedDate(null);
|
||||
};
|
||||
const goNext = () => {
|
||||
setView((v) => {
|
||||
const m = v.month + 1;
|
||||
if (m > 11) return { year: v.year + 1, month: 0 };
|
||||
return { ...v, month: m };
|
||||
});
|
||||
setSelectedDate(null);
|
||||
};
|
||||
const goToday = () => {
|
||||
setView({ year: today.getFullYear(), month: today.getMonth() });
|
||||
setSelectedDate(ymd(today));
|
||||
};
|
||||
|
||||
const monthLabel = formatMonthYear(new Date(view.year, view.month, 1));
|
||||
const isCurrentMonth = view.year === today.getFullYear() && view.month === today.getMonth();
|
||||
|
||||
const selectedDayStops = selectedDate ? stopsByDate.get(selectedDate) ?? [] : [];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-6">
|
||||
{/* Calendar card */}
|
||||
<section
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm"
|
||||
aria-label="Stops calendar"
|
||||
>
|
||||
{/* Decorative paper texture */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.4]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 20% 10%, rgba(34,197,94,0.04) 0%, transparent 40%), radial-gradient(circle at 80% 90%, rgba(217,119,6,0.04) 0%, transparent 40%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.035]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.5 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<header className="relative flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-5">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<h2 className="font-display text-3xl font-medium text-[var(--admin-text-primary)] tracking-tight">
|
||||
{monthLabel.split(" ")[0]}
|
||||
<span className="text-[var(--admin-accent)] italic font-light">
|
||||
{" "}
|
||||
{monthLabel.split(" ")[1]}
|
||||
</span>
|
||||
</h2>
|
||||
<span className="hidden sm:inline-block font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
|
||||
{monthStopsCount} stop{monthStopsCount === 1 ? "" : "s"} scheduled
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={goPrev}
|
||||
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={goToday}
|
||||
disabled={isCurrentMonth}
|
||||
className="h-8 px-3 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
aria-label="Next month"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Weekday header */}
|
||||
<div className="relative grid grid-cols-7 border-b border-[var(--admin-border)]">
|
||||
{WEEKDAY_LABELS.map((d, i) => (
|
||||
<div
|
||||
key={d}
|
||||
className={`px-2 py-2.5 text-center font-mono text-[10px] uppercase tracking-[0.2em] ${
|
||||
i === 0 || i === 6 ? "text-[var(--admin-accent)]" : "text-[var(--admin-text-muted)]"
|
||||
}`}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="relative grid grid-cols-7 grid-rows-6">
|
||||
{cells.map((cell, idx) => {
|
||||
const k = ymd(cell.date);
|
||||
const dayStops = stopsByDate.get(k) ?? [];
|
||||
const visibleStops = visibleStopsByDate.get(k) ?? [];
|
||||
const isToday = isSameDay(cell.date, today);
|
||||
const isSelected = k === selectedDate;
|
||||
const isWeekend = cell.date.getDay() === 0 || cell.date.getDay() === 6;
|
||||
const overflow = dayStops.length - visibleStops.length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setSelectedDate(k)}
|
||||
className={`
|
||||
group relative flex min-h-[110px] flex-col items-stretch gap-1.5 border-b border-r border-[var(--admin-border-light)] p-2 text-left transition-colors
|
||||
${!cell.inMonth ? "bg-[var(--admin-bg-subtle)]/40" : "bg-white"}
|
||||
${isWeekend && cell.inMonth ? "bg-[var(--admin-bg-subtle)]/30" : ""}
|
||||
${isSelected ? "!bg-[var(--admin-accent-light)] ring-2 ring-inset ring-[var(--admin-accent)]" : "hover:bg-[var(--admin-accent-light)]/40"}
|
||||
`}
|
||||
>
|
||||
{/* Date number row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`
|
||||
inline-flex h-6 min-w-[1.5rem] items-center justify-center rounded-md px-1.5 font-mono text-[11px] font-semibold
|
||||
${isToday
|
||||
? "bg-gradient-to-br from-amber-300 to-orange-400 text-white shadow-sm"
|
||||
: !cell.inMonth
|
||||
? "text-[var(--admin-text-muted)]/50"
|
||||
: isSelected
|
||||
? "text-[var(--admin-accent-text)]"
|
||||
: "text-[var(--admin-text-primary)]"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{cell.date.getDate()}
|
||||
</span>
|
||||
{dayStops.length > 0 && cell.inMonth && (
|
||||
<span className="font-mono text-[9px] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
{dayStops.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stop chips */}
|
||||
<div className="flex flex-col gap-0.5 overflow-hidden">
|
||||
{visibleStops.slice(0, 3).map((s) => {
|
||||
const status = getStopStatus(s);
|
||||
const st = STATUS_STYLES[status];
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`flex items-center gap-1 rounded ${st.bg} ${st.border} border px-1.5 py-0.5 text-[10px] font-medium leading-tight ${st.text}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${st.dot}`} aria-hidden />
|
||||
<span className="truncate">
|
||||
{s.time && (
|
||||
<span className="font-mono opacity-70 mr-0.5">{formatTime12(s.time).replace(" ", "").toLowerCase()}</span>
|
||||
)}
|
||||
{s.city}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<div className="px-1.5 text-[10px] font-mono font-semibold text-[var(--admin-accent-text)]">
|
||||
+{overflow} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<footer className="relative flex flex-wrap items-center gap-4 border-t border-[var(--admin-border)] px-6 py-3">
|
||||
{(["active", "draft", "inactive"] as StopStatus[]).map((s) => {
|
||||
const st = STATUS_STYLES[s];
|
||||
return (
|
||||
<div key={s} className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
<span className={`h-2 w-2 rounded-full ${st.dot}`} aria-hidden />
|
||||
<span>{s}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="ml-auto flex items-center gap-3 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
<span>
|
||||
<span className="font-display text-base font-semibold text-[var(--admin-accent)]">
|
||||
{upcomingWeek}
|
||||
</span>{" "}
|
||||
upcoming in 7d
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
{/* Day detail panel */}
|
||||
<aside className="rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
|
||||
<div className="border-b border-[var(--admin-border)] px-5 py-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
|
||||
{selectedDate
|
||||
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", { weekday: "long" })
|
||||
: "Pick a day"}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-0.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
|
||||
{selectedDate
|
||||
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: "Select a date on the calendar"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[640px] overflow-y-auto px-2 py-2">
|
||||
{!selectedDate ? (
|
||||
<div className="px-4 py-10 text-center font-mono text-[11px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
No day selected
|
||||
</div>
|
||||
) : selectedDayStops.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
|
||||
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops</p>
|
||||
<p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
Free day on the route
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{selectedDayStops.map((s) => {
|
||||
const status = getStopStatus(s);
|
||||
const st = STATUS_STYLES[status];
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<Link
|
||||
href={`/admin/stops/${s.id}`}
|
||||
className="group block rounded-xl border border-transparent px-3 py-3 transition-all hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${st.bg} ${st.border} border`}
|
||||
>
|
||||
<span className="font-mono text-[10px] font-bold text-[var(--admin-text-primary)]">
|
||||
{formatTime12(s.time).split(" ")[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="truncate font-display text-base font-medium text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors">
|
||||
{s.city}, {s.state}
|
||||
</p>
|
||||
<span className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
{formatTime12(s.time).split(" ")[1]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</p>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full ${st.bg} ${st.text} px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider ${st.border} border`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} aria-hidden />
|
||||
{status}
|
||||
</span>
|
||||
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name ? (
|
||||
<span className="truncate font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { type Stop, type StopView, getStopStatus } from "./types";
|
||||
import StopsCalendar from "./StopsCalendar";
|
||||
import StopsLocations from "./StopsLocations";
|
||||
import StopsList from "./StopsList";
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
};
|
||||
|
||||
const TABS: { value: StopView; label: string; hint: string }[] = [
|
||||
{ value: "calendar", label: "Calendar", hint: "Month at a glance" },
|
||||
{ value: "locations", label: "Locations", hint: "Stops grouped by city" },
|
||||
{ value: "list", label: "List", hint: "All stops in order" },
|
||||
];
|
||||
|
||||
export default function StopsDashboardClient({ stops }: Props) {
|
||||
const [view, setView] = useState<StopView>("calendar");
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = stops.length;
|
||||
const active = stops.filter((s) => getStopStatus(s) === "active").length;
|
||||
const draft = stops.filter((s) => getStopStatus(s) === "draft").length;
|
||||
const cities = new Set(stops.map((s) => s.city.trim().toLowerCase())).size;
|
||||
const venues = new Set(
|
||||
stops.map((s) => `${s.city}|${s.state}|${s.location}`.toLowerCase())
|
||||
).size;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const in7 = new Date(today);
|
||||
in7.setDate(today.getDate() + 7);
|
||||
const upcoming = stops.filter((s) => {
|
||||
if (!s.date) return false;
|
||||
if (getStopStatus(s) === "inactive") return false;
|
||||
const d = new Date(s.date + "T00:00:00");
|
||||
return d >= today && d < in7;
|
||||
}).length;
|
||||
return { total, active, draft, cities, venues, upcoming };
|
||||
}, [stops]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top stats strip — almanac style */}
|
||||
<section
|
||||
aria-label="Stops overview"
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-5 shadow-sm"
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.6 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
|
||||
Harvest Dispatch · Almanac
|
||||
</p>
|
||||
<h2 className="mt-2 font-display text-3xl sm:text-4xl font-medium tracking-tight text-[var(--admin-text-primary)]">
|
||||
{stats.total === 0
|
||||
? "No stops scheduled"
|
||||
: `${stats.total} stop${stats.total === 1 ? "" : "s"} on the route`}
|
||||
<span className="ml-2 text-[var(--admin-accent)] italic font-light">
|
||||
{stats.cities} cit{stats.cities === 1 ? "y" : "ies"}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
|
||||
{stats.active} active · {stats.draft} drafts · {stats.venues} distinct venues
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab nav — binder-style with notched corners */}
|
||||
<div className="flex items-end gap-1">
|
||||
{TABS.map((t) => {
|
||||
const isActive = t.value === view;
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setView(t.value)}
|
||||
aria-pressed={isActive}
|
||||
className={`
|
||||
group relative -mb-px inline-flex flex-col items-start gap-0.5 rounded-t-xl border border-b-0 px-4 py-2.5 transition-all
|
||||
${isActive
|
||||
? "z-10 border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] shadow-[0_-4px_8px_-4px_rgba(60,56,37,0.08)]"
|
||||
: "border-transparent bg-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-white/40"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className={`font-display text-base font-medium ${isActive ? "text-[var(--admin-accent-text)]" : ""}`}>
|
||||
{t.label}
|
||||
</span>
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.18em] opacity-80">
|
||||
{t.hint}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-3 right-3 -bottom-px h-0.5 bg-[var(--admin-accent)]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat cells */}
|
||||
<div className="relative mt-5 grid grid-cols-2 gap-3 border-t border-[var(--admin-border-light)] pt-5 sm:grid-cols-4">
|
||||
<AlmanacStat
|
||||
numeral="I"
|
||||
label="Active"
|
||||
value={stats.active}
|
||||
accent
|
||||
/>
|
||||
<AlmanacStat
|
||||
numeral="II"
|
||||
label="Upcoming 7d"
|
||||
value={stats.upcoming}
|
||||
accent={stats.upcoming > 0}
|
||||
/>
|
||||
<AlmanacStat
|
||||
numeral="III"
|
||||
label="Cities"
|
||||
value={stats.cities}
|
||||
/>
|
||||
<AlmanacStat
|
||||
numeral="IV"
|
||||
label="Venues"
|
||||
value={stats.venues}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Active view */}
|
||||
<section>
|
||||
{view === "calendar" && <StopsCalendar stops={stops} />}
|
||||
{view === "locations" && <StopsLocations stops={stops} />}
|
||||
{view === "list" && <StopsList stops={stops} />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlmanacStat({
|
||||
numeral,
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
}: {
|
||||
numeral: string;
|
||||
label: string;
|
||||
value: number;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md font-display text-base font-medium ${
|
||||
accent ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
{numeral}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`font-display text-2xl font-medium leading-none tabular-nums ${
|
||||
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { type Stop, getStopStatus } from "./types";
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
};
|
||||
|
||||
// Minimal list view used inside the StopsDashboard tab nav.
|
||||
// It is a calmer, read-only list; for bulk publish/edit use the
|
||||
// dedicated /admin/stops page.
|
||||
export default function StopsList({ stops }: Props) {
|
||||
const sorted = useMemo(() => {
|
||||
return [...stops].sort((a, b) => (a.date || "").localeCompare(b.date || ""));
|
||||
}, [stops]);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
|
||||
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops yet</p>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
|
||||
Switch to a different tab to add stops.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-[var(--admin-border-light)] overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
|
||||
{sorted.map((s) => {
|
||||
const status = getStopStatus(s);
|
||||
const dot =
|
||||
status === "active"
|
||||
? "bg-[var(--admin-accent-dot)]"
|
||||
: status === "draft"
|
||||
? "bg-amber-500"
|
||||
: "bg-stone-400";
|
||||
const brand = Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name;
|
||||
return (
|
||||
<li key={s.id} className="group flex items-center gap-4 px-5 py-3 transition-colors hover:bg-[var(--admin-bg-subtle)]">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)] w-20">
|
||||
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" }) : "—"}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
|
||||
{s.time ? s.time.slice(0, 5) : "—"}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="truncate font-display text-sm font-medium text-[var(--admin-text-primary)]">
|
||||
{s.city}, {s.state}
|
||||
</span>
|
||||
<span className="ml-2 truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</span>
|
||||
</span>
|
||||
{brand && (
|
||||
<span className="hidden md:inline-block font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
{brand}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`font-mono text-[9px] uppercase tracking-wider ${
|
||||
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
type LocationGroup,
|
||||
type Stop,
|
||||
formatTime12,
|
||||
getStopStatus,
|
||||
} from "./types";
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
};
|
||||
|
||||
function groupStopsByLocation(stops: Stop[]): LocationGroup[] {
|
||||
const map = new Map<string, LocationGroup>();
|
||||
for (const s of stops) {
|
||||
const key = `${(s.city || "Unknown").trim().toUpperCase()}|${(s.state || "").trim().toUpperCase()}`;
|
||||
let group = map.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
key,
|
||||
city: s.city || "Unknown",
|
||||
state: s.state || "",
|
||||
venueCount: 0,
|
||||
total: 0,
|
||||
active: 0,
|
||||
draft: 0,
|
||||
inactive: 0,
|
||||
upcoming: 0,
|
||||
nextDate: null,
|
||||
firstDate: null,
|
||||
lastDate: null,
|
||||
sampleVenue: s.location,
|
||||
stops: [],
|
||||
};
|
||||
map.set(key, group);
|
||||
}
|
||||
group.stops.push(s);
|
||||
group.total += 1;
|
||||
const status = getStopStatus(s);
|
||||
if (status === "active") group.active += 1;
|
||||
else if (status === "draft") group.draft += 1;
|
||||
else group.inactive += 1;
|
||||
|
||||
if (s.date) {
|
||||
if (!group.firstDate || s.date < group.firstDate) group.firstDate = s.date;
|
||||
if (!group.lastDate || s.date > group.lastDate) group.lastDate = s.date;
|
||||
}
|
||||
}
|
||||
// post-process: count distinct venues, upcoming stops
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
|
||||
for (const g of map.values()) {
|
||||
const venues = new Set(g.stops.map((s) => (s.location || "").trim().toLowerCase()).filter(Boolean));
|
||||
g.venueCount = Math.max(1, venues.size);
|
||||
g.upcoming = g.stops.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive").length;
|
||||
const future = g.stops
|
||||
.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive")
|
||||
.map((s) => s.date)
|
||||
.sort();
|
||||
g.nextDate = future[0] ?? null;
|
||||
g.stops.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
|
||||
}
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => {
|
||||
// active locations with upcoming stops first, sorted by next date; then by city
|
||||
if (a.nextDate && !b.nextDate) return -1;
|
||||
if (!a.nextDate && b.nextDate) return 1;
|
||||
if (a.nextDate && b.nextDate) return a.nextDate.localeCompare(b.nextDate);
|
||||
return a.city.localeCompare(b.city);
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateLabel(ymd: string | null): string {
|
||||
if (!ymd) return "—";
|
||||
const d = new Date(ymd + "T00:00:00");
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatRange(a: string | null, b: string | null): string {
|
||||
if (!a) return "—";
|
||||
if (!b || a === b) return formatDateLabel(a);
|
||||
return `${formatDateLabel(a)} → ${formatDateLabel(b)}`;
|
||||
}
|
||||
|
||||
export default function StopsLocations({ stops }: Props) {
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [showOnlyActive, setShowOnlyActive] = useState(false);
|
||||
|
||||
const groups = useMemo(() => groupStopsByLocation(stops), [stops]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return groups.filter((g) => {
|
||||
if (q) {
|
||||
const hay = `${g.city} ${g.state} ${g.sampleVenue}`.toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
if (showOnlyActive && g.active === 0) return false;
|
||||
return true;
|
||||
});
|
||||
}, [groups, query, showOnlyActive]);
|
||||
|
||||
// Aggregated stats
|
||||
const stats = useMemo(() => {
|
||||
const totalLocations = groups.length;
|
||||
const totalStops = stops.length;
|
||||
const totalActive = groups.reduce((sum, g) => sum + g.active, 0);
|
||||
const totalUpcoming = groups.reduce((sum, g) => sum + g.upcoming, 0);
|
||||
const totalDrafts = groups.reduce((sum, g) => sum + g.draft, 0);
|
||||
const totalCities = new Set(groups.map((g) => g.city.toLowerCase())).size;
|
||||
const totalVenues = groups.reduce((sum, g) => sum + g.venueCount, 0);
|
||||
return { totalLocations, totalStops, totalActive, totalUpcoming, totalDrafts, totalCities, totalVenues };
|
||||
}, [groups, stops]);
|
||||
|
||||
if (stops.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-12 text-center shadow-sm">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
|
||||
<svg className="h-7 w-7 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
|
||||
No locations yet
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
|
||||
Create your first stop to see pickup locations here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats strip — almanac style */}
|
||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{[
|
||||
{ numeral: "I", label: "Locations", value: stats.totalLocations, hint: `${stats.totalVenues} venues` },
|
||||
{ numeral: "II", label: "Cities", value: stats.totalCities, hint: "covered" },
|
||||
{ numeral: "III", label: "Stops", value: stats.totalStops, hint: "all-time" },
|
||||
{ numeral: "IV", label: "Active", value: stats.totalActive, hint: "live" },
|
||||
{ numeral: "V", label: "Upcoming", value: stats.totalUpcoming, hint: "in queue" },
|
||||
{ numeral: "VI", label: "Drafts", value: stats.totalDrafts, hint: "unpublished" },
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.numeral}
|
||||
className="group relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-sm transition-all hover:shadow-md hover:border-[var(--admin-accent)]/30"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
|
||||
{s.numeral} · {s.label}
|
||||
</p>
|
||||
<p className="mt-2 font-display text-3xl font-medium leading-none text-[var(--admin-text-primary)] tabular-nums">
|
||||
{s.value}
|
||||
</p>
|
||||
<p className="mt-1.5 font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
{s.hint}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="h-8 w-8 -rotate-3 font-display text-2xl text-[var(--admin-accent)]/15 group-hover:text-[var(--admin-accent)]/30 transition-colors"
|
||||
>
|
||||
{s.numeral}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-px w-full bg-gradient-to-r from-[var(--admin-accent)]/20 via-[var(--admin-accent)]/40 to-[var(--admin-accent)]/20" />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Filter bar */}
|
||||
<section className="flex flex-wrap items-center gap-3 rounded-2xl border border-[var(--admin-border)] bg-white px-4 py-3 shadow-sm">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search city, state, venue..."
|
||||
className="w-full rounded-lg border border-[var(--admin-border)] bg-white pl-9 pr-3 py-2 text-sm text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:border-[var(--admin-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/15"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={showOnlyActive}
|
||||
onClick={() => setShowOnlyActive((v) => !v)}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors ${showOnlyActive ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${showOnlyActive ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-secondary)]">
|
||||
Active only
|
||||
</span>
|
||||
</label>
|
||||
<span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
{filtered.length} of {groups.length} locations
|
||||
</span>
|
||||
</section>
|
||||
|
||||
{/* Location grid */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
|
||||
<p className="font-display text-lg text-[var(--admin-text-primary)]">No locations match.</p>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">Try a different search.</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((g, idx) => {
|
||||
const isOpen = expandedKey === g.key;
|
||||
return (
|
||||
<article
|
||||
key={g.key}
|
||||
className={`
|
||||
group relative overflow-hidden rounded-2xl border bg-white shadow-sm transition-all
|
||||
${isOpen ? "border-[var(--admin-accent)] shadow-md ring-1 ring-[var(--admin-accent)]/20" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)]/40 hover:shadow-md"}
|
||||
`}
|
||||
>
|
||||
{/* Decorative route number */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-2 -top-3 select-none font-display text-[80px] leading-none font-medium text-[var(--admin-accent)]/[0.06] group-hover:text-[var(--admin-accent)]/[0.1] transition-colors"
|
||||
>
|
||||
{String(idx + 1).padStart(2, "0")}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedKey(isOpen ? null : g.key)}
|
||||
className="block w-full text-left p-5"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Pin marker */}
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)]/30">
|
||||
<svg className="h-3.5 w-3.5 text-[var(--admin-accent-text)]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate font-display text-xl font-medium text-[var(--admin-text-primary)]">
|
||||
{g.city}
|
||||
{g.state && (
|
||||
<span className="ml-1.5 font-mono text-xs font-normal text-[var(--admin-text-muted)]">
|
||||
{g.state}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="truncate text-xs text-[var(--admin-text-secondary)]">
|
||||
{g.sampleVenue}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 h-7 w-7 shrink-0 inline-flex items-center justify-center rounded-full border transition-transform ${
|
||||
isOpen ? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] rotate-45" : "border-[var(--admin-border)] bg-white text-[var(--admin-text-muted)]"
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat grid */}
|
||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||
<Stat label="Stops" value={g.total} />
|
||||
<Stat label="Active" value={g.active} accent={g.active > 0} />
|
||||
<Stat label="Upcoming" value={g.upcoming} accent={g.upcoming > 0} />
|
||||
</div>
|
||||
|
||||
{/* Date range + drafts */}
|
||||
<div className="mt-4 flex items-center justify-between border-t border-[var(--admin-border-light)] pt-3 font-mono text-[10px] uppercase tracking-wider">
|
||||
<div className="text-[var(--admin-text-muted)]">
|
||||
{g.firstDate ? (
|
||||
<>
|
||||
<span className="text-[var(--admin-text-secondary)]">{formatRange(g.firstDate, g.lastDate)}</span>
|
||||
{" · "}
|
||||
{g.venueCount > 1 ? `${g.venueCount} venues` : `${g.total} stop${g.total === 1 ? "" : "s"}`}
|
||||
</>
|
||||
) : (
|
||||
"No dates"
|
||||
)}
|
||||
</div>
|
||||
{g.draft > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden />
|
||||
{g.draft} draft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next up ribbon */}
|
||||
{g.nextDate && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-[var(--admin-accent)]/20 bg-gradient-to-r from-[var(--admin-accent-light)] to-transparent px-3 py-2">
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-accent-text)]">
|
||||
Next →
|
||||
</span>
|
||||
<span className="font-display text-sm font-medium text-[var(--admin-text-primary)]">
|
||||
{formatDateLabel(g.nextDate)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded stop list */}
|
||||
{isOpen && (
|
||||
<div className="border-t border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/40 px-5 py-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
|
||||
All stops · {g.stops.length}
|
||||
</h4>
|
||||
<Link
|
||||
href={`/admin/stops/new?city=${encodeURIComponent(g.city)}&state=${encodeURIComponent(g.state)}`}
|
||||
className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-accent-text)] hover:underline"
|
||||
>
|
||||
+ Add at this city
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{g.stops.map((s) => {
|
||||
const status = getStopStatus(s);
|
||||
const dot =
|
||||
status === "active"
|
||||
? "bg-[var(--admin-accent-dot)]"
|
||||
: status === "draft"
|
||||
? "bg-amber-500"
|
||||
: "bg-stone-400";
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<Link
|
||||
href={`/admin/stops/${s.id}`}
|
||||
className="flex items-center gap-3 rounded-lg border border-transparent bg-white px-3 py-2 transition-colors hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
|
||||
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-16">
|
||||
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }) : "—"}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
|
||||
{formatTime12(s.time)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-xs text-[var(--admin-text-primary)]">
|
||||
{s.location}
|
||||
</span>
|
||||
<span
|
||||
className={`font-mono text-[9px] uppercase tracking-wider ${
|
||||
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border px-2.5 py-2 ${
|
||||
accent ? "border-[var(--admin-accent)]/30 bg-[var(--admin-accent-light)]" : "border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/50"
|
||||
}`}
|
||||
>
|
||||
<p className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">{label}</p>
|
||||
<p
|
||||
className={`mt-0.5 font-display text-xl font-medium tabular-nums leading-none ${
|
||||
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type StopStatus = "draft" | "active" | "inactive";
|
||||
|
||||
export type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
time: string; // HH:MM
|
||||
location: string;
|
||||
active: boolean;
|
||||
deleted_at?: string | null;
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
brands: { name: string } | { name: string }[];
|
||||
};
|
||||
|
||||
export type StopView = "calendar" | "locations" | "list";
|
||||
|
||||
export type LocationGroup = {
|
||||
key: string;
|
||||
city: string;
|
||||
state: string;
|
||||
venueCount: number; // distinct location strings at this city
|
||||
total: number;
|
||||
active: number;
|
||||
draft: number;
|
||||
inactive: number;
|
||||
upcoming: number;
|
||||
nextDate: string | null; // YYYY-MM-DD
|
||||
firstDate: string | null;
|
||||
lastDate: string | null;
|
||||
sampleVenue: string;
|
||||
stops: Stop[];
|
||||
};
|
||||
|
||||
export function getStopStatus(s: Stop): StopStatus {
|
||||
if (s.status === "draft") return "draft";
|
||||
return s.active ? "active" : "inactive";
|
||||
}
|
||||
|
||||
export function getStopDate(s: Stop): Date | null {
|
||||
if (!s.date) return null;
|
||||
const d = new Date(s.date + "T00:00:00");
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export function formatTime12(time: string): string {
|
||||
if (!time) return "—";
|
||||
const [h, m] = time.split(":");
|
||||
const hour = parseInt(h, 10);
|
||||
if (isNaN(hour)) return time;
|
||||
const ampm = hour >= 12 ? "PM" : "AM";
|
||||
const hour12 = hour % 12 || 12;
|
||||
return `${hour12}:${m} ${ampm}`;
|
||||
}
|
||||
|
||||
export function formatMonthYear(date: Date): string {
|
||||
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type QuickCartSheetProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Optional override for the product just added (used when re-opening). */
|
||||
productName?: string;
|
||||
/** Optional message override (e.g. "Added ✓" vs default). */
|
||||
justAddedLabel?: string;
|
||||
};
|
||||
|
||||
const EASE_OUT = [0.22, 0.61, 0.36, 1] as const;
|
||||
|
||||
/**
|
||||
* Slide-up cart drawer that appears immediately after the buyer adds an item.
|
||||
*
|
||||
* Mobile: bottom sheet that slides up from the bottom of the screen,
|
||||
* full width, ~85vh tall. Drag handle at top.
|
||||
* Desktop: right-side drawer (420px wide) that slides in from the right.
|
||||
*
|
||||
* Contains:
|
||||
* - "✓ Added" confirmation
|
||||
* - The added item card (name, price, qty, fulfillment)
|
||||
* - Express checkout row (Apple Pay / Google Pay / Shop Pay)
|
||||
* - Primary "Checkout" CTA
|
||||
* - Secondary "View full cart" link
|
||||
* - "Continue shopping" dismiss link
|
||||
*/
|
||||
export default function QuickCartSheet({
|
||||
open,
|
||||
onClose,
|
||||
productName,
|
||||
justAddedLabel,
|
||||
}: QuickCartSheetProps) {
|
||||
const { cart, subtotal, selectedStop, justAdded, dismissToast } = useCart();
|
||||
|
||||
// Lock body scroll while open (mobile only — desktop keeps scroll)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
const isDesktop = window.matchMedia("(min-width: 1024px)").matches;
|
||||
if (!isDesktop) {
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
const displayName = productName ?? justAdded?.name ?? cart[0]?.name ?? "Your box";
|
||||
const addedAt = new Date();
|
||||
const headerLabel = justAddedLabel ?? "Just added";
|
||||
|
||||
function handleViewCart() {
|
||||
dismissToast();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleCheckout() {
|
||||
dismissToast();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label="Close cart"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: EASE_OUT }}
|
||||
className="fixed inset-0 z-[60] bg-stone-950/55 backdrop-blur-[3px] cursor-default"
|
||||
/>
|
||||
|
||||
{/* Mobile: bottom sheet */}
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Quick cart"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 38, mass: 0.9 }}
|
||||
drag="y"
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.5 }}
|
||||
onDragEnd={(_, info) => {
|
||||
if (info.offset.y > 90 || info.velocity.y > 480) onClose();
|
||||
}}
|
||||
className="lg:hidden fixed inset-x-0 bottom-0 z-[61] max-h-[88vh] rounded-t-[28px] bg-[#FAF6EA] shadow-[0_-24px_60px_-12px_rgba(26,77,46,0.35)] flex flex-col"
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-3 pb-1 cursor-grab active:cursor-grabbing">
|
||||
<div className="h-1.5 w-12 rounded-full bg-stone-300/70" />
|
||||
</div>
|
||||
|
||||
<SheetContent
|
||||
displayName={displayName}
|
||||
addedAt={addedAt}
|
||||
headerLabel={headerLabel}
|
||||
cart={cart}
|
||||
subtotal={subtotal}
|
||||
selectedStop={selectedStop}
|
||||
onViewCart={handleViewCart}
|
||||
onCheckout={handleCheckout}
|
||||
onContinue={onClose}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Desktop: right-side drawer */}
|
||||
<motion.aside
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Quick cart"
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 36, mass: 0.9 }}
|
||||
className="hidden lg:flex fixed inset-y-0 right-0 z-[61] w-full sm:w-[420px] xl:w-[460px] bg-[#FAF6EA] shadow-[-24px_0_60px_-12px_rgba(26,77,46,0.35)] flex-col"
|
||||
>
|
||||
<SheetContent
|
||||
displayName={displayName}
|
||||
addedAt={addedAt}
|
||||
headerLabel={headerLabel}
|
||||
cart={cart}
|
||||
subtotal={subtotal}
|
||||
selectedStop={selectedStop}
|
||||
onViewCart={handleViewCart}
|
||||
onCheckout={handleCheckout}
|
||||
onContinue={onClose}
|
||||
showCloseButton
|
||||
/>
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type CartRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
quantity: number;
|
||||
fulfillment?: "pickup" | "ship";
|
||||
};
|
||||
|
||||
type StopInfo = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
brand_id: string;
|
||||
};
|
||||
|
||||
type SheetContentProps = {
|
||||
displayName: string;
|
||||
addedAt: Date;
|
||||
headerLabel: string;
|
||||
cart: CartRow[];
|
||||
subtotal: number;
|
||||
selectedStop: StopInfo | null;
|
||||
onViewCart: () => void;
|
||||
onCheckout: () => void;
|
||||
onContinue: () => void;
|
||||
showCloseButton?: boolean;
|
||||
};
|
||||
|
||||
function SheetContent({
|
||||
displayName,
|
||||
addedAt,
|
||||
headerLabel,
|
||||
cart,
|
||||
subtotal,
|
||||
selectedStop,
|
||||
onViewCart,
|
||||
onCheckout,
|
||||
onContinue,
|
||||
showCloseButton,
|
||||
}: SheetContentProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="relative px-6 sm:px-8 pt-2 pb-5 border-b border-stone-900/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -45 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 18, delay: 0.05 }}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#1A4D2E] text-white shadow-[0_4px_14px_rgba(26,77,46,0.4)]"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.22em] text-[#1A4D2E]/70">
|
||||
{headerLabel}
|
||||
</p>
|
||||
<h2 className="font-display text-[22px] sm:text-[24px] font-bold leading-[1.1] text-stone-950 truncate">
|
||||
{displayName}
|
||||
</h2>
|
||||
</div>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onContinue}
|
||||
aria-label="Close"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-stone-500 hover:bg-stone-900/5 hover:text-stone-900 transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
{addedAt.toLocaleString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})} · Today
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body — scrollable */}
|
||||
<div className="flex-1 overflow-y-auto px-6 sm:px-8 py-5">
|
||||
<ul className="space-y-3">
|
||||
{cart.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-start gap-4 rounded-2xl bg-white/70 ring-1 ring-stone-900/8 p-4"
|
||||
>
|
||||
{/* Tiny product swatch */}
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-gradient-to-br from-[#FFD86A] via-[#F0B81C] to-[#A47A0B] ring-1 ring-stone-900/10">
|
||||
<CornGlyph className="absolute inset-0 m-auto h-9 w-9 text-[#1A4D2E]/85" />
|
||||
<span className="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-[#1A4D2E] text-[10px] font-bold text-white shadow-sm">
|
||||
{item.quantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-stone-950 text-[15px] leading-tight truncate">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] font-mono uppercase tracking-widest text-stone-500">
|
||||
{item.fulfillment === "ship" ? "Ships to door" : "Pickup at stop"}
|
||||
</p>
|
||||
<p className="mt-1.5 text-[15px] font-bold text-stone-950">
|
||||
{item.price}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Selected stop banner (if any) */}
|
||||
{selectedStop && (
|
||||
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-dashed border-[#1A4D2E]/40 bg-[#1A4D2E]/5 p-3.5">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#1A4D2E] text-white">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a2 2 0 01-2.828 0l-4.243-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-mono uppercase tracking-widest text-[#1A4D2E]/70">Pickup at</p>
|
||||
<p className="text-sm font-semibold text-stone-950 truncate">
|
||||
{selectedStop.city}, {selectedStop.state}
|
||||
</p>
|
||||
<p className="text-[11px] text-stone-600">
|
||||
{formatDate(selectedStop.date)} · {selectedStop.time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtotal */}
|
||||
<div className="mt-5 flex items-baseline justify-between border-t border-stone-900/10 pt-4">
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
Subtotal
|
||||
</span>
|
||||
<span className="font-display text-2xl font-bold text-stone-950">
|
||||
${subtotal.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-stone-500 text-right">
|
||||
+ tax & shipping at checkout
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer — sticky CTA + express */}
|
||||
<div className="border-t border-stone-900/10 bg-[#F5EFD9] px-6 sm:px-8 pt-5 pb-7 space-y-3">
|
||||
{/* Express checkout row — the buttons are visual shortcuts.
|
||||
/checkout auto-renders Stripe's <ExpressCheckoutElement> for
|
||||
Apple Pay / Google Pay / Link + <PaymentElement> for card via
|
||||
StripeExpressCheckout. The `?express=` query is decorative. */}
|
||||
<div>
|
||||
<p className="font-mono text-[9px] uppercase tracking-[0.22em] text-stone-500 mb-2 text-center">
|
||||
Express checkout
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<ExpressButton
|
||||
href="/checkout?express=apple_pay"
|
||||
onClick={onCheckout}
|
||||
label="Pay"
|
||||
icon={<ApplePayMark />}
|
||||
className="bg-black text-white hover:bg-stone-900"
|
||||
/>
|
||||
<ExpressButton
|
||||
href="/checkout?express=google_pay"
|
||||
onClick={onCheckout}
|
||||
label="Pay"
|
||||
icon={<GooglePayMark />}
|
||||
className="bg-white text-stone-900 ring-1 ring-stone-900/10 hover:bg-stone-50"
|
||||
/>
|
||||
<ExpressButton
|
||||
href="/checkout?express=shop_pay"
|
||||
onClick={onCheckout}
|
||||
label="Shop"
|
||||
icon={<ShopPayMark />}
|
||||
className="bg-[#5A31F4] text-white hover:bg-[#4A22E0]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary checkout */}
|
||||
<Link
|
||||
href="/checkout"
|
||||
onClick={onCheckout}
|
||||
className="group flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-[#1A4D2E] text-white font-bold tracking-wide shadow-[0_10px_24px_-8px_rgba(26,77,46,0.5)] hover:bg-[#143C24] active:scale-[0.99] transition-all"
|
||||
>
|
||||
Checkout · ${subtotal.toFixed(2)}
|
||||
<svg className="h-5 w-5 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
{/* Secondary actions */}
|
||||
<div className="flex items-center justify-between pt-1 text-[13px]">
|
||||
<Link
|
||||
href="/cart"
|
||||
onClick={onViewCart}
|
||||
className="font-semibold text-stone-600 hover:text-stone-950 underline-offset-4 hover:underline transition-colors"
|
||||
>
|
||||
View full cart
|
||||
</Link>
|
||||
<button
|
||||
onClick={onContinue}
|
||||
className="font-semibold text-stone-600 hover:text-stone-950 underline-offset-4 hover:underline transition-colors"
|
||||
>
|
||||
Continue shopping
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpressButton({
|
||||
href,
|
||||
onClick,
|
||||
label,
|
||||
icon,
|
||||
className,
|
||||
}: {
|
||||
href: string;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className={`group flex h-12 items-center justify-center gap-1.5 rounded-xl font-bold text-[13px] tracking-tight transition-all active:scale-[0.97] ${className}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Express brand marks (stylized, not official) ─────────────────── */
|
||||
|
||||
function ApplePayMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 28 14" className="h-4 w-auto" aria-hidden="true">
|
||||
<text x="0" y="11" fontFamily="system-ui, -apple-system, sans-serif" fontWeight="700" fontSize="12" fill="currentColor" letterSpacing="-0.5">
|
||||
Pay
|
||||
</text>
|
||||
<path
|
||||
d="M5.5 3.2c.5-.6.8-1.4.7-2.2-.7 0-1.5.5-2 1.1-.4.5-.8 1.3-.7 2.1.8 0 1.6-.4 2-1zm.7.9c-1.1 0-2 .6-2.6.6-.6 0-1.4-.6-2.3-.6-1.2 0-2.3.7-2.9 1.8C-2.9 8.1-2 11 1 11c.8 0 1.4-.5 2.3-.5s1.4.5 2.3.5c1 0 1.6-.8 2.2-1.6.7-1 .9-1.9.9-2-.1 0-1.7-.6-1.7-2.4 0-1.5 1.2-2.2 1.3-2.2-.7-1.1-1.9-1.2-2.3-1.2z"
|
||||
transform="translate(0, -3) scale(0.5)"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GooglePayMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 36 16" className="h-3.5 w-auto" aria-hidden="true">
|
||||
<text
|
||||
x="0"
|
||||
y="12"
|
||||
fontFamily="system-ui, -apple-system, sans-serif"
|
||||
fontWeight="600"
|
||||
fontSize="11"
|
||||
fill="currentColor"
|
||||
letterSpacing="-0.3"
|
||||
>
|
||||
G
|
||||
<tspan dx="0.5" fontWeight="400" fontStyle="italic">
|
||||
Pay
|
||||
</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ShopPayMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 38 14" className="h-3.5 w-auto" aria-hidden="true">
|
||||
<text
|
||||
x="0"
|
||||
y="11"
|
||||
fontFamily="system-ui, -apple-system, sans-serif"
|
||||
fontWeight="700"
|
||||
fontSize="11"
|
||||
fill="currentColor"
|
||||
letterSpacing="-0.4"
|
||||
>
|
||||
shop
|
||||
<tspan fontWeight="400">Pay</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CornGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" className={className} aria-hidden="true">
|
||||
{/* Husk leaves */}
|
||||
<path
|
||||
d="M9 16c-2-4-2-9 0-12 1 3 3 5 5 6"
|
||||
fill="currentColor"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<path
|
||||
d="M23 16c2-4 2-9 0-12-1 3-3 5-5 6"
|
||||
fill="currentColor"
|
||||
opacity="0.4"
|
||||
/>
|
||||
{/* Cob */}
|
||||
<ellipse cx="16" cy="17" rx="6" ry="9" fill="currentColor" />
|
||||
{/* Kernels (dots) */}
|
||||
<g fill="#FAF6EA" opacity="0.7">
|
||||
<circle cx="14" cy="12" r="0.8" />
|
||||
<circle cx="18" cy="12" r="0.8" />
|
||||
<circle cx="13" cy="15" r="0.8" />
|
||||
<circle cx="16" cy="14" r="0.8" />
|
||||
<circle cx="19" cy="15" r="0.8" />
|
||||
<circle cx="14" cy="18" r="0.8" />
|
||||
<circle cx="18" cy="18" r="0.8" />
|
||||
<circle cx="16" cy="20" r="0.8" />
|
||||
<circle cx="14" cy="22" r="0.8" />
|
||||
<circle cx="18" cy="22" r="0.8" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Elements, ExpressCheckoutElement, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
import type { StripeExpressCheckoutElementConfirmEvent } from "@stripe/stripe-js";
|
||||
import { getStripe, hasStripePublishableKey } from "@/lib/stripe-client";
|
||||
import { createRetailPaymentIntent } from "@/actions/billing/retail-payment-intent";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time?: string;
|
||||
location: string;
|
||||
brand_id: string;
|
||||
};
|
||||
|
||||
export type CheckoutItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string; // dollars, with or without $ sign
|
||||
quantity: number;
|
||||
fulfillment?: "pickup" | "ship";
|
||||
};
|
||||
|
||||
type Props = {
|
||||
items: CheckoutItem[];
|
||||
brandId: string | null;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
selectedStop: Stop | null;
|
||||
/** Required shipping address fields (only used when items include ship fulfillment). */
|
||||
shippingState?: string;
|
||||
shippingPostal?: string;
|
||||
shippingCity?: string;
|
||||
/** Called after the user submits via the form submit button (hosted checkout fallback). */
|
||||
onHostedCheckout?: () => void;
|
||||
/** Stable id used to group intents for the same logical checkout (UUID per page load). */
|
||||
checkoutSessionKey?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the embedded Stripe Elements (Express Checkout + Payment Element)
|
||||
* on top of the existing hosted Stripe Checkout fallback.
|
||||
*
|
||||
* Express Checkout Element is the primary CTA — it surfaces Apple Pay /
|
||||
* Google Pay / Link / PayPal buttons automatically based on the user's
|
||||
* browser and wallet availability. Tapping one opens the wallet's
|
||||
* payment sheet, confirms the PaymentIntent in-page, and we navigate
|
||||
* to /checkout/success to create the order.
|
||||
*
|
||||
* If Stripe.js can't load (no publishable key, network error, etc.),
|
||||
* the component renders a graceful error and the parent falls back to
|
||||
* the hosted Stripe Checkout button.
|
||||
*/
|
||||
export default function StripeExpressCheckout(props: Props) {
|
||||
const router = useRouter();
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [paymentIntentId, setPaymentIntentId] = useState<string | null>(null);
|
||||
const [intentAmount, setIntentAmount] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [available, setAvailable] = useState(false);
|
||||
|
||||
const hasShip = props.items.some((i) => i.fulfillment === "ship");
|
||||
const hasPickup = props.items.some(
|
||||
(i) => (i.fulfillment ?? "pickup") === "pickup" && (i as { pickup_type?: string }).pickup_type !== "shed"
|
||||
);
|
||||
|
||||
// Block express checkout if pickup items exist but no stop is selected
|
||||
const stopBlocked = hasPickup && !props.selectedStop;
|
||||
const emailBlocked = !props.customerEmail.trim();
|
||||
|
||||
// Stable key for grouping intents by checkout session (in case of retry)
|
||||
const sessionKey = useMemo(
|
||||
() => props.checkoutSessionKey ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
[props.checkoutSessionKey]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!hasStripePublishableKey()) {
|
||||
setAvailable(false);
|
||||
setError("Stripe publishable key not configured. Use the secure checkout button below.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.items.length === 0) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stopBlocked) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const result = await createRetailPaymentIntent(
|
||||
props.items.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
price: Number(String(i.price).replace(/[$,]/g, "")) || 0,
|
||||
quantity: i.quantity,
|
||||
})),
|
||||
{
|
||||
name: props.customerName,
|
||||
email: props.customerEmail,
|
||||
},
|
||||
props.brandId,
|
||||
props.selectedStop?.id ?? null,
|
||||
hasShip
|
||||
? {
|
||||
state: props.shippingState,
|
||||
postal_code: props.shippingPostal,
|
||||
city: props.shippingCity,
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
if (result.success) {
|
||||
setClientSecret(result.clientSecret);
|
||||
setPaymentIntentId(result.paymentIntentId);
|
||||
setIntentAmount(result.amount);
|
||||
setAvailable(true);
|
||||
setError(null);
|
||||
} else {
|
||||
setAvailable(false);
|
||||
setError(result.error);
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Re-fetch when the inputs that affect the PaymentIntent change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
sessionKey,
|
||||
props.brandId,
|
||||
props.selectedStop?.id,
|
||||
props.customerName,
|
||||
props.customerEmail,
|
||||
hasShip ? `${props.shippingState}|${props.shippingPostal}|${props.shippingCity}` : "",
|
||||
stopBlocked,
|
||||
]);
|
||||
|
||||
// Persist the pending-checkout payload to sessionStorage so the
|
||||
// success page can create the order. The wallet may overwrite the
|
||||
// name/email (Apple Pay provides them), so we read from the PaymentIntent
|
||||
// metadata on the success page where appropriate.
|
||||
function persistPendingCheckout(intentId: string) {
|
||||
if (typeof sessionStorage === "undefined") return;
|
||||
sessionStorage.setItem(
|
||||
"pending_checkout",
|
||||
JSON.stringify({
|
||||
customerName: props.customerName,
|
||||
customerEmail: props.customerEmail,
|
||||
customerPhone: props.customerPhone,
|
||||
stopId: props.selectedStop?.id ?? null,
|
||||
items: props.items.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
price: Number(String(i.price).replace(/[$,]/g, "")) || 0,
|
||||
quantity: i.quantity,
|
||||
fulfillment: (i.fulfillment ?? "pickup") as "pickup" | "ship",
|
||||
})),
|
||||
cartBrandId: props.brandId,
|
||||
shippingAddress: hasShip
|
||||
? {
|
||||
state: props.shippingState,
|
||||
postal_code: props.shippingPostal,
|
||||
city: props.shippingCity,
|
||||
}
|
||||
: undefined,
|
||||
idempotencyKey: sessionKey,
|
||||
paymentIntentId: intentId,
|
||||
paymentIntentAmount: intentAmount,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Stripe.js loader promise
|
||||
const stripePromise = useMemo(() => getStripe(), []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ExpressShell>
|
||||
<div className="flex items-center gap-2 text-stone-500 text-sm py-3">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-stone-300 border-t-stone-700 animate-spin" />
|
||||
Preparing express checkout…
|
||||
</div>
|
||||
</ExpressShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!available || !clientSecret || !stripePromise) {
|
||||
return (
|
||||
<ExpressShell>
|
||||
{error && (
|
||||
<p className="text-xs text-stone-500 mb-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onHostedCheckout}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-stone-900 to-stone-700 px-6 py-3.5 text-sm font-semibold text-white hover:from-stone-800 hover:to-stone-600 transition-all shadow-lg shadow-stone-900/20"
|
||||
>
|
||||
Continue to secure checkout →
|
||||
</button>
|
||||
</ExpressShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpressShell>
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
appearance: {
|
||||
theme: "flat",
|
||||
variables: {
|
||||
colorPrimary: "#1A4D2E",
|
||||
colorBackground: "#ffffff",
|
||||
colorText: "#0a0a0a",
|
||||
colorDanger: "#dc2626",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
spacingUnit: "4px",
|
||||
borderRadius: "12px",
|
||||
},
|
||||
rules: {
|
||||
".Input": { boxShadow: "none", border: "1px solid #e7e5e4" },
|
||||
".Input:focus": { border: "1px solid #1A4D2E" },
|
||||
".Label": { fontWeight: "600", color: "#0a0a0a" },
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CheckoutInner
|
||||
{...props}
|
||||
emailBlocked={emailBlocked}
|
||||
stopBlocked={stopBlocked}
|
||||
paymentIntentId={paymentIntentId}
|
||||
intentAmount={intentAmount}
|
||||
persistPendingCheckout={persistPendingCheckout}
|
||||
onError={setError}
|
||||
onSuccess={() => {
|
||||
// Express / Card confirmed in-page. Navigate to success.
|
||||
router.push("/checkout/success?payment_intent=" + (paymentIntentId ?? ""));
|
||||
}}
|
||||
onHostedCheckout={props.onHostedCheckout}
|
||||
/>
|
||||
</Elements>
|
||||
</ExpressShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpressShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<svg className="h-4 w-4 text-stone-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-700">
|
||||
Express checkout
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type InnerProps = Props & {
|
||||
emailBlocked: boolean;
|
||||
stopBlocked: boolean;
|
||||
paymentIntentId: string | null;
|
||||
intentAmount: number | null;
|
||||
persistPendingCheckout: (intentId: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
function CheckoutInner({
|
||||
items,
|
||||
brandId,
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
selectedStop,
|
||||
shippingState,
|
||||
shippingPostal,
|
||||
shippingCity,
|
||||
emailBlocked,
|
||||
stopBlocked,
|
||||
paymentIntentId,
|
||||
persistPendingCheckout,
|
||||
onError,
|
||||
onSuccess,
|
||||
onHostedCheckout,
|
||||
}: InnerProps) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [expressReady, setExpressReady] = useState(false);
|
||||
|
||||
const blocked = emailBlocked || stopBlocked;
|
||||
const siteUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
|
||||
|
||||
async function handleExpressConfirm(event: StripeExpressCheckoutElementConfirmEvent) {
|
||||
if (blocked) {
|
||||
event.paymentFailed({ reason: 'fail' });
|
||||
onError(
|
||||
emailBlocked
|
||||
? "Enter your email above before paying."
|
||||
: "Pick a pickup stop before paying."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stripe || !elements) {
|
||||
event.paymentFailed({ reason: 'fail' });
|
||||
onError("Stripe is not ready yet. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the pending checkout payload so the success page can
|
||||
// create the order. We do this BEFORE confirmPayment because the
|
||||
// wallet may navigate away briefly during the payment sheet.
|
||||
if (paymentIntentId) persistPendingCheckout(paymentIntentId);
|
||||
|
||||
const { error: confirmError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${siteUrl}/checkout/success?source=express`,
|
||||
payment_method_data: {
|
||||
billing_details: {
|
||||
name: customerName || undefined,
|
||||
email: customerEmail || undefined,
|
||||
phone: customerPhone || undefined,
|
||||
address: shippingState
|
||||
? {
|
||||
state: shippingState,
|
||||
postal_code: shippingPostal,
|
||||
city: shippingCity,
|
||||
country: "US",
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (confirmError) {
|
||||
event.paymentFailed({ reason: 'fail' });
|
||||
onError(confirmError.message ?? "Payment failed. Please try again.");
|
||||
} else {
|
||||
// payment confirmed in-page — wallet closes its sheet automatically.
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCardSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!stripe || !elements) {
|
||||
onError("Stripe is not ready yet. Please try again.");
|
||||
return;
|
||||
}
|
||||
if (blocked) {
|
||||
onError(
|
||||
emailBlocked
|
||||
? "Enter your email above before paying."
|
||||
: "Pick a pickup stop before paying."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
if (paymentIntentId) persistPendingCheckout(paymentIntentId);
|
||||
|
||||
const { error: submitError } = await elements.submit();
|
||||
if (submitError) {
|
||||
onError(submitError.message ?? "Form is incomplete.");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: confirmError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: `${siteUrl}/checkout/success?source=card`,
|
||||
payment_method_data: {
|
||||
billing_details: {
|
||||
name: customerName || undefined,
|
||||
email: customerEmail || undefined,
|
||||
phone: customerPhone || undefined,
|
||||
address: shippingState
|
||||
? {
|
||||
state: shippingState,
|
||||
postal_code: shippingPostal,
|
||||
city: shippingCity,
|
||||
country: "US",
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (confirmError) {
|
||||
onError(confirmError.message ?? "Payment failed.");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Express (Apple Pay, Google Pay, Link, PayPal) */}
|
||||
<div>
|
||||
<ExpressCheckoutElement
|
||||
onConfirm={handleExpressConfirm}
|
||||
onReady={(ev) => {
|
||||
const m = ev.availablePaymentMethods;
|
||||
const any = !!m && (m.applePay || m.googlePay || m.link || m.paypal || m.amazonPay);
|
||||
setExpressReady(any);
|
||||
}}
|
||||
options={{
|
||||
buttonType: { applePay: "buy", googlePay: "buy" },
|
||||
buttonTheme: { applePay: "black", googlePay: "black" },
|
||||
paymentMethodOrder: ["apple_pay", "google_pay", "link", "paypal"],
|
||||
layout: { maxColumns: 3, maxRows: 1 },
|
||||
}}
|
||||
/>
|
||||
{!expressReady && !blocked && (
|
||||
<p className="mt-2 text-[11px] text-stone-500 text-center">
|
||||
No express wallets detected on this device — you can still pay by card below.
|
||||
</p>
|
||||
)}
|
||||
{blocked && (
|
||||
<p className="mt-2 text-[11px] text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-center">
|
||||
{emailBlocked
|
||||
? "Enter your email above to enable express checkout."
|
||||
: "Pick a pickup stop above to enable express checkout."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-stone-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-white px-3 text-[10px] font-bold uppercase tracking-widest text-stone-400">
|
||||
or pay by card
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card form */}
|
||||
<form onSubmit={handleCardSubmit}>
|
||||
<PaymentElement
|
||||
options={{
|
||||
layout: "tabs",
|
||||
fields: { billingDetails: "auto" },
|
||||
wallets: { applePay: "never", googlePay: "never" },
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || !elements || submitting || blocked}
|
||||
className="mt-4 w-full rounded-xl bg-stone-900 hover:bg-stone-800 disabled:bg-stone-400 disabled:cursor-not-allowed px-6 py-3.5 text-sm font-semibold text-white transition-all shadow-lg shadow-stone-900/15"
|
||||
>
|
||||
{submitting ? "Processing…" : "Pay by card"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Fallback to hosted checkout (existing flow) */}
|
||||
{onHostedCheckout && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHostedCheckout}
|
||||
className="w-full text-xs text-stone-500 hover:text-stone-800 underline underline-offset-4 transition-colors"
|
||||
>
|
||||
Having trouble? Use the secure hosted checkout instead.
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence, useScroll, useTransform } from "framer-motion";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import QuickCartSheet from "./QuickCartSheet";
|
||||
import StorefrontHeader from "./StorefrontHeader";
|
||||
import StorefrontFooter from "./StorefrontFooter";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
logoUrl?: string | null;
|
||||
logoUrlDark?: string | null;
|
||||
};
|
||||
|
||||
const PRODUCT = {
|
||||
id: "sweet-corn-box-12",
|
||||
slug: "sweet-corn-box",
|
||||
name: "Fresh Sweet Corn Box – 12 Ears",
|
||||
tagline: "Straight-from-the-farm sweetness in every bite.",
|
||||
price: "$29.99",
|
||||
priceNumeric: 29.99,
|
||||
// Used by the existing /cart flow to filter pickup vs ship
|
||||
type: "Pickup & Shipping",
|
||||
imageUrl:
|
||||
"https://images.unsplash.com/photo-1551754655-cd27e38d2076?auto=format&fit=crop&w=1600&q=80",
|
||||
fallbackGradient:
|
||||
"radial-gradient(ellipse at 50% 50%, #FFE08A 0%, #E5A80F 35%, #8A5A06 80%, #1A4D2E 100%)",
|
||||
};
|
||||
|
||||
const EASE_OUT = [0.22, 0.61, 0.36, 1] as const;
|
||||
|
||||
export default function SweetCornProductPage({ brandId, brandName, logoUrl, logoUrlDark }: Props) {
|
||||
const router = useRouter();
|
||||
const { addToCart, buyNow } = useCart();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [justAdded, setJustAdded] = useState(false);
|
||||
const [buyNowPulse, setBuyNowPulse] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
|
||||
// Parallax for the hero corn ears floating decoration
|
||||
const { scrollY } = useScroll();
|
||||
const floatY1 = useTransform(scrollY, [0, 800], [0, -120]);
|
||||
const floatY2 = useTransform(scrollY, [0, 800], [0, 80]);
|
||||
|
||||
// Auto-dismiss the "Added" pulse after a short delay
|
||||
useEffect(() => {
|
||||
if (!justAdded) return;
|
||||
const t = setTimeout(() => setJustAdded(false), 1800);
|
||||
return () => clearTimeout(t);
|
||||
}, [justAdded]);
|
||||
|
||||
function buildBaseItem() {
|
||||
return {
|
||||
id: PRODUCT.id,
|
||||
name: PRODUCT.name,
|
||||
price: PRODUCT.price,
|
||||
brand_id: brandId,
|
||||
brand_slug: "tuxedo",
|
||||
is_taxable: true,
|
||||
pickup_type: "scheduled_stop" as const,
|
||||
description: PRODUCT.tagline,
|
||||
};
|
||||
}
|
||||
|
||||
function handleAddToCart() {
|
||||
addToCart(buildBaseItem(), "pickup");
|
||||
setJustAdded(true);
|
||||
// Tiny delay so the user sees the "Added" state before the sheet slides up
|
||||
setTimeout(() => setSheetOpen(true), 280);
|
||||
}
|
||||
|
||||
function handleBuyNow() {
|
||||
buyNow(buildBaseItem(), "pickup");
|
||||
setBuyNowPulse(true);
|
||||
setTimeout(() => router.push("/checkout"), 140);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#FAF6EA] text-stone-950">
|
||||
<StorefrontHeader
|
||||
brandName={brandName}
|
||||
brandSlug="tuxedo"
|
||||
logoUrl={logoUrl}
|
||||
logoUrlDark={logoUrlDark}
|
||||
showWholesaleLink
|
||||
brandAccent="green"
|
||||
/>
|
||||
|
||||
<main>
|
||||
{/* ── HERO ─────────────────────────────────────────────────────── */}
|
||||
<section className="relative overflow-hidden bg-[#FAF6EA]">
|
||||
{/* Subtle paper grain */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.035] mix-blend-multiply"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.7'/></svg>\")",
|
||||
}}
|
||||
/>
|
||||
{/* Decorative giant "12" in the background */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-20 -right-10 lg:-right-24 font-display text-[260px] lg:text-[420px] font-black leading-none text-[#E5A80F]/8 select-none pointer-events-none"
|
||||
style={{ color: "rgba(229, 168, 15, 0.08)" }}
|
||||
>
|
||||
12
|
||||
</div>
|
||||
|
||||
{/* Floating decorative corn ears */}
|
||||
<motion.div
|
||||
aria-hidden
|
||||
style={{ y: floatY1 }}
|
||||
className="absolute top-32 right-[6%] hidden lg:block"
|
||||
>
|
||||
<FloatingCorn className="w-16 text-[#1A4D2E]/15 rotate-12" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
aria-hidden
|
||||
style={{ y: floatY2 }}
|
||||
className="absolute bottom-20 left-[4%] hidden lg:block"
|
||||
>
|
||||
<FloatingCorn className="w-12 text-[#E5A80F]/25 -rotate-12" />
|
||||
</motion.div>
|
||||
|
||||
<div className="relative mx-auto max-w-6xl px-5 sm:px-6 lg:px-8 pt-10 sm:pt-14 lg:pt-20 pb-8 lg:pb-12">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1.5 text-[11px] font-mono uppercase tracking-widest text-stone-500">
|
||||
<Link href="/tuxedo" className="hover:text-stone-900 transition-colors">
|
||||
Tuxedo Corn
|
||||
</Link>
|
||||
<Chevron />
|
||||
<Link href="/tuxedo#products" className="hover:text-stone-900 transition-colors">
|
||||
Shop
|
||||
</Link>
|
||||
<Chevron />
|
||||
<span className="text-stone-900">Sweet Corn Box</span>
|
||||
</nav>
|
||||
|
||||
<div className="mt-8 lg:mt-12 grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-14 items-start">
|
||||
{/* LEFT — Visual */}
|
||||
<div className="lg:col-span-7 relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease: EASE_OUT }}
|
||||
className="relative aspect-[4/3] sm:aspect-[5/4] lg:aspect-[6/5] w-full overflow-hidden rounded-[28px] ring-1 ring-stone-900/10 shadow-[0_30px_60px_-20px_rgba(26,77,46,0.35)]"
|
||||
>
|
||||
{/* Gradient fallback (always behind, visible until image loads or on error) */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: PRODUCT.fallbackGradient }}
|
||||
/>
|
||||
{/* Decorative corn cobs inside the image frame */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<CornIllustration className="w-3/5 max-w-[420px] text-[#1A4D2E]/55" />
|
||||
</div>
|
||||
{!imgError && (
|
||||
<Image
|
||||
src={PRODUCT.imageUrl}
|
||||
alt="Freshly harvested Olathe Sweet sweet corn"
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 58vw, 100vw"
|
||||
className={`object-cover transition-opacity duration-700 ${
|
||||
imgLoaded ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onLoad={() => setImgLoaded(true)}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
{/* Bottom scrim for badge legibility */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-black/35 to-transparent pointer-events-none" />
|
||||
{/* Tilted corner stamp */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, rotate: 0, scale: 0.6 }}
|
||||
animate={{ opacity: 1, rotate: -8, scale: 1 }}
|
||||
transition={{ delay: 0.55, type: "spring", stiffness: 200, damping: 14 }}
|
||||
className="absolute top-5 left-5 lg:top-7 lg:left-7 origin-top-left"
|
||||
>
|
||||
<div className="flex h-[88px] w-[88px] lg:h-[100px] lg:w-[100px] flex-col items-center justify-center rounded-full border-2 border-dashed border-white/85 bg-[#1A4D2E]/90 text-white text-center shadow-lg backdrop-blur-sm">
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.18em] text-white/70">
|
||||
Peak
|
||||
</span>
|
||||
<span className="font-display text-[20px] lg:text-[22px] font-black leading-none">
|
||||
SEASON
|
||||
</span>
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.18em] text-white/70">
|
||||
2026
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* Right corner "12 ears" badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
className="absolute bottom-5 right-5 lg:bottom-7 lg:right-7"
|
||||
>
|
||||
<div className="rounded-2xl bg-white/95 px-3.5 py-2 ring-1 ring-stone-900/10 shadow-lg backdrop-blur-sm">
|
||||
<p className="font-mono text-[9px] uppercase tracking-widest text-stone-500 leading-none">
|
||||
Box contains
|
||||
</p>
|
||||
<p className="font-display text-[22px] font-black text-stone-950 leading-none mt-0.5">
|
||||
12 ears
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Thumbnails row (decorative — same image) */}
|
||||
<div className="mt-4 flex gap-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`View image ${i + 1}`}
|
||||
className={`relative aspect-square w-16 sm:w-20 overflow-hidden rounded-xl ring-2 transition-all ${
|
||||
i === 0
|
||||
? "ring-[#1A4D2E]"
|
||||
: "ring-stone-900/10 hover:ring-stone-900/30"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: PRODUCT.fallbackGradient }}
|
||||
/>
|
||||
<CornIllustration className="absolute inset-0 m-auto w-3/5 text-[#1A4D2E]/55" />
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto hidden sm:flex items-center gap-1.5 rounded-xl bg-stone-900/5 px-3 py-2 text-[10px] font-mono uppercase tracking-widest text-stone-600">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Lot 04-26 · In season
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT — Copy + Buy Box */}
|
||||
<div className="lg:col-span-5">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.1, ease: EASE_OUT }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-[#1A4D2E] px-3 py-1 text-[10px] font-bold uppercase tracking-widest text-white">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#FCD34D] animate-pulse" />
|
||||
Ships fresh
|
||||
</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
Same-day harvest when possible
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-5 font-display text-[44px] sm:text-[52px] lg:text-[60px] font-black leading-[0.95] tracking-[-0.02em] text-stone-950">
|
||||
<span className="block">Sweet Corn</span>
|
||||
<span className="block italic font-normal text-[#1A4D2E]">Box</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 font-display text-[19px] sm:text-[21px] leading-[1.35] text-stone-700 italic">
|
||||
{PRODUCT.tagline}
|
||||
</p>
|
||||
|
||||
<p className="mt-5 text-[15px] leading-relaxed text-stone-700 max-w-prose">
|
||||
Our 12-Ear Corn Box is packed with peak-season, super-sweet corn picked
|
||||
that morning and rushed to your door. Perfect for BBQs, family dinners,
|
||||
or freezing for winter.
|
||||
</p>
|
||||
|
||||
{/* Why our corn (inline) */}
|
||||
<ul className="mt-6 space-y-2.5">
|
||||
{WHY_CORN.map((item) => (
|
||||
<li key={item.title} className="flex items-start gap-3">
|
||||
<span className="mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[#1A4D2E] text-white">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<span className="font-bold text-stone-950 text-[14px]">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="text-stone-600 text-[14px]">
|
||||
{item.body && <> — {item.body}</>}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Price + buy box (desktop sticky) */}
|
||||
<div
|
||||
id="buy-box"
|
||||
className="mt-8 lg:mt-10 rounded-3xl bg-white ring-1 ring-stone-900/10 p-6 sm:p-7 shadow-[0_20px_40px_-12px_rgba(26,77,46,0.18)]"
|
||||
>
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
One box · 12 ears
|
||||
</p>
|
||||
<p className="mt-1 font-display text-[44px] font-black leading-none tracking-tight text-stone-950">
|
||||
{PRODUCT.price}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-stone-500">
|
||||
≈ $2.50 / ear · tax included
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
In stock
|
||||
</p>
|
||||
<p className="mt-1 font-display text-[20px] font-bold text-[#1A4D2E]">
|
||||
240 boxes
|
||||
</p>
|
||||
<p className="text-[10px] text-stone-500">ready this week</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
Qty
|
||||
</span>
|
||||
<div className="inline-flex items-center rounded-full ring-1 ring-stone-900/15 bg-stone-50">
|
||||
<button
|
||||
aria-label="Decrease quantity"
|
||||
className="h-10 w-10 flex items-center justify-center text-stone-700 hover:text-stone-950 hover:bg-stone-100 rounded-l-full transition-colors"
|
||||
onClick={() => {
|
||||
/* single-product page — qty is always 1 for now */
|
||||
}}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-10 text-center font-bold text-stone-950">1</span>
|
||||
<button
|
||||
aria-label="Increase quantity"
|
||||
className="h-10 w-10 flex items-center justify-center text-stone-700 hover:text-stone-950 hover:bg-stone-100 rounded-r-full transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="ml-auto text-[12px] text-stone-500 hidden sm:inline">
|
||||
Ships in a compostable cooler
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Add to Cart (primary) */}
|
||||
<motion.button
|
||||
onClick={handleAddToCart}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`mt-5 relative w-full overflow-hidden rounded-2xl h-14 sm:h-16 font-bold text-[15px] sm:text-[16px] tracking-wide transition-all ${
|
||||
justAdded
|
||||
? "bg-[#1A4D2E] text-white"
|
||||
: "bg-stone-950 text-white hover:bg-stone-800"
|
||||
}`}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{justAdded ? (
|
||||
<motion.span
|
||||
key="added"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Added to cart
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="add"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
Add Box to Cart · {PRODUCT.price}
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
|
||||
{/* Buy Now (secondary, urgent orange) */}
|
||||
<motion.button
|
||||
onClick={handleBuyNow}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`mt-3 group relative w-full overflow-hidden rounded-2xl h-12 sm:h-[52px] font-bold text-[14px] tracking-wide transition-all ${
|
||||
buyNowPulse
|
||||
? "bg-[#D97706] text-white"
|
||||
: "bg-[#FFF1D6] text-[#9A4A06] ring-1 ring-[#E5A80F]/40 hover:bg-[#FFE7B0]"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" />
|
||||
</svg>
|
||||
Quick Buy — Ships Today
|
||||
<span className="ml-1 font-mono text-[10px] font-medium uppercase tracking-widest opacity-70 hidden sm:inline">
|
||||
1-click
|
||||
</span>
|
||||
</span>
|
||||
</motion.button>
|
||||
|
||||
{/* Express row (visual only) */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||
<ExpressChip label="Apple Pay" />
|
||||
<ExpressChip label="Google Pay" />
|
||||
<ExpressChip label="Shop Pay" />
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-[11px] text-stone-500">
|
||||
One click. One box. Farm-fresh delivered.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── GUARANTEE / TRUST STRIP ─────────────────────────────────────── */}
|
||||
<section className="relative bg-[#1A4D2E] text-white overflow-hidden">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 opacity-[0.08]"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse at 20% 50%, rgba(252, 211, 77, 0.4) 0%, transparent 50%), radial-gradient(ellipse at 80% 50%, rgba(252, 211, 77, 0.3) 0%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative mx-auto max-w-6xl px-5 sm:px-6 lg:px-8 py-12 sm:py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10">
|
||||
<TrustColumn
|
||||
icon={<TruckIcon />}
|
||||
eyebrow="Ships Fresh"
|
||||
title="Same-day harvest when possible"
|
||||
body="We pick before the heat steals a single calorie of sweetness. By the time your order arrives, the corn was still in the field that morning."
|
||||
/>
|
||||
<TrustColumn
|
||||
icon={<ShieldIcon />}
|
||||
eyebrow="Satisfaction"
|
||||
title="100% Sweetness Guarantee"
|
||||
body="If it’s not the sweetest corn you’ve ever had, we’ll replace it. No questions, no forms, no fuss."
|
||||
accent
|
||||
/>
|
||||
<TrustColumn
|
||||
icon={<SparkleIcon />}
|
||||
eyebrow="Versatile"
|
||||
title="Great for every summer table"
|
||||
body="Grilling, boiling, freezing, or corn-on-the-cob parties — twelve ears is exactly the right amount for a family of four to share."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FARM STORY ─────────────────────────────────────────────────── */}
|
||||
<section className="relative bg-[#F3EDD8]">
|
||||
<div className="mx-auto max-w-6xl px-5 sm:px-6 lg:px-8 py-20 sm:py-28">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center">
|
||||
<div className="lg:col-span-5">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[#9A4A06]">
|
||||
Field notes · Vol. IV
|
||||
</p>
|
||||
<h2 className="mt-4 font-display text-[36px] sm:text-[44px] lg:text-[52px] font-black leading-[1.02] tracking-tight text-stone-950">
|
||||
Picked by hand, <span className="italic font-normal text-[#1A4D2E]">stayed</span> sweet for days.
|
||||
</h2>
|
||||
<p className="mt-5 text-stone-700 text-[16px] leading-relaxed max-w-prose">
|
||||
We hand-select every ear for size and quality. No machine knows when a
|
||||
cob is at peak sugar content. That’s why your box is a little heavier
|
||||
than you expect — and a whole lot sweeter than any corn you’ve bought
|
||||
from a grocery store shelf.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center gap-6">
|
||||
<Stat number="40+" label="Years growing" />
|
||||
<span className="h-10 w-px bg-stone-900/15" />
|
||||
<Stat number="3" label="Generations" />
|
||||
<span className="h-10 w-px bg-stone-900/15" />
|
||||
<Stat number="100%" label="Hand-picked" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-7 relative">
|
||||
<div className="relative aspect-[5/4] overflow-hidden rounded-[28px] ring-1 ring-stone-900/10 shadow-[0_30px_60px_-20px_rgba(26,77,46,0.35)]">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #FCD34D 0%, #E5A80F 45%, #8A5A06 100%)",
|
||||
}}
|
||||
/>
|
||||
{/* Abstract corn field lines */}
|
||||
<svg
|
||||
viewBox="0 0 400 320"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<pattern id="rows" width="50" height="20" patternUnits="userSpaceOnUse" patternTransform="rotate(-8)">
|
||||
<line x1="0" y1="10" x2="50" y2="10" stroke="#1A4D2E" strokeWidth="2" strokeLinecap="round" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="400" height="320" fill="url(#rows)" opacity="0.45" />
|
||||
</svg>
|
||||
{/* Floating cob clusters */}
|
||||
<CornIllustration className="absolute -left-6 top-1/2 -translate-y-1/2 w-32 text-[#1A4D2E]/85 rotate-[-8deg]" />
|
||||
<CornIllustration className="absolute right-6 top-8 w-24 text-[#1A4D2E]/75 rotate-[12deg]" />
|
||||
<CornIllustration className="absolute right-16 bottom-8 w-20 text-[#1A4D2E]/70 -rotate-6" />
|
||||
{/* Sun stamp */}
|
||||
<div className="absolute top-5 right-5 h-20 w-20 rounded-full bg-[#FFE08A] ring-4 ring-[#1A4D2E]/15 flex items-center justify-center">
|
||||
<span className="font-display text-[10px] font-bold text-[#1A4D2E] text-center leading-tight">
|
||||
HAND
|
||||
<br />
|
||||
PICKED
|
||||
</span>
|
||||
</div>
|
||||
{/* Bottom data strip */}
|
||||
<div className="absolute inset-x-4 bottom-4 rounded-2xl bg-stone-950/90 backdrop-blur-sm px-4 py-3 flex items-center justify-between text-white text-[11px] font-mono">
|
||||
<div>
|
||||
<span className="text-white/50">FIELD</span> · 04-N
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/50">BRIX</span> · 24.2°
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white/50">PICK</span> · 06:14
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FAQ MICRO ──────────────────────────────────────────────────── */}
|
||||
<section className="relative bg-[#FAF6EA]">
|
||||
<div className="mx-auto max-w-4xl px-5 sm:px-6 lg:px-8 py-20 sm:py-24">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[#9A4A06] text-center">
|
||||
Buyer’s notes
|
||||
</p>
|
||||
<h2 className="mt-4 font-display text-[36px] sm:text-[44px] font-black leading-tight text-center text-stone-950">
|
||||
Questions, answered.
|
||||
</h2>
|
||||
<div className="mt-12 divide-y divide-stone-900/10 border-t border-b border-stone-900/10">
|
||||
{FAQS.map((faq) => (
|
||||
<FAQItem key={faq.q} q={faq.q} a={faq.a} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FINAL CTA STRIP (desktop) ─────────────────────────────────── */}
|
||||
<section className="hidden lg:block relative bg-stone-950 text-white overflow-hidden">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse at 30% 50%, rgba(229, 168, 15, 0.5) 0%, transparent 60%), radial-gradient(ellipse at 80% 50%, rgba(26, 77, 46, 0.6) 0%, transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative mx-auto max-w-6xl px-8 py-16 flex items-center justify-between gap-10">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[#FCD34D]">
|
||||
Ready when you are
|
||||
</p>
|
||||
<h3 className="mt-3 font-display text-[40px] font-black leading-[1.05]">
|
||||
Twelve ears. <span className="italic font-normal">One click.</span>
|
||||
</h3>
|
||||
<p className="mt-2 text-stone-400 max-w-md">
|
||||
Free shipping on orders over $40. Cancel anytime before harvest.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="rounded-2xl h-14 px-7 bg-white text-stone-950 font-bold hover:bg-stone-100 active:scale-[0.98] transition-all"
|
||||
>
|
||||
Add to cart — {PRODUCT.price}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBuyNow}
|
||||
className="rounded-2xl h-14 px-7 bg-[#FCD34D] text-stone-950 font-bold hover:bg-[#FBBF24] active:scale-[0.98] transition-all"
|
||||
>
|
||||
Quick Buy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* ── STICKY MOBILE BOTTOM CTA BAR ──────────────────────────────────── */}
|
||||
<div className="lg:hidden fixed inset-x-0 bottom-0 z-40 bg-[#FAF6EA]/95 backdrop-blur-xl border-t border-stone-900/10 shadow-[0_-12px_30px_-12px_rgba(26,77,46,0.3)]">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-mono text-[9px] uppercase tracking-widest text-stone-500 leading-none">
|
||||
12-Ear Box
|
||||
</p>
|
||||
<p className="mt-1 font-display text-[26px] font-black leading-none text-stone-950">
|
||||
{PRODUCT.price}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBuyNow}
|
||||
className="h-14 min-w-[120px] rounded-2xl bg-[#FFF1D6] text-[#9A4A06] font-bold text-[14px] ring-1 ring-[#E5A80F]/40 active:scale-[0.98] transition-all"
|
||||
>
|
||||
Quick Buy
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="h-14 min-w-[160px] rounded-2xl bg-stone-950 text-white font-bold text-[15px] active:scale-[0.98] transition-all"
|
||||
>
|
||||
Add · {PRODUCT.price}
|
||||
</button>
|
||||
</div>
|
||||
{/* Safe-area spacer for iOS */}
|
||||
<div className="h-[env(safe-area-inset-bottom)]" />
|
||||
</div>
|
||||
|
||||
{/* Bottom padding so sticky bar doesn't cover last content on mobile */}
|
||||
<div className="lg:hidden h-28" aria-hidden />
|
||||
|
||||
<StorefrontFooter
|
||||
brandName={brandName}
|
||||
brandSlug="tuxedo"
|
||||
logoUrl={logoUrl}
|
||||
logoUrlDark={logoUrlDark}
|
||||
brandAccent="green"
|
||||
/>
|
||||
|
||||
<QuickCartSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
productName={PRODUCT.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Sub-components ─────────────────────────────────────────────── */
|
||||
|
||||
function TrustColumn({
|
||||
icon,
|
||||
eyebrow,
|
||||
title,
|
||||
body,
|
||||
accent,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
body: string;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-2xl ${
|
||||
accent ? "bg-[#FCD34D] text-stone-950" : "bg-white/10 text-[#FCD34D]"
|
||||
} ring-1 ring-white/15`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<p
|
||||
className={`mt-5 font-mono text-[10px] uppercase tracking-[0.25em] ${
|
||||
accent ? "text-[#FCD34D]" : "text-white/60"
|
||||
}`}
|
||||
>
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h3 className="mt-2 font-display text-[24px] font-bold leading-[1.15] text-white">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-3 text-[14px] leading-relaxed text-white/75 max-w-sm">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ number, label }: { number: string; label: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="font-display text-[40px] font-black leading-none text-stone-950">
|
||||
{number}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-[10px] uppercase tracking-widest text-stone-500">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FAQItem({ q, a }: { q: string; a: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="py-5">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between gap-4 text-left group"
|
||||
>
|
||||
<span className="font-display text-[18px] sm:text-[20px] font-bold text-stone-950 group-hover:text-[#1A4D2E] transition-colors">
|
||||
{q}
|
||||
</span>
|
||||
<span
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ring-1 ring-stone-900/15 transition-transform duration-300 ${
|
||||
open ? "rotate-45 bg-stone-950 text-white" : "text-stone-700"
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: EASE_OUT }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pt-3 text-[15px] leading-relaxed text-stone-700 max-w-prose">{a}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chevron() {
|
||||
return (
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpressChip({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-9 items-center justify-center rounded-lg bg-stone-100 ring-1 ring-stone-900/5 text-[11px] font-semibold text-stone-700">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TruckIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7h11v10H3z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14 10h4l3 3v4h-7z" />
|
||||
<circle cx="7" cy="18" r="2" />
|
||||
<circle cx="17" cy="18" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 2l8 4v6c0 5-3.5 9-8 10-4.5-1-8-5-8-10V6l8-4z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SparkleIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3l2 5 5 2-5 2-2 5-2-5-5-2 5-2 2-5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CornIllustration({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 140" className={className} aria-hidden="true">
|
||||
{/* Husk leaves */}
|
||||
<path
|
||||
d="M30 70c-12-15-15-40-5-60 5 18 15 30 25 38"
|
||||
fill="currentColor"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<path
|
||||
d="M70 70c12-15 15-40 5-60-5 18-15 30-25 38"
|
||||
fill="currentColor"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<path
|
||||
d="M50 75c-3-20 0-50 5-65 1 20-1 45-3 60"
|
||||
fill="currentColor"
|
||||
opacity="0.45"
|
||||
/>
|
||||
{/* Cob */}
|
||||
<ellipse cx="50" cy="78" rx="20" ry="34" fill="currentColor" />
|
||||
{/* Kernels */}
|
||||
<g fill="#FAF6EA" opacity="0.75">
|
||||
{Array.from({ length: 8 }).map((_, row) =>
|
||||
Array.from({ length: 5 }).map((_, col) => {
|
||||
const x = 50 - 14 + col * 7 + (row % 2 ? 3.5 : 0);
|
||||
const y = 55 + row * 6;
|
||||
return <circle key={`${row}-${col}`} cx={x} cy={y} r="1.6" />;
|
||||
})
|
||||
)}
|
||||
</g>
|
||||
{/* Silk at top */}
|
||||
<g stroke="currentColor" strokeWidth="1.4" fill="none" opacity="0.7" strokeLinecap="round">
|
||||
<path d="M50 45c-6-8-12-12-18-12" />
|
||||
<path d="M50 45c0-10 4-15 8-20" />
|
||||
<path d="M50 45c6-6 12-8 18-10" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FloatingCorn({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 60 80" className={className} aria-hidden="true">
|
||||
<ellipse cx="30" cy="40" rx="14" ry="24" fill="currentColor" />
|
||||
<g fill="white" opacity="0.3">
|
||||
<circle cx="26" cy="28" r="1.2" />
|
||||
<circle cx="32" cy="32" r="1.2" />
|
||||
<circle cx="28" cy="38" r="1.2" />
|
||||
<circle cx="34" cy="42" r="1.2" />
|
||||
<circle cx="26" cy="48" r="1.2" />
|
||||
<circle cx="32" cy="52" r="1.2" />
|
||||
<circle cx="28" cy="58" r="1.2" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Static data ─────────────────────────────────────────────── */
|
||||
|
||||
const WHY_CORN = [
|
||||
{ title: "Naturally grown & non-GMO", body: "no lab has touched this seed" },
|
||||
{ title: "Harvested at peak sugar content", body: "before the heat can steal a calorie" },
|
||||
{ title: "Hand-selected for size & quality", body: "the only machine we trust is a cooler" },
|
||||
{ title: "Stays sweet for days in the fridge", body: "and freezes beautifully for winter" },
|
||||
];
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "How fast will my box ship?",
|
||||
a: "Same-day harvest when the weather cooperates. You’ll get a tracking link the moment the cooler leaves the farm — typically within 24 hours of your order.",
|
||||
},
|
||||
{
|
||||
q: "Can I pick up at a local stop instead?",
|
||||
a: "Yes — at checkout, choose pickup and select a stop near you. We deliver to scheduled stops in Colorado, Utah, New Mexico, and Arizona through the summer.",
|
||||
},
|
||||
{
|
||||
q: "What if my corn isn’t the sweetest I’ve ever had?",
|
||||
a: "We replace it. Send a photo to hello@tuxedocorn.com and we’ll send a fresh box the next morning. No forms, no questions.",
|
||||
},
|
||||
{
|
||||
q: "How long does it stay fresh?",
|
||||
a: "Seven days in the fridge with the husks on. Three months in the freezer if you blanch it for three minutes first.",
|
||||
},
|
||||
];
|
||||
@@ -20,6 +20,8 @@ type StopInfo = {
|
||||
brand_id: string;
|
||||
};
|
||||
|
||||
export type { StopInfo };
|
||||
|
||||
type CartContextType = {
|
||||
cart: CartItem[];
|
||||
subtotal: number;
|
||||
@@ -30,6 +32,12 @@ type CartContextType = {
|
||||
cartRestored: boolean; // true when server cart was loaded (for toast)
|
||||
setSelectedStop: (stop: StopInfo | null) => void;
|
||||
addToCart: (item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship") => void;
|
||||
/**
|
||||
* Replaces the cart with a single item (clears other items + selected stop)
|
||||
* and returns the resulting item so the caller can navigate to /checkout
|
||||
* for a true 1-tap "Buy Now" flow.
|
||||
*/
|
||||
buyNow: (item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship") => CartItem;
|
||||
increaseQuantity: (id: string) => void;
|
||||
decreaseQuantity: (id: string) => void;
|
||||
removeFromCart: (id: string) => void;
|
||||
@@ -201,6 +209,27 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the entire cart with this single item. Use for "Buy Now" /
|
||||
* "Quick Buy" flows where the buyer wants to skip the cart step and go
|
||||
* straight to checkout. The returned CartItem reflects the final state
|
||||
* (existing items of the same product are merged by quantity).
|
||||
*/
|
||||
function buyNow(item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship"): CartItem {
|
||||
// Always clear selected stop on a buy-now — the buyer is bypassing the
|
||||
// standard stop-picker flow and will pick a stop at checkout.
|
||||
setSelectedStop(null);
|
||||
|
||||
const finalItem: CartItem = {
|
||||
...item,
|
||||
quantity: 1,
|
||||
fulfillment: fulfillment ?? "pickup",
|
||||
};
|
||||
setCart([finalItem]);
|
||||
setJustAdded(finalItem);
|
||||
return finalItem;
|
||||
}
|
||||
|
||||
function increaseQuantity(id: string) {
|
||||
setCart((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, quantity: item.quantity + 1 } : item))
|
||||
@@ -291,6 +320,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
cartRestored: showRestoredToast,
|
||||
setSelectedStop: setSelectedStop,
|
||||
addToCart,
|
||||
buyNow,
|
||||
increaseQuantity,
|
||||
decreaseQuantity,
|
||||
removeFromCart,
|
||||
@@ -306,7 +336,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
export function useCart(): CartContextType {
|
||||
const context = useContext(CartContext);
|
||||
if (!context) throw new Error("useCart must be used inside CartProvider");
|
||||
return context;
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
// Shared AdminUser type — safe to import from both server and client components
|
||||
//
|
||||
// `brand_id` is the active brand (one of `brand_ids`, or null for platform_admin).
|
||||
// `brand_ids` is the full list of brands the admin can act in.
|
||||
// - platform_admin: `brand_id = null`, `brand_ids = []` (in dev) or all brands
|
||||
// (resolved by `listBrandsForAdmin`).
|
||||
// - multi_brand_admin: `brand_id` = selected/cookie brand, `brand_ids` = 2+.
|
||||
// - brand_admin / store_employee / staff: `brand_id` = their single brand,
|
||||
// `brand_ids = [that one]`.
|
||||
export type AdminUser = {
|
||||
id?: string;
|
||||
user_id: string;
|
||||
brand_id: string | null;
|
||||
role: "platform_admin" | "brand_admin" | "store_employee" | "staff";
|
||||
brand_ids: string[];
|
||||
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
|
||||
active: boolean;
|
||||
can_manage_products: boolean;
|
||||
can_manage_stops: boolean;
|
||||
can_manage_orders: boolean;
|
||||
@@ -15,4 +25,4 @@ export type AdminUser = {
|
||||
can_manage_reports: boolean;
|
||||
can_manage_settings: boolean;
|
||||
must_change_password?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { cookies } from "next/headers";
|
||||
import type { AdminUser } from "./admin-permissions-types";
|
||||
export type { AdminUser } from "./admin-permissions-types";
|
||||
|
||||
export type AdminUser = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
brand_id: string | null;
|
||||
role: string;
|
||||
active: boolean;
|
||||
can_manage_products: boolean;
|
||||
can_manage_stops: boolean;
|
||||
can_manage_orders: boolean;
|
||||
can_manage_pickup: boolean;
|
||||
can_manage_messages: boolean;
|
||||
can_manage_refunds: boolean;
|
||||
can_manage_users: boolean;
|
||||
can_manage_water_log: boolean;
|
||||
can_manage_reports: boolean;
|
||||
can_manage_settings: boolean;
|
||||
must_change_password: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the current admin user, or `null` if not authenticated.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Mock data mode (NEXT_PUBLIC_USE_MOCK_DATA=true) → platform_admin dev.
|
||||
* 2. `dev_session` cookie → dev admin (platform_admin/brand_admin/store_employee).
|
||||
* 3. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids.
|
||||
*
|
||||
* `brand_id` is the active brand; `brand_ids` is the full membership list.
|
||||
* For dev sessions without a real DB, `brand_ids` is populated by:
|
||||
* - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table)
|
||||
* - store_employee: `[<first real brand>]` if a brand exists, else `[]`
|
||||
* - brand_admin: `[]` (legacy dev; we don't have a way to scope brand in dev)
|
||||
*/
|
||||
export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
@@ -76,7 +73,7 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
if (res.ok) {
|
||||
const inserted = await res.json().catch(() => null);
|
||||
if (inserted && inserted.length > 0) {
|
||||
return buildAdminUser(inserted[0] as Record<string, unknown>);
|
||||
return buildAdminUser(inserted[0] as Record<string, unknown>, []);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -88,11 +85,56 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
const admin = adminUsers[0] as Record<string, unknown>;
|
||||
if (!admin.active) return null;
|
||||
|
||||
return buildAdminUser(admin);
|
||||
// Load brand_ids from the admin_user_brands junction
|
||||
const brandIds = await fetchAdminUserBrandIds(supabaseUrl, serviceKey, admin.id as string);
|
||||
|
||||
return buildAdminUser(admin, brandIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load `brand_ids` from the admin_user_brands junction for the given admin row.
|
||||
* Returns an empty array on any failure (e.g. before migration 207 is applied).
|
||||
*/
|
||||
async function fetchAdminUserBrandIds(
|
||||
supabaseUrl: string,
|
||||
serviceKey: string,
|
||||
adminRowId: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/admin_user_brands?admin_user_id=eq.${adminRowId}&select=brand_id`,
|
||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json().catch(() => []);
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data
|
||||
.map((row: Record<string, unknown>) => row.brand_id as string)
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildDevAdmin(role: string): AdminUser {
|
||||
const base = { id: "dev", user_id: "dev", brand_id: null, role, active: true, must_change_password: false };
|
||||
// For dev sessions we don't have an admin_user_brands junction row to load.
|
||||
// - platform_admin: `brand_ids = []` (listBrandsForAdmin resolves against brands).
|
||||
// - store_employee: `brand_ids = []` (dev AdminAccessDenied is acceptable;
|
||||
// this is the documented limitation — re-read spec section on getAdminUser
|
||||
// step 1. We skip the spec's "fetch first real brand" complexity here in
|
||||
// favour of keeping dev session cheap and DB-independent).
|
||||
// - brand_admin: `brand_ids = []` (same rationale).
|
||||
// `role` is narrowed to the strict union — we know the dev callers pass
|
||||
// only valid values.
|
||||
const base = {
|
||||
id: "dev",
|
||||
user_id: "dev",
|
||||
brand_id: null,
|
||||
brand_ids: [] as string[],
|
||||
role: role as AdminUser["role"],
|
||||
active: true,
|
||||
must_change_password: false,
|
||||
};
|
||||
if (role === "store_employee") {
|
||||
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
|
||||
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
|
||||
@@ -103,10 +145,28 @@ function buildDevAdmin(role: string): AdminUser {
|
||||
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true };
|
||||
}
|
||||
|
||||
function buildAdminUser(r: Record<string, unknown>): AdminUser {
|
||||
const role = r.role as string;
|
||||
const base = { id: r.id as string, user_id: r.user_id as string, brand_id: r.brand_id as string | null,
|
||||
role, active: r.active as boolean, must_change_password: Boolean(r.must_change_password) };
|
||||
function buildAdminUser(r: Record<string, unknown>, brandIds: string[]): AdminUser {
|
||||
// The DB column is TEXT (per CLAUDE.md) so the runtime value is a string.
|
||||
// We narrow it to the known union here. If the DB has an unknown role
|
||||
// (e.g. a future role), the migration's CHECK constraint will reject it
|
||||
// before it ever reaches this function.
|
||||
const role = r.role as AdminUser["role"];
|
||||
// `brand_id` is the *legacy* single-brand column — preserved here as-is.
|
||||
// The canonical "active brand" is resolved by `getActiveBrandId` on each
|
||||
// page/action, which considers URL params, the active_brand_id cookie,
|
||||
// and this legacy fallback. Setting `brand_id` here to a sensible default
|
||||
// (legacy → first of brand_ids) keeps the AdminUser shape useful even
|
||||
// for callers that haven't migrated to `getActiveBrandId` yet.
|
||||
const legacyBrandId = (r.brand_id as string | null) ?? null;
|
||||
const base = {
|
||||
id: r.id as string,
|
||||
user_id: r.user_id as string,
|
||||
brand_id: legacyBrandId ?? brandIds[0] ?? null,
|
||||
brand_ids: brandIds,
|
||||
role,
|
||||
active: r.active as boolean,
|
||||
must_change_password: Boolean(r.must_change_password),
|
||||
};
|
||||
if (role === "platform_admin") {
|
||||
return { ...base, can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
|
||||
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
|
||||
@@ -122,4 +182,4 @@ function buildAdminUser(r: Record<string, unknown>): AdminUser {
|
||||
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
|
||||
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
|
||||
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
|
||||
}
|
||||
}
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import NextAuth from "next-auth";
|
||||
import PostgresAdapter from "@auth/pg-adapter";
|
||||
import { Pool } from "pg";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import {
|
||||
authConfig,
|
||||
isDevLoginEnabled,
|
||||
} from "@/auth.config";
|
||||
|
||||
/**
|
||||
* Build the dev Credentials provider. Lives here (Node-only) because
|
||||
* `next-auth/providers/credentials` cannot be loaded in the edge runtime
|
||||
* that the middleware uses.
|
||||
*/
|
||||
function buildDevCredentialsProvider() {
|
||||
return Credentials({
|
||||
id: "dev-login",
|
||||
name: "Dev login",
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(creds) {
|
||||
if (!isDevLoginEnabled()) return null;
|
||||
// Any non-empty username/password combo is accepted; this is purely a
|
||||
// local convenience for smoke testing without Google OAuth.
|
||||
const username = String(creds?.username ?? "").trim();
|
||||
const password = String(creds?.password ?? "");
|
||||
if (!username || !password) return null;
|
||||
|
||||
return {
|
||||
id: `dev-${username.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
|
||||
name: username,
|
||||
email: `${username}@dev.local`,
|
||||
// Custom field surfaced via `jwt` callback if needed
|
||||
devRole: "platform_admin",
|
||||
} as unknown as { id: string; name: string; email: string };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Postgres pool for Auth.js. Reuses the same database the rest of
|
||||
* the app talks to (via `pg`). Lives behind a module-level singleton so
|
||||
* Next.js hot reload doesn't open a new pool on every request.
|
||||
*
|
||||
* Note: in production, `DATABASE_URL` should be the only DB env var. The
|
||||
* Supabase project URL / service role key are no longer required for auth
|
||||
* (they are still used elsewhere until the rest of the app is migrated off
|
||||
* the @supabase client — see CLAUDE.md).
|
||||
*/
|
||||
const globalForPool = globalThis as unknown as { __pgPool?: Pool };
|
||||
|
||||
function getPool(): Pool {
|
||||
if (globalForPool.__pgPool) return globalForPool.__pgPool;
|
||||
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ??
|
||||
process.env.SUPABASE_DB_URL ??
|
||||
process.env.POSTGRES_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
// Don't throw at module load — let route handlers return a clean 500
|
||||
// if env is missing. The smoke test instructions tell the user to
|
||||
// set DATABASE_URL.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[auth] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — Auth.js database adapter will not be wired up."
|
||||
);
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
// Reasonable defaults; override via connection string if you need more
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
});
|
||||
globalForPool.__pgPool = pool;
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final server-side Auth.js config.
|
||||
*
|
||||
* Builds on `authConfig` (edge-safe) and layers on:
|
||||
* 1. The Postgres database adapter
|
||||
* 2. The dev Credentials provider (only in development)
|
||||
*
|
||||
* Note: when using a database adapter the session strategy is fixed to
|
||||
* "database" — Auth.js will persist sessions in the `sessions` table.
|
||||
*/
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
...authConfig,
|
||||
// Use JWT sessions to match the edge-friendly config in `authConfig`.
|
||||
// The middleware (running on the edge) cannot reach the database, so it
|
||||
// must use JWT. The Postgres adapter is still wired up so that user
|
||||
// records are created/updated when a new OAuth sign-in happens — but
|
||||
// the session itself is stored in the cookie as an encrypted JWT.
|
||||
adapter: PostgresAdapter(getPool()),
|
||||
// `session.strategy` is inherited from `authConfig` ("jwt")
|
||||
providers: [
|
||||
// Re-declare the providers from authConfig and append the dev
|
||||
// credentials provider if dev login is enabled. (NextAuth merges by
|
||||
// provider id, so this overrides the edge stubs.)
|
||||
...authConfig.providers,
|
||||
...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []),
|
||||
],
|
||||
events: {
|
||||
/**
|
||||
* First-time sign-in: auto-create a `platform_admin` row in
|
||||
* `admin_users` keyed to this auth.js user id, mirroring the legacy
|
||||
* `rc_auth_uid` flow. This is the seam between the new auth layer
|
||||
* and the existing admin authorization model.
|
||||
*/
|
||||
async signIn({ user }) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const userId = user.id;
|
||||
if (!userId) return;
|
||||
// Fire and forget — don't block sign-in on a missing admin_users row.
|
||||
await pool.query(
|
||||
`SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
// Note: we don't auto-create here; the existing `getAdminUser()`
|
||||
// in `src/lib/admin-permissions.ts` is the source of truth for
|
||||
// role lookups and is unchanged. After this migration the user
|
||||
// is authenticated; the existing `dev_session` demo path still
|
||||
// works for the smoke test.
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[auth] signIn event error (non-fatal):", e);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Brand-scope helpers for multi-brand admin support.
|
||||
*
|
||||
* Resolution order (documented in
|
||||
* docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md):
|
||||
* 1. URL/explicit `requested` brand id (highest priority)
|
||||
* 2. `active_brand_id` cookie (the persistent "what brand am I in right now")
|
||||
* 3. `adminUser.brand_id` (legacy single-brand fallback)
|
||||
* 4. First of `adminUser.brand_ids`
|
||||
* 5. (platform_admin only) `null` → "all brands"
|
||||
*
|
||||
* For non-platform-admins, the returned brand is validated against
|
||||
* `adminUser.brand_ids` — if `requested` or the cookie brand is not in the
|
||||
* admin's accessible brands, the resolver falls through to a brand the admin
|
||||
* does have access to (silent recovery).
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import type { AdminUser } from "./admin-permissions-types";
|
||||
|
||||
export const ACTIVE_BRAND_COOKIE = "active_brand_id";
|
||||
|
||||
/**
|
||||
* Resolve the active brand id for the given admin user.
|
||||
*
|
||||
* @param adminUser - The current admin user (must already be loaded).
|
||||
* @param requested - Optional explicit brand id (e.g. from a URL param).
|
||||
* When set and the admin has access, wins over cookie.
|
||||
* @returns The brand id to act in, or `null` for platform_admin "all brands".
|
||||
*/
|
||||
export async function getActiveBrandId(
|
||||
adminUser: AdminUser,
|
||||
requested?: string | null
|
||||
): Promise<string | null> {
|
||||
const cookieStore = await cookies();
|
||||
const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null;
|
||||
|
||||
// platform_admin: requested > cookie > null (all brands)
|
||||
if (adminUser.role === "platform_admin") {
|
||||
return requested ?? cookieBrand ?? null;
|
||||
}
|
||||
|
||||
// Non-platform-admin: validate that requested/cookie brands are accessible
|
||||
if (requested && adminUser.brand_ids.includes(requested)) {
|
||||
return requested;
|
||||
}
|
||||
if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) {
|
||||
return cookieBrand;
|
||||
}
|
||||
|
||||
// Fall back to the legacy single brand, then first of the membership list
|
||||
return adminUser.brand_id ?? adminUser.brand_ids[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the persistent active-brand cookie. Should only be called after
|
||||
* validating the admin has access (use `assertBrandAccess`).
|
||||
*/
|
||||
export async function setActiveBrandCookie(brandId: string): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(ACTIVE_BRAND_COOKIE, brandId, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the active-brand cookie. Used when platform_admin selects
|
||||
* "All brands" (the cookie absence = "no specific brand pinned").
|
||||
*/
|
||||
export async function clearActiveBrandCookie(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(ACTIVE_BRAND_COOKIE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws if the admin user is not a platform_admin and does not have the
|
||||
* given brand in their membership list. Use this for server actions and
|
||||
* API routes that receive a brandId from URL/form/RPC return rather than
|
||||
* `getActiveBrandId`.
|
||||
*/
|
||||
export function assertBrandAccess(adminUser: AdminUser, brandId: string): void {
|
||||
if (adminUser.role === "platform_admin") return;
|
||||
if (!adminUser.brand_ids.includes(brandId)) {
|
||||
throw new Error("Brand access denied");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Browser-only Stripe.js loader.
|
||||
*
|
||||
* Reads `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and returns a cached
|
||||
* `loadStripe` promise. Never call this from a server component — it
|
||||
* pulls in `@stripe/stripe-js` which depends on `window`.
|
||||
*
|
||||
* The published key is safe to ship to the browser; the secret key
|
||||
* never leaves the server (see `src/actions/billing/retail-checkout.ts`).
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||
|
||||
let stripePromise: Promise<Stripe | null> | null = null;
|
||||
|
||||
/**
|
||||
* Returns the cached Stripe.js promise, creating it on the first call.
|
||||
* Returns `null` if the publishable key is not configured — callers
|
||||
* should fall back to the hosted Stripe Checkout flow in that case.
|
||||
*/
|
||||
export function getStripe(): Promise<Stripe | null> | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!key) return null;
|
||||
if (!stripePromise) {
|
||||
stripePromise = loadStripe(key);
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
|
||||
/** Synchronous check — useful for deciding whether to render Stripe Elements. */
|
||||
export function hasStripePublishableKey(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// Supabase Auth Middleware - keeps existing auth working
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
// Public routes that don't require authentication
|
||||
const publicRoutes = [
|
||||
"/",
|
||||
"/login",
|
||||
"/login2",
|
||||
"/register",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/pricing",
|
||||
"/terms-and-conditions",
|
||||
"/privacy-policy",
|
||||
"/contact",
|
||||
"/api/health",
|
||||
"/api/stripe/webhook",
|
||||
"/api/resend/webhook",
|
||||
// Brand storefronts are public
|
||||
"/tuxedo",
|
||||
"/tuxedo/*",
|
||||
"/indian-river-direct",
|
||||
"/indian-river-direct/*",
|
||||
"/cart",
|
||||
"/cart/*",
|
||||
"/checkout",
|
||||
"/checkout/*",
|
||||
// Error pages
|
||||
"/error",
|
||||
"/not-found",
|
||||
];
|
||||
|
||||
// Admin routes that require auth
|
||||
const adminRoutes = ["/admin", "/water/admin"];
|
||||
|
||||
// Wholesale routes
|
||||
const wholesaleRoutes = ["/wholesale"];
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Check if route is public
|
||||
const isPublicRoute = publicRoutes.some(
|
||||
(route) => pathname === route || pathname.startsWith(route.replace("/*", ""))
|
||||
);
|
||||
|
||||
if (isPublicRoute) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check for auth cookie (Supabase session)
|
||||
const hasAuthCookie =
|
||||
request.cookies.get("rc_auth_uid")?.value ||
|
||||
request.cookies.get("rc_uid")?.value ||
|
||||
request.cookies.get("dev_session")?.value;
|
||||
|
||||
if (!hasAuthCookie) {
|
||||
// Redirect to login
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("redirect", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// Check for admin routes (may need additional role checking)
|
||||
const isAdminRoute = adminRoutes.some((route) => pathname.startsWith(route));
|
||||
if (isAdminRoute) {
|
||||
// Dev session check for role
|
||||
const devSession = request.cookies.get("dev_session")?.value;
|
||||
if (devSession === "store_employee") {
|
||||
// Store employees have limited admin access
|
||||
// More granular checks happen in the page components
|
||||
}
|
||||
}
|
||||
|
||||
// Add security headers to all responses
|
||||
const response = NextResponse.next();
|
||||
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("X-XSS-Protection", "1; mode=block");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Skip Next.js internals and all files in the _next directory
|
||||
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authConfig } from "@/auth.config";
|
||||
|
||||
/**
|
||||
* Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16).
|
||||
* This is the single source of truth for route protection. The legacy
|
||||
* `src/middleware.ts` has been deleted (Next.js only runs one).
|
||||
*
|
||||
* Why an `auth` wrapper instead of a hand-rolled `NextResponse.next()`?
|
||||
* 1. Auth.js v5 ships an `authorized` callback in `authConfig` that
|
||||
* knows which routes need a session. We reuse it here at the edge.
|
||||
* 2. It auto-populates `request.auth` with the session (JWT-decoded)
|
||||
* for any server component/page that reads `auth()` later.
|
||||
*
|
||||
* Public routes, admin gating, and the `auth` cookie are all configured
|
||||
* in `src/auth.config.ts`.
|
||||
*/
|
||||
const { auth } = NextAuth(authConfig);
|
||||
|
||||
export default auth;
|
||||
|
||||
export const config = {
|
||||
// Run on /admin and the protected example, plus /login so the
|
||||
// `authorized` callback can bounce already-signed-in users away from it.
|
||||
matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"],
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user