Compare commits
100 Commits
v2.0.0
..
661c6e8a86
| Author | SHA1 | Date | |
|---|---|---|---|
| 661c6e8a86 | |||
| 1fa8a78f82 | |||
| afd96b93e1 | |||
| d913ca194e | |||
| f4d5a56181 | |||
| 139445879e | |||
| 818bd4d47b | |||
| 8a912b1e62 | |||
| f7a02fe127 | |||
| 4c428fd9da | |||
| c67df118e2 | |||
| d9a45b0b33 | |||
| cf451f0580 | |||
| 493ca4047f | |||
| cbd6eda640 | |||
| 37998aad46 | |||
| 1577f6363b | |||
| 9b9643e2c7 | |||
| d629cfe3d5 | |||
| 827b9ef318 | |||
| 6fc641a8d4 | |||
| dd545c00d6 | |||
| 487fafc828 | |||
| 5560727cd8 | |||
| 804940ae78 | |||
| 3ac167799b | |||
| 987ddcc25d | |||
| 506b062917 | |||
| 9256d7ac38 | |||
| 4e059e6def | |||
| 3f2c99fbb2 | |||
| 837f7f83cb | |||
| 532511e1b5 | |||
| 6d2c90ae6b | |||
| ef565fbfb1 | |||
| 895defa453 | |||
| 9edbfc2e1b | |||
| 6075d22a84 | |||
| cc5c0e49be | |||
| d50ec4deda | |||
| d3b8e4f7cd | |||
| 6a9807a3be | |||
| 182ccf2c72 | |||
| 6ffd07c54e | |||
| 417379d1af | |||
| ead638d9ae | |||
| f00b327477 | |||
| 6545c2e0d4 | |||
| ddf82a0c66 | |||
| 7e079e0186 | |||
| eb37df347e | |||
| 7e665ea43e | |||
| 9d9bc5d257 | |||
| d75380eb9a | |||
| 11cd2fd01a | |||
| 52c8a71cd0 | |||
| e5db66e74a | |||
| 365609d518 | |||
| a6df4c2dd9 | |||
| 4b781d3c76 | |||
| 0a534222e8 | |||
| 3d4b98d703 | |||
| 4f22da65d8 | |||
| 9da9c8b6e0 | |||
| 5902d2b360 | |||
| 36e13ba7fa | |||
| 9fcc514045 | |||
| 7047e086d6 | |||
| 1e0e278451 | |||
| 467f7e63fd | |||
| 685a1269a4 | |||
| 6c0a282765 | |||
| 1a47bbea2f | |||
| 442c16d572 | |||
| 8e937344ff | |||
| 4ded68cec2 | |||
| 750efdd318 | |||
| 18fb44ed38 | |||
| bd2dadd9ee | |||
| c8fa2e8b52 | |||
| 4ebbc6dacf | |||
| 9458fd0506 | |||
| 83ad6536a3 | |||
| 244551ce70 | |||
| b1d4174721 | |||
| 001840ab05 | |||
| f0a703794a | |||
| 7d1e6f784b | |||
| b5f7252ac0 | |||
| 24cf9a7261 | |||
| 8428f3a490 | |||
| 77fb8fe7ee | |||
| eabc709076 | |||
| 4bd2ed0db2 | |||
| c1396096ad | |||
| a53516bfe6 | |||
| fb23c21ad9 | |||
| 0cf2ee7948 | |||
| 6c1c616c1c | |||
| 4909a78aca |
@@ -68,3 +68,14 @@ MINIO_BUCKET_WATER_LOGS=route-water-logs
|
||||
|
||||
# ── Cron / automation ───────────────────────────────────────────────────────
|
||||
CRON_SECRET=replace-me-with-a-random-string
|
||||
|
||||
# ── Water Log ───────────────────────────────────────────────────────────────
|
||||
# The Water Log module reuses the existing `DATABASE_URL` and (for photo
|
||||
# uploads) the `MINIO_BUCKET_WATER_LOGS` bucket above. It also reuses the
|
||||
# brand's Twilio / SMS config for high/low threshold alerts. The
|
||||
# Tuxedo brand UUID is a public default used as a fallback for cold-start
|
||||
# paths when the calling admin user is a platform_admin with brand_id=null.
|
||||
# It does NOT need to be set as an env var — the value is hardcoded in
|
||||
# the server actions — but you can override it with:
|
||||
# TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de
|
||||
# See docs/water-log.md for the full module guide.
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -176,7 +177,7 @@ jobs:
|
||||
|
||||
# Upload env file and sync build output
|
||||
echo "Uploading env file..."
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no "$ENV_FILE" tyler@route.crispygoat.com:$APP_DIR/.env.production
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no "$ENV_FILE" tyler@route.crispygoat.com:$APP_DIR/.env
|
||||
|
||||
echo "Copying .next/..."
|
||||
scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r .next tyler@route.crispygoat.com:$APP_DIR/
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: PWA + Mobile A11y Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lighthouse:
|
||||
runs-on: 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: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Start server
|
||||
run: npm run start &
|
||||
env:
|
||||
PORT: 3000
|
||||
|
||||
- name: Wait for server
|
||||
run: sleep 10
|
||||
|
||||
- name: Run Lighthouse CI
|
||||
run: npx lhci autorun
|
||||
@@ -47,3 +47,5 @@ playwright-report/
|
||||
.mcp.json
|
||||
.env*
|
||||
public/videos/tuxedo-hero.mp4
|
||||
.neon
|
||||
.worktrees/
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
|
||||
|
||||
**Last updated:** 2026-06 (migration reliability + Google sign-in work)
|
||||
**Last updated:** 2026-06 (admin_users schema fix + migration reliability + Google sign-in work)
|
||||
|
||||
## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043)
|
||||
|
||||
The 0001 schema's `admin_users` table has just `name` plus the role-derived `can_manage_*` columns (`can_manage_orders`, `can_manage_products`, `can_manage_stops`, `can_manage_customers`, `can_manage_wholesale`, `can_manage_billing`, `can_manage_settings`, `can_manage_water_log`, `can_manage_time_tracking`, `can_manage_route_trace`, `can_manage_reports`, `can_manage_communications`).
|
||||
|
||||
But `src/actions/admin/users.ts` was written against a richer schema: it INSERTs and SELECTs `display_name`, `phone_number`, `brand_id`, `can_manage_pickup/messages/refunds/users`, `active`, `must_change_password`, `auth_provider`, `auth_subject`, `last_login`. Submitting the create-user form produced `column "display_name" of relation "admin_users" does not exist`.
|
||||
|
||||
Migration `db/migrations/0043_admin_users_extra_columns.sql` adds all twelve missing columns (`ADD COLUMN IF NOT EXISTS`, re-runnable). `db/schema/brands.ts`'s Drizzle `adminUsers` definition was extended in lockstep.
|
||||
|
||||
**Important**: the four extra `can_manage_*` flags (pickup / messages / refunds / users) are **vestigial** — `getAdminUser()` in `lib/admin-permissions.ts` derives per-user permissions from the role via `permissionsForRole()`, never reads those columns. They exist only so the create-user form's per-user permission toggles persist; they have no runtime authorization effect. Cleanup target: either (a) read them in the role-derived lookup to support real per-user overrides, or (b) drop them from the form.
|
||||
|
||||
The `brand_id` column on `admin_users` is a **denormalization** of the `admin_user_brands` link table (which is the source of truth, and is what `getAdminUser()` reads). The action's `getAdminUsers` joins on `au.brand_id` so keeping the column avoids rewriting that SELECT. Future cleanup can drop the denormalized column and migrate the SELECT to join through the link table.
|
||||
|
||||
## 2026-06: CI migration failures on re-deploy (0001_init.sql "already exists")
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ The admin dashboard lives at `/admin`:
|
||||
- **Communications** — Harvest Reach campaign manager
|
||||
- **Wholesale** — Wholesale portal settings
|
||||
- **Billing** — Plan and subscription management
|
||||
- **Water Log** — Irrigation tracking (add-on)
|
||||
- **Water Log** — Irrigation tracking (add-on) — see [docs/water-log.md](docs/water-log.md)
|
||||
- **Settings** — Brand settings, payments, apps
|
||||
|
||||
## Email Automations (Harvest Reach)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Migration 003: Batch insert RPCs for tour stop / location seeding
|
||||
-- Required by db/seeds/2026-tuxedo-tour-stops.sql and scripts/import-tuxedo-stops.ts
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- admin_create_locations_batch: insert or update locations from tour seed
|
||||
-- Payload shape: { name, address, city, state, zip, phone, contact_name, contact_email, notes, active }[]
|
||||
CREATE OR REPLACE FUNCTION admin_create_locations_batch(p_brand_id UUID, p_locations JSONB)
|
||||
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
DECLARE
|
||||
v_loc JSONB;
|
||||
BEGIN
|
||||
FOR v_loc IN SELECT * FROM jsonb_array_elements(p_locations)
|
||||
LOOP
|
||||
INSERT INTO locations (
|
||||
brand_id, name, address, city, state, zip,
|
||||
phone, contact_name, contact_email, notes, active
|
||||
) VALUES (
|
||||
p_brand_id,
|
||||
v_loc->>'name',
|
||||
v_loc->>'address',
|
||||
v_loc->>'city',
|
||||
v_loc->>'state',
|
||||
NULLIF(v_loc->>'zip', '')::TEXT,
|
||||
v_loc->>'phone',
|
||||
v_loc->>'contact_name',
|
||||
v_loc->>'contact_email',
|
||||
v_loc->>'notes',
|
||||
COALESCE((v_loc->>'active')::BOOLEAN, true)
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- admin_create_stops_batch: insert stops from tour seed
|
||||
-- Payload shape: { city, state, location, date, time, address, zip, cutoff_time, active, notes }[]
|
||||
-- date format: '2026-07-22 00:00:00+00' — cast to DATE
|
||||
CREATE OR REPLACE FUNCTION admin_create_stops_batch(p_brand_id UUID, p_stops JSONB)
|
||||
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
DECLARE
|
||||
v_stop JSONB;
|
||||
BEGIN
|
||||
FOR v_stop IN SELECT * FROM jsonb_array_elements(p_stops)
|
||||
LOOP
|
||||
INSERT INTO stops (
|
||||
brand_id, name, location, address, city, state, zip,
|
||||
date, "time", cutoff_date, status, is_public, notes
|
||||
) VALUES (
|
||||
p_brand_id,
|
||||
COALESCE(NULLIF(v_stop->>'name', ''), (v_stop->>'location')::TEXT || ' - ' || (v_stop->>'city')::TEXT || ', ' || (v_stop->>'state')::TEXT),
|
||||
v_stop->>'location',
|
||||
v_stop->>'address',
|
||||
v_stop->>'city',
|
||||
v_stop->>'state',
|
||||
NULLIF(v_stop->>'zip', '')::TEXT,
|
||||
CASE
|
||||
WHEN v_stop->>'date' IS NULL THEN NULL
|
||||
ELSE LEFT(v_stop->>'date', 10)::DATE
|
||||
END,
|
||||
v_stop->>'time',
|
||||
NULLIF(v_stop->>'cutoff_time', '')::DATE,
|
||||
'active',
|
||||
COALESCE((v_stop->>'active')::BOOLEAN, true),
|
||||
v_stop->>'notes'
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,196 @@
|
||||
-- Migration 0040: Platform command center — founder_pain_log table/view + RPCs
|
||||
-- Restores objects that the /admin/command-center page depends on.
|
||||
-- Original definitions were in archived Supabase migrations 126 + 127.
|
||||
|
||||
-- ── founder_pain_log table ───────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS founder_pain_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
|
||||
category TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolved_by UUID REFERENCES admin_users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- ── founder_pain_log_platform view (joins brand name/slug for the UI) ────────
|
||||
CREATE OR REPLACE VIEW founder_pain_log_platform AS
|
||||
SELECT
|
||||
fpl.id,
|
||||
fpl.brand_id,
|
||||
fpl.severity,
|
||||
fpl.category,
|
||||
fpl.title,
|
||||
fpl.description,
|
||||
fpl.status,
|
||||
fpl.created_at,
|
||||
fpl.resolved_at,
|
||||
fpl.resolved_by,
|
||||
b.name AS brand_name,
|
||||
b.slug AS brand_slug
|
||||
FROM founder_pain_log fpl
|
||||
LEFT JOIN brands b ON b.id = fpl.brand_id
|
||||
ORDER BY
|
||||
CASE fpl.severity
|
||||
WHEN 'critical' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'medium' THEN 3
|
||||
WHEN 'low' THEN 4
|
||||
END,
|
||||
fpl.created_at DESC;
|
||||
|
||||
-- ── Platform-wide metrics ────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION get_platform_command_center_metrics()
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
result JSONB;
|
||||
BEGIN
|
||||
result := jsonb_build_object(
|
||||
'active_brands', (
|
||||
SELECT COUNT(*) FROM brands WHERE slug IN ('tuxedo', 'indian-river-direct')
|
||||
),
|
||||
'orders_today', (
|
||||
SELECT COUNT(*) FROM orders
|
||||
WHERE DATE(created_at) = CURRENT_DATE
|
||||
),
|
||||
'revenue_today', (
|
||||
SELECT COALESCE(SUM(subtotal), 0)
|
||||
FROM orders
|
||||
WHERE DATE(created_at) = CURRENT_DATE
|
||||
AND status NOT IN ('cancelled', 'refunded')
|
||||
),
|
||||
'active_routes', (
|
||||
SELECT COUNT(DISTINCT s.id)
|
||||
FROM stops s
|
||||
WHERE s.date::DATE >= CURRENT_DATE
|
||||
AND s.status = 'active'
|
||||
AND s.date ~ '^\d{4}-\d{2}-\d{2}$'
|
||||
),
|
||||
'failed_orders_today', (
|
||||
SELECT COUNT(*)
|
||||
FROM orders
|
||||
WHERE DATE(created_at) = CURRENT_DATE
|
||||
AND status IN ('payment_failed', 'failed')
|
||||
),
|
||||
'pending_orders_today', (
|
||||
SELECT COUNT(*)
|
||||
FROM orders
|
||||
WHERE DATE(created_at) = CURRENT_DATE
|
||||
AND status = 'pending'
|
||||
)
|
||||
);
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Recent activity feed (last 50 operational events across all brands) ──────
|
||||
CREATE OR REPLACE FUNCTION get_platform_activity_feed()
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
event_type TEXT,
|
||||
entity_type TEXT,
|
||||
entity_id UUID,
|
||||
payload JSONB,
|
||||
actor_type TEXT,
|
||||
source TEXT,
|
||||
created_at TIMESTAMPTZ,
|
||||
brand_id UUID,
|
||||
brand_name TEXT
|
||||
)
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
oe.id,
|
||||
oe.event_type,
|
||||
oe.entity_type,
|
||||
oe.entity_id,
|
||||
oe.payload,
|
||||
oe.actor_type::TEXT,
|
||||
oe.source::TEXT,
|
||||
oe.created_at,
|
||||
(oe.payload->>'brand_id')::UUID AS brand_id,
|
||||
b.name AS brand_name
|
||||
FROM operational_events oe
|
||||
LEFT JOIN brands b ON b.id = (oe.payload->>'brand_id')::UUID
|
||||
ORDER BY oe.created_at DESC
|
||||
LIMIT 50;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Per-brand health snapshot ────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION get_brand_health_snapshot()
|
||||
RETURNS TABLE (
|
||||
brand_id UUID,
|
||||
brand_name TEXT,
|
||||
brand_slug TEXT,
|
||||
plan_tier TEXT,
|
||||
orders_today INT,
|
||||
revenue_today NUMERIC,
|
||||
active_stops INT,
|
||||
failed_orders INT,
|
||||
pending_orders INT,
|
||||
last_activity TIMESTAMPTZ,
|
||||
open_pain_items INT,
|
||||
health_status TEXT
|
||||
)
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH bm AS (
|
||||
SELECT
|
||||
b.id AS bid,
|
||||
b.name AS bname,
|
||||
b.slug AS bslug,
|
||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE) AS ords_today,
|
||||
SUM(o.subtotal) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status NOT IN ('cancelled', 'refunded')) AS rev_today,
|
||||
COUNT(DISTINCT s.id) FILTER (
|
||||
WHERE (s.date ~ '^\d{4}-\d{2}-\d{2}$') AND (s.date::DATE >= CURRENT_DATE) AND s.status = 'active'
|
||||
) AS act_stops,
|
||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status IN ('payment_failed', 'failed')) AS fail_ords,
|
||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.created_at) = CURRENT_DATE AND o.status = 'pending') AS pend_ords,
|
||||
MAX(oe.created_at) AS last_act
|
||||
FROM brands b
|
||||
LEFT JOIN orders o ON o.brand_id = b.id
|
||||
LEFT JOIN stops s ON s.brand_id = b.id
|
||||
LEFT JOIN operational_events oe ON oe.payload->>'brand_id' = b.id::TEXT
|
||||
WHERE b.slug IN ('tuxedo', 'indian-river-direct')
|
||||
GROUP BY b.id, b.name, b.slug
|
||||
),
|
||||
pc AS (
|
||||
SELECT f.brand_id AS pc_bid, COUNT(*) AS open_cnt
|
||||
FROM founder_pain_log f
|
||||
WHERE f.status = 'open'
|
||||
GROUP BY f.brand_id
|
||||
)
|
||||
SELECT
|
||||
bm.bid,
|
||||
bm.bname,
|
||||
bm.bslug,
|
||||
NULL::TEXT,
|
||||
COALESCE(bm.ords_today, 0)::INT,
|
||||
COALESCE(bm.rev_today, 0)::NUMERIC,
|
||||
COALESCE(bm.act_stops, 0)::INT,
|
||||
COALESCE(bm.fail_ords, 0)::INT,
|
||||
COALESCE(bm.pend_ords, 0)::INT,
|
||||
bm.last_act,
|
||||
COALESCE(pc.open_cnt, 0)::INT,
|
||||
CASE
|
||||
WHEN COALESCE(bm.fail_ords, 0) > 0 THEN 'critical'
|
||||
WHEN COALESCE(bm.pend_ords, 0) > 5 THEN 'warning'
|
||||
WHEN COALESCE(pc.open_cnt, 0) > 0 THEN 'warning'
|
||||
WHEN bm.last_act IS NULL OR bm.last_act < (NOW() - INTERVAL '24 hours') THEN 'warning'
|
||||
ELSE 'healthy'
|
||||
END
|
||||
FROM bm
|
||||
LEFT JOIN pc ON pc.pc_bid = bm.bid;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,147 @@
|
||||
-- Migration 0041: Fix command-center RPCs against actual prod schema
|
||||
--
|
||||
-- Migration 0040 was written against an assumed schema and references columns
|
||||
-- that don't exist in the prod `orders` table:
|
||||
-- - `orders.created_at` does not exist → use `orders.placed_at`
|
||||
-- - `orders.subtotal` does not exist → use `orders.total_cents / 100`
|
||||
-- - `stops.date` is already DATE, not TEXT → drop the `~ '^\d{4}-...' ` regex
|
||||
--
|
||||
-- All three functions are fixed in place via CREATE OR REPLACE FUNCTION.
|
||||
-- The action layer (src/actions/platform/command-center.ts) is updated
|
||||
-- separately to use `SELECT * FROM fn()` for the two setof-returning functions.
|
||||
--
|
||||
-- Reference (prod schema as of 2026-06-17):
|
||||
-- orders: id, brand_id, customer_id, stop_id, total_cents INT,
|
||||
-- status TEXT, fulfillment TEXT, customer_*,
|
||||
-- idempotency_key, notes, placed_at TIMESTAMPTZ, updated_at
|
||||
-- stops: id, brand_id, name, location, address, city, state, zip,
|
||||
-- date DATE, time, cutoff_date, status, is_public, notes,
|
||||
-- created_at, updated_at
|
||||
-- operational_events: id, brand_id, event_type, entity_type, entity_id,
|
||||
-- actor_type, actor_id, source, payload JSONB, created_at
|
||||
-- brands: (assumed present per CLAUDE.md)
|
||||
|
||||
-- ── Platform-wide metrics ──────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION get_platform_command_center_metrics()
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
result JSONB;
|
||||
BEGIN
|
||||
result := jsonb_build_object(
|
||||
'active_brands', (
|
||||
SELECT COUNT(*) FROM brands WHERE slug IN ('tuxedo', 'indian-river-direct')
|
||||
),
|
||||
'orders_today', (
|
||||
SELECT COUNT(*) FROM orders
|
||||
WHERE DATE(placed_at) = CURRENT_DATE
|
||||
),
|
||||
'revenue_today', (
|
||||
SELECT COALESCE(SUM(total_cents), 0)::NUMERIC / 100
|
||||
FROM orders
|
||||
WHERE DATE(placed_at) = CURRENT_DATE
|
||||
AND status NOT IN ('cancelled', 'refunded')
|
||||
),
|
||||
'active_routes', (
|
||||
SELECT COUNT(DISTINCT s.id)
|
||||
FROM stops s
|
||||
WHERE s.date >= CURRENT_DATE
|
||||
AND s.status = 'active'
|
||||
),
|
||||
'failed_orders_today', (
|
||||
SELECT COUNT(*)
|
||||
FROM orders
|
||||
WHERE DATE(placed_at) = CURRENT_DATE
|
||||
AND status IN ('payment_failed', 'failed')
|
||||
),
|
||||
'pending_orders_today', (
|
||||
SELECT COUNT(*)
|
||||
FROM orders
|
||||
WHERE DATE(placed_at) = CURRENT_DATE
|
||||
AND status = 'pending'
|
||||
)
|
||||
);
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Per-brand health snapshot ──────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION get_brand_health_snapshot()
|
||||
RETURNS TABLE (
|
||||
brand_id UUID,
|
||||
brand_name TEXT,
|
||||
brand_slug TEXT,
|
||||
plan_tier TEXT,
|
||||
orders_today INT,
|
||||
revenue_today NUMERIC,
|
||||
active_stops INT,
|
||||
failed_orders INT,
|
||||
pending_orders INT,
|
||||
last_activity TIMESTAMPTZ,
|
||||
open_pain_items INT,
|
||||
health_status TEXT
|
||||
)
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH bm AS (
|
||||
SELECT
|
||||
b.id AS bid,
|
||||
b.name AS bname,
|
||||
b.slug AS bslug,
|
||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE) AS ords_today,
|
||||
(COALESCE(SUM(o.total_cents) FILTER (
|
||||
WHERE DATE(o.placed_at) = CURRENT_DATE
|
||||
AND o.status NOT IN ('cancelled', 'refunded')
|
||||
), 0)::NUMERIC / 100) AS rev_today,
|
||||
COUNT(DISTINCT s.id) FILTER (
|
||||
WHERE s.date >= CURRENT_DATE AND s.status = 'active'
|
||||
) AS act_stops,
|
||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE AND o.status IN ('payment_failed', 'failed')) AS fail_ords,
|
||||
COUNT(DISTINCT o.id) FILTER (WHERE DATE(o.placed_at) = CURRENT_DATE AND o.status = 'pending') AS pend_ords,
|
||||
MAX(oe.created_at) AS last_act
|
||||
FROM brands b
|
||||
LEFT JOIN orders o ON o.brand_id = b.id
|
||||
LEFT JOIN stops s ON s.brand_id = b.id
|
||||
LEFT JOIN operational_events oe
|
||||
ON oe.payload->>'brand_id' = b.id::TEXT
|
||||
AND oe.payload->>'brand_id' ~ '^[0-9a-fA-F-]{36}$'
|
||||
WHERE b.slug IN ('tuxedo', 'indian-river-direct')
|
||||
GROUP BY b.id, b.name, b.slug
|
||||
),
|
||||
pc AS (
|
||||
SELECT f.brand_id AS pc_bid, COUNT(*) AS open_cnt
|
||||
FROM founder_pain_log f
|
||||
WHERE f.status = 'open'
|
||||
GROUP BY f.brand_id
|
||||
)
|
||||
SELECT
|
||||
bm.bid,
|
||||
bm.bname,
|
||||
bm.bslug,
|
||||
NULL::TEXT,
|
||||
COALESCE(bm.ords_today, 0)::INT,
|
||||
COALESCE(bm.rev_today, 0)::NUMERIC,
|
||||
COALESCE(bm.act_stops, 0)::INT,
|
||||
COALESCE(bm.fail_ords, 0)::INT,
|
||||
COALESCE(bm.pend_ords, 0)::INT,
|
||||
bm.last_act,
|
||||
COALESCE(pc.open_cnt, 0)::INT,
|
||||
CASE
|
||||
WHEN COALESCE(bm.fail_ords, 0) > 0 THEN 'critical'
|
||||
WHEN COALESCE(bm.pend_ords, 0) > 5 THEN 'warning'
|
||||
WHEN COALESCE(pc.open_cnt, 0) > 0 THEN 'warning'
|
||||
WHEN bm.last_act IS NULL OR bm.last_act < (NOW() - INTERVAL '24 hours') THEN 'warning'
|
||||
ELSE 'healthy'
|
||||
END
|
||||
FROM bm
|
||||
LEFT JOIN pc ON pc.pc_bid = bm.bid;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- get_platform_activity_feed from 0040 is already correct against the prod
|
||||
-- schema (operational_events.payload->>'brand_id' is a text→UUID cast that
|
||||
-- nulls out on missing keys). No body change required.
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Migration 0042: Drop command-center RPCs and founder_pain_log
|
||||
--
|
||||
-- The /admin/command-center page is being removed (see commit message).
|
||||
-- All objects it depended on are also dropped:
|
||||
-- - get_platform_command_center_metrics (JSONB-returning RPC)
|
||||
-- - get_platform_activity_feed (TABLE-returning RPC)
|
||||
-- - get_brand_health_snapshot (TABLE-returning RPC)
|
||||
-- - founder_pain_log (table)
|
||||
-- - founder_pain_log_platform (view, joins brand name onto founder_pain_log)
|
||||
--
|
||||
-- The platform_admin role has no other consumer of these objects. The
|
||||
-- cross-brand KPIs and activity feed that the page showed are reachable
|
||||
-- per-brand via /admin, /admin/orders, /admin/stops, and /admin/reports.
|
||||
|
||||
DROP VIEW IF EXISTS founder_pain_log_platform;
|
||||
DROP FUNCTION IF EXISTS get_brand_health_snapshot();
|
||||
DROP FUNCTION IF EXISTS get_platform_activity_feed();
|
||||
DROP FUNCTION IF EXISTS get_platform_command_center_metrics();
|
||||
DROP TABLE IF EXISTS founder_pain_log;
|
||||
@@ -0,0 +1,70 @@
|
||||
-- 0043_admin_users_extra_columns.sql
|
||||
--
|
||||
-- The application-layer admin user CRUD in `src/actions/admin/users.ts`
|
||||
-- was written for a richer `admin_users` schema than the one that
|
||||
-- landed in 0001_init.sql. The 0001 schema has just `name` plus the
|
||||
-- role-derived flag columns (`can_manage_orders`, `can_manage_products`,
|
||||
-- `can_manage_stops`, `can_manage_customers`, `can_manage_wholesale`,
|
||||
-- `can_manage_billing`, `can_manage_settings`, `can_manage_water_log`,
|
||||
-- `can_manage_time_tracking`, `can_manage_route_trace`,
|
||||
-- `can_manage_reports`, `can_manage_communications`).
|
||||
--
|
||||
-- The action also references these columns, which are not in 0001:
|
||||
--
|
||||
-- display_name, phone_number, brand_id,
|
||||
-- can_manage_pickup, can_manage_messages,
|
||||
-- can_manage_refunds, can_manage_users,
|
||||
-- active, must_change_password,
|
||||
-- auth_provider, auth_subject, last_login
|
||||
--
|
||||
-- This migration adds all of them so the create-user, list-users,
|
||||
-- update-user, and read-user flows work against the actual table.
|
||||
-- Re-runnable: each ALTER uses ADD COLUMN IF NOT EXISTS.
|
||||
--
|
||||
-- Notes on the new columns:
|
||||
--
|
||||
-- - `display_name` is what the UI shows in the user list / "logged in
|
||||
-- as" labels. The original `name` column from 0001 is left in place
|
||||
-- (Drizzle `adminUsers.name` still maps to it; `getAdminUser()` reads
|
||||
-- it) — `display_name` is the column the action's SQL touches so it
|
||||
-- stays the writable surface for the create-user form. Both can
|
||||
-- coexist; the next cleanup pass can collapse them.
|
||||
--
|
||||
-- - The four extra `can_manage_*` flags (pickup / messages / refunds /
|
||||
-- users) are not consulted by `getAdminUser()` — that lookup uses
|
||||
-- `permissionsForRole(role)` from `lib/admin-permissions.ts`, which
|
||||
-- derives flags from the user's role rather than from these
|
||||
-- columns. They are kept on the row so the create-user form's
|
||||
-- per-user permission toggles can persist; they simply do not yet
|
||||
-- affect runtime authorization. Cleanup target: either move them into
|
||||
-- a per-user permission table that the role-derived lookup merges
|
||||
-- in, or drop them from the form.
|
||||
--
|
||||
-- - `brand_id` is a denormalization of `admin_user_brands` (the link
|
||||
-- table is the source of truth for brand assignment, and
|
||||
-- `getAdminUser()` reads from it). The action writes both, which is
|
||||
-- redundant but not incorrect — keeping it makes the action's
|
||||
-- read-after-write (`getAdminUsers` joins on `au.brand_id`) work
|
||||
-- without rewriting the SELECT.
|
||||
--
|
||||
-- - `must_change_password` defaults to FALSE at the column level; the
|
||||
-- action sets it to TRUE on every create so the user is forced to
|
||||
-- set a new password on first sign-in.
|
||||
|
||||
ALTER TABLE admin_users
|
||||
ADD COLUMN IF NOT EXISTS display_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS phone_number TEXT,
|
||||
ADD COLUMN IF NOT EXISTS brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS can_manage_pickup BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS can_manage_messages BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS can_manage_refunds BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS can_manage_users BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS auth_provider TEXT,
|
||||
ADD COLUMN IF NOT EXISTS auth_subject TEXT,
|
||||
ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ;
|
||||
|
||||
-- Helpful index for the brand-name lookup the user list does
|
||||
-- (LEFT JOIN brands b ON b.id = au.brand_id).
|
||||
CREATE INDEX IF NOT EXISTS admin_users_brand_id_idx ON admin_users (brand_id);
|
||||
@@ -0,0 +1,172 @@
|
||||
-- 0090_water_log_completion.sql
|
||||
--
|
||||
-- Water Log feature completion. The original `0001_init.sql` shipped the
|
||||
-- core five tables (headgates, irrigators, sessions, log entries, alerts)
|
||||
-- with RLS, but the SaaS rebuild's actions and UIs depend on a wider
|
||||
-- surface that this migration adds:
|
||||
--
|
||||
-- - `water_headgates.headgate_token` — opaque per-gate token for QR
|
||||
-- - `water_headgates.status` — open / closed / maintenance
|
||||
-- - `water_headgates.max_flow_gpm` — optional high-water marker
|
||||
-- - `water_headgates.notes` — free-form location notes
|
||||
-- - `water_headgates.last_used_at` — denormalized for display
|
||||
-- - `water_headgates.high_threshold` — optional alert ceiling
|
||||
-- - `water_headgates.low_threshold` — optional alert floor
|
||||
-- - `water_headgates.unit` — display unit (CFS/GPM/etc.)
|
||||
--
|
||||
-- - `water_irrigators.role` — "irrigator" | "water_admin"
|
||||
-- - `water_irrigators.phone` — optional contact
|
||||
-- - `water_irrigators.notes` — free-form
|
||||
--
|
||||
-- - `water_log_entries.method` — "manual" | "meter" | "estimate" | "qr"
|
||||
-- - `water_log_entries.total_gallons` — derived when computable
|
||||
-- - `water_log_entries.photo_url` — link to storage object
|
||||
-- - `water_log_entries.logged_date` — date-only (YYYY-MM-DD) for fast grouping
|
||||
-- - `water_log_entries.latitude`/`longitude` — optional GPS pin
|
||||
-- - `water_log_entries.brand_id` index upgrade
|
||||
--
|
||||
-- - NEW `water_admin_settings` (per-brand admin PIN + alert config)
|
||||
-- - NEW `water_admin_sessions` (admin sign-in sessions, separate cookie)
|
||||
-- - NEW `water_audit_log` (who changed what, when)
|
||||
--
|
||||
-- All new tables follow project conventions:
|
||||
-- - TIMESTAMPTZ for timestamps
|
||||
-- - UUID PKs via gen_random_uuid()
|
||||
-- - brand_id scoped, RLS enabled, FORCE'd
|
||||
-- - CREATE TABLE IF NOT EXISTS for re-runnability
|
||||
--
|
||||
-- ============================================================================
|
||||
|
||||
-- ─── 1. Extend water_headgates ──────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE water_headgates
|
||||
ADD COLUMN IF NOT EXISTS headgate_token TEXT UNIQUE
|
||||
DEFAULT encode(gen_random_bytes(12), 'hex'),
|
||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'closed', 'maintenance')),
|
||||
ADD COLUMN IF NOT EXISTS max_flow_gpm NUMERIC,
|
||||
ADD COLUMN IF NOT EXISTS notes TEXT,
|
||||
ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS high_threshold NUMERIC,
|
||||
ADD COLUMN IF NOT EXISTS low_threshold NUMERIC,
|
||||
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS';
|
||||
|
||||
-- Backfill any existing rows that pre-date the default expression
|
||||
UPDATE water_headgates
|
||||
SET headgate_token = encode(gen_random_bytes(12), 'hex')
|
||||
WHERE headgate_token IS NULL;
|
||||
|
||||
-- ─── 2. Extend water_irrigators ─────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE water_irrigators
|
||||
ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'irrigator'
|
||||
CHECK (role IN ('irrigator', 'water_admin')),
|
||||
ADD COLUMN IF NOT EXISTS phone TEXT,
|
||||
ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||
|
||||
-- ─── 3. Extend water_log_entries ────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE water_log_entries
|
||||
ADD COLUMN IF NOT EXISTS method TEXT NOT NULL DEFAULT 'manual'
|
||||
CHECK (method IN ('manual', 'meter', 'estimate', 'qr')),
|
||||
ADD COLUMN IF NOT EXISTS total_gallons NUMERIC,
|
||||
ADD COLUMN IF NOT EXISTS photo_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS logged_date DATE,
|
||||
ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION,
|
||||
ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION;
|
||||
|
||||
-- Backfill logged_date for any rows that pre-date the column
|
||||
UPDATE water_log_entries
|
||||
SET logged_date = (logged_at AT TIME ZONE 'UTC')::date
|
||||
WHERE logged_date IS NULL AND logged_at IS NOT NULL;
|
||||
|
||||
-- Index for fast "today's entries" + "this week's entries" queries
|
||||
CREATE INDEX IF NOT EXISTS water_log_entries_brand_date_idx
|
||||
ON water_log_entries (brand_id, logged_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS water_log_entries_irrigator_idx
|
||||
ON water_log_entries (irrigator_id, logged_at DESC);
|
||||
|
||||
-- ─── 4. water_admin_settings (per brand) ───────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS water_admin_settings (
|
||||
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
session_duration_hours INTEGER NOT NULL DEFAULT 4
|
||||
CHECK (session_duration_hours BETWEEN 1 AND 168),
|
||||
can_edit_entries BOOLEAN NOT NULL DEFAULT true,
|
||||
can_delete_entries BOOLEAN NOT NULL DEFAULT true,
|
||||
can_export_csv BOOLEAN NOT NULL DEFAULT true,
|
||||
alert_phone TEXT,
|
||||
alerts_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_by UUID REFERENCES admin_users(id)
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger t
|
||||
JOIN pg_class c ON t.tgrelid = c.oid
|
||||
JOIN pg_namespace n ON c.relnamespace = n.oid
|
||||
WHERE t.tgname = 'water_admin_settings_updated_at' AND n.nspname = current_schema()
|
||||
) THEN
|
||||
CREATE TRIGGER water_admin_settings_updated_at BEFORE UPDATE ON water_admin_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ─── 5. water_admin_sessions (separate from irrigator sessions) ───────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS water_admin_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
pin_hash_used TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS water_admin_sessions_admin_idx
|
||||
ON water_admin_sessions (admin_user_id, expires_at DESC);
|
||||
|
||||
-- ─── 6. water_audit_log (who changed what, when) ───────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS water_audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
actor_id UUID REFERENCES admin_users(id),
|
||||
actor_label TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id UUID,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS water_audit_log_brand_recent_idx
|
||||
ON water_audit_log (brand_id, created_at DESC);
|
||||
|
||||
-- ─── 7. RLS for new tables ────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE water_admin_settings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE water_admin_sessions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE water_audit_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
ALTER TABLE water_admin_settings FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE water_admin_sessions FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE water_audit_log FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON water_admin_settings;
|
||||
CREATE POLICY tenant_isolation ON water_admin_settings FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON water_admin_sessions;
|
||||
CREATE POLICY tenant_isolation ON water_admin_sessions FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON water_audit_log;
|
||||
CREATE POLICY tenant_isolation ON water_audit_log FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
@@ -0,0 +1,89 @@
|
||||
-- 0091_dashboard_summary_rpc.sql
|
||||
--
|
||||
-- Dashboard summary RPC for the mobile-first admin v2 dashboard
|
||||
-- (`/admin/v2`).
|
||||
--
|
||||
-- Returns a single JSONB document that powers the four stat cards +
|
||||
-- the "Today's stops" timeline + the 7-day mini-chart:
|
||||
--
|
||||
-- {
|
||||
-- "orders_today": <int>, -- count of orders placed today
|
||||
-- "revenue_today": <int cents>, -- sum of total_cents (excl. canceled)
|
||||
-- "pending_fulfillment": <int>, -- count of orders awaiting pickup/ship
|
||||
-- "stops_today": <int>, -- count of stops scheduled for today
|
||||
-- "orders_last_7_days": [{date, count}] -- 7-element array, oldest → today
|
||||
-- }
|
||||
--
|
||||
-- Schema notes (vs. the plan's draft):
|
||||
-- * `orders` uses `placed_at` (TIMESTAMPTZ) — not `created_at`
|
||||
-- * `orders.status` is the legacy enum: 'pending' | 'confirmed' |
|
||||
-- 'fulfilled' | 'canceled' (not the v2 vocabulary 'placed'/'ready')
|
||||
-- * `orders.total_cents` is INTEGER (NOT NULL DEFAULT 0 in 0001) — no
|
||||
-- column-add step is required
|
||||
-- * `stops` has a `date DATE` column (not `scheduled_at`); the legacy
|
||||
-- stops page (and v2 stops page) both key off `stops.date`
|
||||
--
|
||||
-- SECURITY DEFINER so the v2 server action can call it without depending
|
||||
-- on RLS; brand scoping is enforced inside the function via the
|
||||
-- `p_brand_id` parameter (NULL → platform_admin "all brands" scope).
|
||||
-- Re-runs are safe — this is `CREATE OR REPLACE FUNCTION`.
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
result JSONB;
|
||||
brand_filter TEXT;
|
||||
BEGIN
|
||||
-- The brand filter is the same text fragment for every SELECT in this
|
||||
-- function. NULL = "all brands" (platform_admin scope); non-NULL =
|
||||
-- scope to a single brand.
|
||||
IF p_brand_id IS NULL THEN
|
||||
brand_filter := '';
|
||||
ELSE
|
||||
brand_filter := format(' AND brand_id = %L', p_brand_id);
|
||||
END IF;
|
||||
|
||||
EXECUTE format(
|
||||
$q$
|
||||
SELECT jsonb_build_object(
|
||||
'orders_today', (
|
||||
SELECT COUNT(*) FROM orders
|
||||
WHERE placed_at::date = CURRENT_DATE %s
|
||||
),
|
||||
'revenue_today', (
|
||||
SELECT COALESCE(SUM(total_cents), 0)::bigint FROM orders
|
||||
WHERE placed_at::date = CURRENT_DATE
|
||||
AND status <> 'canceled' %s
|
||||
),
|
||||
'pending_fulfillment', (
|
||||
SELECT COUNT(*) FROM orders
|
||||
WHERE status IN ('pending', 'confirmed') %s
|
||||
),
|
||||
'stops_today', (
|
||||
SELECT COUNT(*) FROM stops
|
||||
WHERE "date" = CURRENT_DATE %s
|
||||
),
|
||||
'orders_last_7_days', (
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0)) ORDER BY d), '[]'::jsonb)
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d
|
||||
LEFT JOIN (
|
||||
SELECT placed_at::date AS day, COUNT(*) AS cnt
|
||||
FROM orders
|
||||
WHERE placed_at >= CURRENT_DATE - INTERVAL '7 days' %s
|
||||
GROUP BY 1
|
||||
) o ON o.day = d::date
|
||||
)
|
||||
)
|
||||
$q$,
|
||||
brand_filter, brand_filter, brand_filter, brand_filter, brand_filter
|
||||
) INTO result;
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION get_dashboard_summary(UUID) IS
|
||||
'Returns a single JSONB document with the four dashboard stat counts + 7-day order history. p_brand_id = NULL means platform_admin scope (all brands).';
|
||||
@@ -60,6 +60,23 @@ export const adminUsers = pgTable(
|
||||
userId: uuid("user_id"),
|
||||
email: text("email").notNull().unique(),
|
||||
name: text("name"),
|
||||
// The columns below were added in migration 0043. They back the
|
||||
// create-user / list-users / edit-user flows in
|
||||
// `src/actions/admin/users.ts`. See that migration's header for
|
||||
// notes on which of these are vestigial (the four can_manage_*
|
||||
// toggles) vs. read by the UI.
|
||||
displayName: text("display_name"),
|
||||
phoneNumber: text("phone_number"),
|
||||
brandId: uuid("brand_id"),
|
||||
canManagePickup: boolean("can_manage_pickup").notNull().default(true),
|
||||
canManageMessages: boolean("can_manage_messages").notNull().default(true),
|
||||
canManageRefunds: boolean("can_manage_refunds").notNull().default(false),
|
||||
canManageUsers: boolean("can_manage_users").notNull().default(false),
|
||||
active: boolean("active").notNull().default(true),
|
||||
mustChangePassword: boolean("must_change_password").notNull().default(false),
|
||||
authProvider: text("auth_provider"),
|
||||
authSubject: text("auth_subject"),
|
||||
lastLogin: timestamp("last_login", { withTimezone: true }),
|
||||
role: text("role", {
|
||||
enum: ["platform_admin", "brand_admin", "store_employee"],
|
||||
}).notNull().default("brand_admin"),
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
/**
|
||||
* Water Log. Source: `db/migrations/0001_init.sql`.
|
||||
* Water Log. Source: `db/migrations/0001_init.sql` + `0090_water_log_completion.sql`.
|
||||
*
|
||||
* Six tables, all brand-scoped with RLS:
|
||||
* - water_headgates — physical gates a measurement is tied to
|
||||
* - water_irrigators — PIN-authenticated field workers
|
||||
* - water_sessions — short-lived PIN sessions for irrigators
|
||||
* - water_log_entries — the actual reading logs
|
||||
* - water_alert_log — high/low threshold alert history
|
||||
* - water_admin_settings — per-brand admin PIN + alert config
|
||||
* - water_admin_sessions — admin sign-in sessions (separate cookie)
|
||||
* - water_audit_log — who changed what, when
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
@@ -8,7 +18,13 @@ import {
|
||||
numeric,
|
||||
boolean,
|
||||
timestamp,
|
||||
date,
|
||||
jsonb,
|
||||
doublePrecision,
|
||||
index,
|
||||
uniqueIndex,
|
||||
integer,
|
||||
check,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
import { adminUsers } from "./brands";
|
||||
@@ -21,11 +37,29 @@ export const waterHeadgates = pgTable(
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
/** Per-headgate opaque token used in the QR code. */
|
||||
headgateToken: text("headgate_token").notNull().unique(),
|
||||
/** Open / Closed / Maintenance. */
|
||||
status: text("status").notNull().default("open"),
|
||||
/** Display unit: CFS, GPM, Inches, AF/Day, etc. */
|
||||
unit: text("unit").notNull().default("CFS"),
|
||||
/** Optional max-flow marker in GPM. */
|
||||
maxFlowGpm: numeric("max_flow_gpm"),
|
||||
/** High-water alert threshold (units match `unit`). */
|
||||
highThreshold: numeric("high_threshold"),
|
||||
/** Low-water alert threshold (units match `unit`). */
|
||||
lowThreshold: numeric("low_threshold"),
|
||||
notes: text("notes"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("water_headgates_brand_idx").on(t.brandId),
|
||||
tokenIdx: uniqueIndex("water_headgates_token_idx").on(t.headgateToken),
|
||||
}),
|
||||
);
|
||||
|
||||
export const waterIrrigators = pgTable(
|
||||
@@ -40,12 +74,19 @@ export const waterIrrigators = pgTable(
|
||||
languagePreference: text("language_preference")
|
||||
.notNull()
|
||||
.default("en"),
|
||||
/** "irrigator" submits entries only, "water_admin" can manage the brand. */
|
||||
role: text("role").notNull().default("irrigator"),
|
||||
phone: text("phone"),
|
||||
notes: text("notes"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const waterSessions = pgTable(
|
||||
@@ -75,10 +116,20 @@ export const waterLogEntries = pgTable(
|
||||
irrigatorId: uuid("irrigator_id")
|
||||
.notNull()
|
||||
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
||||
/** Raw measurement value, in the entry's `unit`. */
|
||||
measurement: numeric("measurement").notNull(),
|
||||
unit: text("unit").notNull(),
|
||||
/** "manual" | "meter" | "estimate" | "qr" */
|
||||
method: text("method").notNull().default("manual"),
|
||||
/** Optional auto-computed total (in gallons) when CFS × duration is known. */
|
||||
totalGallons: numeric("total_gallons"),
|
||||
notes: text("notes"),
|
||||
submittedVia: text("submitted_via").notNull().default("app"),
|
||||
photoUrl: text("photo_url"),
|
||||
latitude: doublePrecision("latitude"),
|
||||
longitude: doublePrecision("longitude"),
|
||||
/** Date-only mirror of loggedAt for fast grouping / dashboard queries. */
|
||||
loggedDate: date("logged_date"),
|
||||
loggedAt: timestamp("logged_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
@@ -87,6 +138,14 @@ export const waterLogEntries = pgTable(
|
||||
(t) => ({
|
||||
brandIdx: index("water_log_entries_brand_idx").on(t.brandId),
|
||||
headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId),
|
||||
brandDateIdx: index("water_log_entries_brand_date_idx").on(
|
||||
t.brandId,
|
||||
t.loggedDate,
|
||||
),
|
||||
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
|
||||
t.irrigatorId,
|
||||
t.loggedAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -98,7 +157,9 @@ export const waterAlertLog = pgTable(
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
alertType: text("alert_type").notNull(),
|
||||
headgateId: uuid("headgate_id").references(() => waterHeadgates.id),
|
||||
headgateId: uuid("headgate_id").references(() => waterHeadgates.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
message: text("message").notNull(),
|
||||
sentTo: text("sent_to"),
|
||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
||||
@@ -108,8 +169,89 @@ export const waterAlertLog = pgTable(
|
||||
},
|
||||
);
|
||||
|
||||
export const waterAdminSettings = pgTable("water_admin_settings", {
|
||||
brandId: uuid("brand_id")
|
||||
.primaryKey()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
/** Hashed admin PIN (scrypt $ N$r$p$salt$hash format). */
|
||||
pinHash: text("pin_hash"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
sessionDurationHours: integer("session_duration_hours")
|
||||
.notNull()
|
||||
.default(4),
|
||||
canEditEntries: boolean("can_edit_entries").notNull().default(true),
|
||||
canDeleteEntries: boolean("can_delete_entries").notNull().default(true),
|
||||
canExportCsv: boolean("can_export_csv").notNull().default(true),
|
||||
alertPhone: text("alert_phone"),
|
||||
alertsEnabled: boolean("alerts_enabled").notNull().default(false),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedBy: uuid("updated_by").references(() => adminUsers.id),
|
||||
});
|
||||
|
||||
export const waterAdminSessions = pgTable(
|
||||
"water_admin_sessions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
adminUserId: uuid("admin_user_id")
|
||||
.notNull()
|
||||
.references(() => adminUsers.id, { onDelete: "cascade" }),
|
||||
pinHashUsed: text("pin_hash_used").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
adminIdx: index("water_admin_sessions_admin_idx").on(
|
||||
t.adminUserId,
|
||||
t.expiresAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const waterAuditLog = pgTable(
|
||||
"water_audit_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
actorId: uuid("actor_id").references(() => adminUsers.id),
|
||||
actorLabel: text("actor_label").notNull(),
|
||||
action: text("action").notNull(),
|
||||
entityType: text("entity_type").notNull(),
|
||||
entityId: uuid("entity_id"),
|
||||
details: jsonb("details"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandRecentIdx: index("water_audit_log_brand_recent_idx").on(
|
||||
t.brandId,
|
||||
t.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Inferred types (used by every action and client component) ────────────
|
||||
|
||||
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
|
||||
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
|
||||
|
||||
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
|
||||
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
|
||||
|
||||
export type WaterSession = typeof waterSessions.$inferSelect;
|
||||
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
|
||||
export type WaterLogEntryInsert = typeof waterLogEntries.$inferInsert;
|
||||
|
||||
export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
|
||||
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
|
||||
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
|
||||
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# Nested Layout Skeleton
|
||||
|
||||
Visual map of how pages nest inside each other in the Next.js App Router.
|
||||
Each level persists across navigation — children swap, parents stay.
|
||||
|
||||
## Mental model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ app/layout.tsx (root) │
|
||||
│ html / body / fonts / Providers / Toast / CookieBanner │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ No global header. Each route group renders its own chrome. │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┼─────────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Public routes Storefronts Admin
|
||||
(/, /pricing, (/tuxedo/*, (/admin/*)
|
||||
/contact, /login) /indian-river-direct/*)
|
||||
```
|
||||
|
||||
## Full tree
|
||||
|
||||
```
|
||||
src/app/
|
||||
├── layout.tsx ────────────────────────────────────────────────── [ROOT]
|
||||
│ • <html>, <body>
|
||||
│ • next/font: Fraunces (display), Manrope (sans), Fragment_Mono
|
||||
│ • <Providers> (theme, posthog, sentry, etc.)
|
||||
│ • <ToastNotificationContainer />
|
||||
│ • <CookieConsentBanner />
|
||||
│ ─── pages render INSIDE ───
|
||||
│ │
|
||||
│ ├── error.tsx [ROOT-LEVEL] Atelier error page
|
||||
│ ├── loading.tsx [ROOT-LEVEL] "Preparing your harvest"
|
||||
│ ├── not-found.tsx [ROOT-LEVEL] "Off the beaten path"
|
||||
│ │
|
||||
│ ├── page.tsx / LandingPageClient
|
||||
│ │ └── renders its own SiteHeader + Hero + SiteFooter
|
||||
│ │
|
||||
│ ├── login/
|
||||
│ │ ├── page.tsx /login LoginClient (standalone)
|
||||
│ │ └── loading.tsx [isolated]
|
||||
│ │
|
||||
│ ├── cart/
|
||||
│ │ ├── page.tsx /cart CartClient (standalone)
|
||||
│ │ └── loading.tsx
|
||||
│ │
|
||||
│ ├── checkout/
|
||||
│ │ ├── page.tsx /checkout CheckoutClient (standalone)
|
||||
│ │ ├── loading.tsx
|
||||
│ │ └── success/page.tsx /checkout/success
|
||||
│ │
|
||||
│ ├── change-password/page.tsx
|
||||
│ ├── pricing/page.tsx + loading.tsx
|
||||
│ ├── contact/page.tsx + loading.tsx
|
||||
│ ├── blog/page.tsx
|
||||
│ ├── changelog/page.tsx
|
||||
│ ├── roadmap/page.tsx
|
||||
│ ├── waitlist/page.tsx
|
||||
│ ├── security/page.tsx
|
||||
│ ├── privacy-policy/page.tsx
|
||||
│ ├── terms-and-conditions/page.tsx
|
||||
│ ├── maintenance/page.tsx
|
||||
│ │
|
||||
│ ├── ─── STOREFRONTS ─────────────────────────────────────────
|
||||
│ │
|
||||
│ ├── tuxedo/
|
||||
│ │ ├── layout.tsx ─────────────────────── [TUXEDO CHROME]
|
||||
│ │ │ • Dark emerald gradient backdrop
|
||||
│ │ │ • Ambient emerald orb (top-right blur)
|
||||
│ │ │ • <StorefrontHeader> + <StorefrontFooter> per page
|
||||
│ │ │ • BreadcrumbList + LocalBusiness JSON-LD in metadata
|
||||
│ │ │ ─── INSIDE ───
|
||||
│ │ │ ├── error.tsx
|
||||
│ │ │ ├── loading.tsx
|
||||
│ │ │ ├── page.tsx /tuxedo
|
||||
│ │ │ ├── stops/
|
||||
│ │ │ │ ├── page.tsx /tuxedo/stops
|
||||
│ │ │ │ ├── loading.tsx
|
||||
│ │ │ │ └── [slug]/
|
||||
│ │ │ │ ├── page.tsx /tuxedo/stops/:slug
|
||||
│ │ │ │ ├── error.tsx
|
||||
│ │ │ │ └── loading.tsx
|
||||
│ │ │ ├── about/
|
||||
│ │ │ │ ├── layout.tsx
|
||||
│ │ │ │ ├── page.tsx /tuxedo/about
|
||||
│ │ │ │ ├── error.tsx
|
||||
│ │ │ │ └── loading.tsx
|
||||
│ │ │ ├── faq/
|
||||
│ │ │ │ ├── layout.tsx
|
||||
│ │ │ │ ├── page.tsx /tuxedo/faq
|
||||
│ │ │ │ ├── error.tsx
|
||||
│ │ │ │ └── loading.tsx
|
||||
│ │ │ ├── contact/
|
||||
│ │ │ │ ├── layout.tsx
|
||||
│ │ │ │ ├── page.tsx /tuxedo/contact
|
||||
│ │ │ │ ├── error.tsx
|
||||
│ │ │ │ └── loading.tsx
|
||||
│ │ │ ├── products/
|
||||
│ │ │ │ └── [slug]/page.tsx /tuxedo/products/:slug
|
||||
│ │ │ └── time-clock/page.tsx /tuxedo/time-clock
|
||||
│ │
|
||||
│ ├── indian-river-direct/
|
||||
│ │ ├── layout.tsx ─────────────────── [IRD CHROME]
|
||||
│ │ │ • Light blue/white glass gradient backdrop
|
||||
│ │ │ • Three decorative blue orbs
|
||||
│ │ │ • <StorefrontHeader> + <StorefrontFooter> per page
|
||||
│ │ │ • LocalBusiness (FL, family-owned 1985) JSON-LD
|
||||
│ │ │ ─── INSIDE ───
|
||||
│ │ │ ├── error.tsx
|
||||
│ │ │ ├── loading.tsx
|
||||
│ │ │ ├── page.tsx /indian-river-direct
|
||||
│ │ │ ├── stops/
|
||||
│ │ │ │ ├── page.tsx
|
||||
│ │ │ │ └── [slug]/
|
||||
│ │ │ │ ├── page.tsx
|
||||
│ │ │ │ ├── error.tsx
|
||||
│ │ │ │ └── loading.tsx
|
||||
│ │ │ ├── about/ (layout + page + error + loading)
|
||||
│ │ │ ├── faq/ (layout + page + error + loading)
|
||||
│ │ │ └── contact/ (layout + page + error + loading)
|
||||
│ │
|
||||
│ ├── ─── ADMIN ──────────────────────────────────────────────
|
||||
│ │
|
||||
│ └── admin/
|
||||
│ ├── layout.tsx ───────────────────── [ADMIN SHELL]
|
||||
│ │ • dynamic = "force-dynamic" (reads cookies)
|
||||
│ │ • getAdminUser() → AdminSidebar + content area
|
||||
│ │ • ToastProvider + ToastContainer
|
||||
│ │ • Sidebar persists across all /admin/* routes
|
||||
│ │ • Parchment background via .admin-section
|
||||
│ │ ─── INSIDE ───
|
||||
│ │ ├── error.tsx Admin error page
|
||||
│ │ ├── loading.tsx Skeleton: header + stats + table
|
||||
│ │ ├── page.tsx /admin (Dashboard)
|
||||
│ │ │
|
||||
│ │ ├── orders/
|
||||
│ │ │ ├── page.tsx /admin/orders
|
||||
│ │ │ ├── [id]/page.tsx /admin/orders/:id
|
||||
│ │ │ └── new/page.tsx /admin/orders/new
|
||||
│ │ │
|
||||
│ │ ├── products/
|
||||
│ │ │ ├── page.tsx /admin/products
|
||||
│ │ │ ├── [id]/page.tsx
|
||||
│ │ │ ├── new/page.tsx
|
||||
│ │ │ └── import/page.tsx
|
||||
│ │ │
|
||||
│ │ ├── stops/
|
||||
│ │ │ ├── page.tsx /admin/stops
|
||||
│ │ │ ├── [id]/page.tsx
|
||||
│ │ │ └── new/page.tsx
|
||||
│ │ │
|
||||
│ │ ├── communications/
|
||||
│ │ │ ├── loading.tsx
|
||||
│ │ │ ├── page.tsx /admin/communications
|
||||
│ │ │ ├── compose/page.tsx
|
||||
│ │ │ ├── campaigns/[id]/page.tsx
|
||||
│ │ │ ├── contacts/page.tsx
|
||||
│ │ │ ├── logs/page.tsx
|
||||
│ │ │ ├── segments/page.tsx
|
||||
│ │ │ ├── settings/page.tsx
|
||||
│ │ │ ├── templates/page.tsx
|
||||
│ │ │ ├── templates/[id]/page.tsx
|
||||
│ │ │ ├── analytics/page.tsx
|
||||
│ │ │ ├── abandoned-carts/page.tsx
|
||||
│ │ │ └── welcome-sequence/page.tsx
|
||||
│ │ │
|
||||
│ │ ├── settings/ (sub-tree of 13 pages: billing, brand,
|
||||
│ │ │ payments, shipping, ai, apps,
|
||||
│ │ │ integrations, square-sync, ...)
|
||||
│ │ │
|
||||
│ │ ├── route-trace/ (lots, lookup, settings)
|
||||
│ │ ├── me/
|
||||
│ │ ├── pickup/ Store-employee landing
|
||||
│ │ ├── sales/import/
|
||||
│ │ ├── import/
|
||||
│ │ ├── analytics/
|
||||
│ │ ├── command-center/
|
||||
│ │ ├── advanced/
|
||||
│ │ ├── reports/
|
||||
│ │ ├── shipping/
|
||||
│ │ ├── taxes/
|
||||
│ │ ├── time-tracking/
|
||||
│ │ ├── water-log/ (sub-tree)
|
||||
│ │ ├── wholesale/
|
||||
│ │ └── launch-checklist/
|
||||
│ │
|
||||
│ ├── wholesale/ (separate sub-app, not under admin/layout)
|
||||
│ │ ├── login/page.tsx
|
||||
│ │ ├── portal/page.tsx
|
||||
│ │ ├── register/page.tsx
|
||||
│ │ ├── employee/page.tsx
|
||||
│ │ └── payment/{success,cancel}/page.tsx
|
||||
│ │
|
||||
│ ├── water/ (separate sub-app, not under admin/layout)
|
||||
│ │ ├── page.tsx
|
||||
│ │ ├── loading.tsx
|
||||
│ │ └── admin/ (login + page)
|
||||
│ │
|
||||
│ ├── ird/time-clock/ (time-clock for IRD employees)
|
||||
│ ├── trace/[lotNumber]/page.tsx (public route-trace lookup)
|
||||
│ └── protected-example/page.tsx (smoke test for middleware)
|
||||
```
|
||||
|
||||
## Key nesting rules
|
||||
|
||||
1. **The root `app/layout.tsx` is the only one that renders `<html>` and `<body>`**. Every other layout is a child of it.
|
||||
2. **A `layout.tsx` in a folder wraps every `page.tsx` in that folder and all subfolders**. Children swap; layout state persists.
|
||||
3. **`loading.tsx` and `error.tsx` are per-folder** — Next.js shows them when navigating to a sibling `page.tsx`. They re-render as you move between sections.
|
||||
4. **Storefronts are fully isolated**: each `tuxedo/layout.tsx` and `indian-river-direct/layout.tsx` provides its own backdrop and lets individual pages render their own `StorefrontHeader` / `StorefrontFooter`.
|
||||
5. **Admin is a single SPA-feeling shell**: navigating between `/admin/orders` and `/admin/products` keeps the `AdminSidebar` mounted, only the content area swaps.
|
||||
|
||||
## Things that DO NOT yet nest (gaps)
|
||||
|
||||
| Page | Issue | Fix |
|
||||
|---|---|---|
|
||||
| `/cart` | No shared layout with `/checkout` | Add `app/(shop)/layout.tsx` route group to wrap both |
|
||||
| `/wholesale/*` | Wholesale pages have no shared layout | Add `app/wholesale/layout.tsx` with auth gate + nav |
|
||||
| `/water/*` | Water pages have no shared layout | Add `app/water/layout.tsx` |
|
||||
| `/admin/communications/*` | 13 pages share no sub-layout | Could add `app/admin/communications/layout.tsx` with section tabs |
|
||||
| `/admin/settings/*` | 13 settings pages share no sub-layout | Add `app/admin/settings/layout.tsx` with settings sidebar |
|
||||
|
||||
## How to add a nested layout
|
||||
|
||||
```bash
|
||||
# Example: add a settings sub-layout
|
||||
mkdir -p src/app/admin/settings
|
||||
# Create src/app/admin/settings/layout.tsx
|
||||
# It will wrap every page under /admin/settings/* automatically.
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/app/admin/settings/layout.tsx
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[14rem_1fr] gap-6">
|
||||
<SettingsNav /> {/* sticks while content swaps */}
|
||||
<section>{children}</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## How to add parallel routes (modals as overlays)
|
||||
|
||||
```bash
|
||||
mkdir -p 'src/app/@modal'
|
||||
mkdir -p 'src/app/admin/products/(.)new' # intercepts /admin/products/new
|
||||
# Layout returns <>{children}{modal}</>
|
||||
```
|
||||
|
||||
This makes `/admin/products/new` open as a modal over `/admin/products` instead of a hard navigation.
|
||||
@@ -12,7 +12,7 @@ A **multi-tenant B2B produce distribution platform**, not a SaaS starter. Build
|
||||
|---|---|
|
||||
| Migrations | **135 SQL files** (production schema, RLS, SECURITY DEFINER RPCs, audit logs) |
|
||||
| Source | **544 files** (362 .tsx + 179 .ts) |
|
||||
| Admin pages | **88 routes** across orders, products, stops, wholesale, communications, time-tracking, water-log, route-trace, reports, advanced, command-center |
|
||||
| Admin pages | **87 routes** across orders, products, stops, wholesale, communications, time-tracking, water-log, route-trace, reports, advanced |
|
||||
| API routes | **60+ endpoints** (Stripe + Square + FedEx + Resend + Twilio + 8 AI endpoints) |
|
||||
| Public surfaces | 2 brand storefronts (Tuxedo, Indian River Direct) with brand theming, hero, color customization |
|
||||
| Integrations | Stripe (Connect + subscriptions), Square (POS sync), FedEx (rates + labels), Resend (email), Twilio (SMS), OpenAI (8 AI tools) |
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Admin Redesign — Task Checklist
|
||||
|
||||
> Working branch: `design/ui-revamp-2026-06`
|
||||
> Spec: `docs/superpowers/specs/2026-06-17-admin-redesign.md`
|
||||
> Started: 2026-06-17
|
||||
> Deadline: tomorrow
|
||||
|
||||
## How to use this file
|
||||
|
||||
- `[ ]` unchecked → not done · `[x]` checked → done
|
||||
- Tick a box by changing `[ ]` to `[x]`
|
||||
- Add free-form notes in the **Notes** column
|
||||
- One commit per task (or per cluster of related tasks)
|
||||
- Revert a task with `git reset --hard <commit-before-task>`
|
||||
- Full revert: `git checkout main && git branch -D design/ui-revamp-2026-06`
|
||||
|
||||
## Revert cheatsheet
|
||||
|
||||
```bash
|
||||
git status # what's dirty
|
||||
git stash # save uncommitted, discard
|
||||
git diff # see what's staged
|
||||
git log --oneline -10 # see recent commits
|
||||
git reset --hard <sha> # nuke back to that commit
|
||||
git checkout main # abandon branch, keep work
|
||||
git branch -D design/ui-revamp-2026-06 # delete the branch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Setup
|
||||
|
||||
| Done | Task | Files | Notes | Commit |
|
||||
|---|---|---|---|---|
|
||||
| [x] | Create feature branch | — | `design/ui-revamp-2026-06` off `main` | — |
|
||||
| [x] | Write design spec | `docs/superpowers/specs/2026-06-17-admin-redesign.md` | aesthetic, color, IA, phases | — |
|
||||
| [x] | Write this checklist | `docs/superpowers/plans/2026-06-17-admin-redesign-checklist.md` | — | — |
|
||||
| [ ] | Commit baseline | both files above | `chore: design spec + checklist` | (will commit after writing this) |
|
||||
|
||||
## Phase 1 — Foundation (color + design tokens)
|
||||
|
||||
| Done | Task | Files | Notes | Commit |
|
||||
|---|---|---|---|---|
|
||||
| [x] | Replace `--admin-*` color tokens in admin-design-system.css | `src/styles/admin-design-system.css` | use spec §3 table. Drop the muddy `aba278` warning, the purple stat icons, the blue stat icons. | `style(admin): unify color tokens` (18fb44e) |
|
||||
| [x] | Update `globals.css` if any admin colors live there | `src/app/globals.css` | grep for `admin-accent`, `admin-bg` | (same commit) |
|
||||
| [x] | Remove inline `#fef3c7` / `#dbeafe` / `#f3e8ff` from stat cards in DashboardClient | `src/components/admin/DashboardClient.tsx` | semantic colors only | (same commit) |
|
||||
|
||||
## Phase 2 — Discoverability (sidebar + command palette)
|
||||
|
||||
| Done | Task | Files | Notes | Commit |
|
||||
|---|---|---|---|---|
|
||||
| [x] | Create `SideNavGroup` component (label + items + active highlight) | `src/components/admin/SideNavGroup.tsx` (new) | matches new IA: Workspace / Operations / Communications / Growth / Tracking / Insights / Settings | (4ded68c) |
|
||||
| [x] | Refactor `AdminSidebar` to use grouped nav | `src/components/admin/AdminSidebar.tsx` | replace flat `NAV_ITEMS` with groups; keep role-gating, brand selector, sign-out | `feat(admin): grouped sidebar IA` (4ded68c) |
|
||||
| [x] | Create `CommandPalette` (Cmd+K) | `src/components/admin/CommandPalette.tsx` (new) | global search across pages + recent items + quick actions; mounts in admin layout | `feat(admin): command palette` (750efdd) |
|
||||
| [x] | Mount `CommandPalette` in admin layout | `src/app/admin/layout.tsx` | wraps children, listens for `Cmd/Ctrl+K` | (442c16d) |
|
||||
| [x] | Plumb `enabledAddons` to sidebar (gates Water Log / Route Trace) | `src/app/admin/layout.tsx` | `getEnabledAddons(activeBrandId)` from `actions/billing/stripe-portal` | (442c16d) |
|
||||
| [ ] | Settings pages get a top tab bar (no more hidden sub-nav) | `src/app/admin/settings/*/page.tsx` | General / Brand / Billing / Users / Integrations / Payments / Shipping / Add-ons | `feat(admin): settings top tabs` |
|
||||
|
||||
## Phase 3 — Component patterns
|
||||
|
||||
| Done | Task | Files | Notes | Commit |
|
||||
|---|---|---|---|---|
|
||||
| [x] | Extract `KPIStat` from DashboardClient | `src/components/admin/KPIStat.tsx` (new) | label + value + optional trend | `feat(admin): KPIStat component` (8e93734) |
|
||||
| [x] | Tighten `AdminBadge` variants (status / tier / addon) | `src/components/admin/design-system/AdminBadge.tsx` | semantic: success / warning / danger / info / neutral | (8e93734) |
|
||||
| [x] | Add `EmptyState` admin variant | `src/components/admin/EmptyState.tsx` (new) | icon + title + body + CTA; matches color tokens | (8e93734) |
|
||||
| [x] | Add `LoadingState` admin variant | `src/components/admin/LoadingState.tsx` (new) | uses `.ha-skeleton` | (8e93734) |
|
||||
| [ ] | Audit `PageHeader` usage; fix inconsistencies | `src/components/admin/design-system/PageHeader.tsx` + callers | eyebrow + title + subtitle + actions; consistent across all pages | `chore(admin): PageHeader audit` |
|
||||
|
||||
## Phase 4 — Dashboard (unified command center)
|
||||
|
||||
| Done | Task | Files | Notes | Commit |
|
||||
|---|---|---|---|---|
|
||||
| [ ] | Redesign `DashboardClient` as single feed (no 4 tabs) | `src/components/admin/DashboardClient.tsx` | top: KPI strip · middle: "Needs attention" feed · bottom: section grid | `feat(admin): unified dashboard` |
|
||||
| [ ] | Add a "What needs attention" feed (orders pending, stops today, low stock, unanswered contacts) | same file | data from existing actions | (same commit) |
|
||||
|
||||
## Phase 5 — Apply patterns to top pages
|
||||
|
||||
| Done | Task | Files | Notes | Commit |
|
||||
|---|---|---|---|---|
|
||||
| [x] | `/admin/orders` list + detail | `src/app/admin/orders/page.tsx`, `[id]/page.tsx`, `AdminOrdersPanel.tsx` | new PageHeader, StatusPill, EmptyState | `feat(admin): apply design system to Orders` (467f7e6) |
|
||||
| [x] | `/admin/products` list + new + edit | `src/app/admin/products/page.tsx`, `new/page.tsx`, `[id]/page.tsx`, `ProductsClient.tsx` | same | `feat(admin): apply new design system to Products pages` (1e0e278) |
|
||||
| [x] | `/admin/stops` calendar | `src/app/admin/stops/page.tsx`, `StopsCalendarClient.tsx` | same | `feat(admin): apply design system to Stops pages` (685a126) |
|
||||
| [ ] | `/admin/communications` composer | `src/components/admin/HarvestReach/CampaignComposerPage.tsx` | same | `style(admin): comms composer` |
|
||||
| [ ] | `/admin/settings` index + tab bar | `src/app/admin/settings/page.tsx` | overview of all settings w/ quick links | `style(admin): settings index` |
|
||||
|
||||
## Phase 6 — Frontend polish (public side)
|
||||
|
||||
| Done | Task | Files | Notes | Commit |
|
||||
|---|---|---|---|---|
|
||||
| [ ] | Tuxedo home polish | `src/app/tuxedo/page.tsx` + `tuxedo/` components | rhythm, spacing, mobile | `style(storefront): tuxedo polish` |
|
||||
| [ ] | Indian River Direct home polish | `src/app/indian-river-direct/page.tsx` + components | rhythm, spacing, mobile | `style(storefront): IRD polish` |
|
||||
| [ ] | Pricing page polish | `src/app/pricing/page.tsx` | tier card spacing | `style(public): pricing polish` |
|
||||
|
||||
## Phase 7 — Verify (gate before merge)
|
||||
|
||||
| Done | Task | Command | Notes | Commit |
|
||||
|---|---|---|---|---|
|
||||
| [ ] | Type-check clean | `npx tsc --noEmit` | 0 errors | (no commit, gate) |
|
||||
| [ ] | Build clean | `npm run build` | green | (no commit, gate) |
|
||||
| [ ] | Dev server boots | `npm run dev` | `/admin` renders, sidebar works, Cmd+K opens | (no commit, gate) |
|
||||
| [ ] | Manual smoke: dashboard | `localhost:4000/admin` | layout intact, no console errors | (no commit, gate) |
|
||||
| [ ] | Manual smoke: command palette | `Cmd+K` | opens, type, jump | (no commit, gate) |
|
||||
| [ ] | Manual smoke: orders list | `/admin/orders` | loads, page header consistent | (no commit, gate) |
|
||||
|
||||
---
|
||||
|
||||
## Live notes (free-form)
|
||||
|
||||
Add anything you want to remember as we go.
|
||||
|
||||
- The existing `.ha-*` (Harvest Almanac) class system is good. Build on it; don't replace it.
|
||||
- The sidebar is in `src/components/admin/AdminSidebar.tsx` (~520 lines). Don't rewrite from scratch — refactor the data structure to groups and let the JSX mostly stand.
|
||||
- `lucide-react` is already a dependency. New icons should use lucide, not inline SVGs.
|
||||
- The `Atelier des Récoltes` CSS classes (`.atelier-*` in `globals.css`) are public-side only. Don't mix them into admin.
|
||||
- Per MEMORY.md: spawn sub-agents sequentially if TPM rate limit hits. If parallel works, fine; if not, fall back.
|
||||
|
||||
## Decisions log
|
||||
|
||||
Record any calls you make mid-flight that change the spec.
|
||||
|
||||
- (none yet)
|
||||
@@ -0,0 +1,538 @@
|
||||
# Admin Mobile PWA — Design Spec
|
||||
|
||||
- **Date:** 2026-06-17
|
||||
- **Status:** Draft, awaiting user review
|
||||
- **Author:** Grok
|
||||
- **Branch:** `main`
|
||||
- **Scope:** Admin dashboard only (`/admin/*`), with full PWA + offline support
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Route Commerce is used heavily on phones — the team operates out of offices, trucks, warehouses, and outdoor pickup stops. The current admin is mobile-responsive but **not mobile-first**, the PWA is half-built (manifest + service worker exist, but no icons, no install prompt wired up, no offline page, no service worker registration), and Apple HIG accessibility-tier legibility is not the design baseline.
|
||||
|
||||
**Symptoms today:**
|
||||
|
||||
- `public/icons/` and `public/screenshots/` directories don't exist; manifest references 8 icon files and 2 screenshots that all 404
|
||||
- `registerServiceWorker()` is exported but never called
|
||||
- `PWAInstallPrompt` is never mounted in the tree
|
||||
- `apple-touch-icon.png` and `favicon.ico` are referenced but missing
|
||||
- Service worker references `/offline` and `/og-default.jpg` that don't exist
|
||||
- `themeColor` in `viewport` (`#0a0a0a`) mismatches manifest (`#1a4d2e`)
|
||||
- `AdminSidebar` uses `lg:pl-60` — on phones it becomes an overlay drawer; no bottom nav, no thumb-zone ergonomics
|
||||
- Body text is 16pt; tap targets are 40–44pt; status colors don't all clear WCAG AA on the warm cream background
|
||||
- No offline mutation queue — losing signal mid-fulfillment is a real operational problem
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Ship a real, installable PWA** — manifest validates, all icons render, splash + app icon work on iOS Add to Home Screen, themed status bar.
|
||||
2. **Mobile-first admin shell** — bottom tab bar (thumb zone) for the 4 most-used surfaces, drag-up "More" sheet for everything else, sticky top bar with brand selector + search + profile.
|
||||
3. **HIG Accessibility-tier legibility** — 18pt body, 500 weight, AAA contrast, 56pt+ tap targets, bold weights throughout. Readable in direct sunlight, tappable with gloves.
|
||||
4. **Read + queue mutations offline** — operators in dead zones can still see data and queue actions (mark ready, mark picked up, change stop status, adjust stock). Optimistic UI with a visible sync status; replays on reconnect with last-write-wins conflict resolution.
|
||||
5. **Production-optimized** — proper cache headers, code-split admin shell, SW cache versioning, no layout shift on font load, Lighthouse PWA + mobile a11y gates in CI.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Redesigning marketing pages (landing, pricing, blog). They stay as-is.
|
||||
- Redesigning the public storefronts. They stay as-is.
|
||||
- Push notifications / Web Push. The SW has a `push` handler scaffolded but it's not in scope; requires VAPID setup and a separate decision.
|
||||
- Wholesale portal mobile redesign. Tabled for a future spec.
|
||||
- Real-time collaboration (websockets / live cursors). Out of scope.
|
||||
- CRDT-style conflict resolution. We use last-write-wins keyed on `updated_at`.
|
||||
|
||||
## Surfaces in scope
|
||||
|
||||
- `/admin` (Dashboard)
|
||||
- `/admin/orders` + `/admin/orders/[id]`
|
||||
- `/admin/stops`
|
||||
- `/admin/products`
|
||||
- The admin shell that wraps all of the above
|
||||
- PWA install, splash, offline page, and SW behavior across the whole app
|
||||
|
||||
---
|
||||
|
||||
## 1. Aesthetic direction & design system
|
||||
|
||||
### Direction: "HIG-First Field Almanac"
|
||||
|
||||
Apple HIG Accessibility-tier legibility is the **floor**; the existing editorial identity (Fraunces + Manrope + Fragment Mono, "Field Almanac" palette already hinted at in `globals.css`) pushed harder is the **ceiling**. The result feels like a high-end tool someone made for themselves — confident, large, warm, with serif labels and serious typography weight. Distinctive without being precious.
|
||||
|
||||
### Type scale (HIG Accessibility-tier)
|
||||
|
||||
| Token | Size / Weight | Use |
|
||||
| ---------------- | ----------------------------------- | -------------------------------------- |
|
||||
| `text-display` | 32pt / Fraunces 600 | Page titles (Dashboard, Orders, etc.) |
|
||||
| `text-h1` | 24pt / Fraunces 600 | Section headers |
|
||||
| `text-h2` | 19pt / Manrope 700 | Card titles, list primary |
|
||||
| `text-body` | 18pt / Manrope 500 | All body text |
|
||||
| `text-label` | 15pt / Manrope 600 / +0.02em | Button text, field labels, status pills|
|
||||
| `text-mono` | 14pt / Fragment Mono 400 | Order IDs, totals, tracking numbers |
|
||||
| `text-meta` | 13pt / Manrope 600 | Timestamps, helper text |
|
||||
|
||||
Body bumped from 16 → 18pt; weight bumped from 400 → 500 for sun legibility.
|
||||
|
||||
### Color tokens (AAA contrast where text appears)
|
||||
|
||||
| Token | Value | Use | Contrast on `bg` / `surface` |
|
||||
| ------------- | --------- | --------------------------------------- | ---------------------------- |
|
||||
| `bg` | `#ffffff` | App background | — |
|
||||
| `surface` | `#faf8f5` | Page background (warm cream) | — |
|
||||
| `surface-2` | `#f5f5f7` | Cards, raised surfaces | — |
|
||||
| `surface-3` | `#e8e8ed` | Hover / pressed / skeletons | — |
|
||||
| `text` | `#1d1d1f` | Primary text | AAA on all 4 surfaces |
|
||||
| `text-muted` | `#424245` | Secondary text | AAA on all 4 surfaces |
|
||||
| `text-faint` | `#5e5e63` | Tertiary / meta | AA on all 4 surfaces (≥ 5.28 : 1) |
|
||||
| `accent` | `#14532d` | Primary actions, brand | AAA on `bg` (9.11), `surface` (8.59), `surface-2` (8.37), `surface-3` (7.46) |
|
||||
| `accent-2` | `#166534` | Hover state (button background) | White text on `accent-2` = 7.13 : 1 (AAA) |
|
||||
| `danger` | `#7f1d1d` | Errors, destructive | AAA on `bg` (10.02), `surface` (9.45), `surface-2` (9.20), `surface-3` (8.20) |
|
||||
| `warning` | `#5e2a04` | Warnings, low stock | AAA on `bg` (11.61), `surface` (10.95), `surface-2` (10.66), `surface-3` (9.51) |
|
||||
| `success` | `#14532d` | Success, completed | Mirrors `accent` |
|
||||
| `info` | `#1e3a8a` | Informational | AAA on all 4 surfaces (8.48–10.36) |
|
||||
|
||||
**Status text on its `-soft` pill background** is also AAA — the dark status text (e.g. `#14532d`) sits on a light tinted fill (e.g. `#dcfce7`) with ≥ 7:1 contrast. The full matrix (5 status × 4 surfaces + 5 status-on-soft + 4 body-text × 4 surfaces + 1 meta × 4 surfaces + 1 button-text + 5 soft-distinct = 35 assertions) is enforced by `src/lib/__tests__/design-tokens.test.ts`.
|
||||
|
||||
`text-faint` is the only token below AAA — used only for de-emphasized meta text where a stronger color would compete with primary content. All token combinations verified by hand calculation; the test suite in Section 5 enforces this in CI.
|
||||
|
||||
### Spacing (8pt grid)
|
||||
|
||||
`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64`
|
||||
|
||||
### Touch targets (HIG Accessibility + gloves)
|
||||
|
||||
- All interactive elements: **56pt min height** (HIG default 44pt → 56pt for gloves)
|
||||
- Tab bar items: 60pt tall, icon 28pt + label 12pt
|
||||
- List row primary action: full-row 72pt tap zone
|
||||
- Bottom sheet drag handle: 56pt wide × 5pt tall
|
||||
- FAB: 64pt diameter
|
||||
- Minimum spacing between adjacent tap targets: 8pt (HIG recommends 16pt for gloves; we use 12pt to keep density reasonable)
|
||||
|
||||
### Motion
|
||||
|
||||
- All motion respects `prefers-reduced-motion`
|
||||
- Card list items stagger in (16ms delay each, max 240ms total) on first mount
|
||||
- Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out)
|
||||
- StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out)
|
||||
- Pull-to-refresh: custom indicator showing last-synced time in mono
|
||||
- Default easing: `cubic-bezier(0.32, 0.72, 0, 1)` (Apple's standard)
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture & data flow
|
||||
|
||||
### Mobile admin shell
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ TopBar: [brand▼] [🔍] [👤] │ ← 56pt, sticky
|
||||
├─────────────────────────────────┤
|
||||
│ │
|
||||
│ Page content │ ← scrollable, max 720pt content width
|
||||
│ (card-stacked) │
|
||||
│ │
|
||||
├─────────────────────────────────┤
|
||||
│ [Home][Orders][Stops][Products] │ ← bottom tab bar, 60pt, fixed
|
||||
│ [More] │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Bottom tab bar** (always visible, thumb zone): Dashboard · Orders · Stops · Products · **More**
|
||||
- **"More" sheet** (drag-up full screen, 90% max-height): Communications, Pickup, Shipping, Reports, Analytics, Settings, etc. — every other admin link, organized into sections.
|
||||
- **TopBar** (sticky): brand selector (left), search button (center, opens full-screen search), profile menu (right).
|
||||
- **No desktop sidebar on mobile** — `AdminSidebar` is hidden below `lg`. Above `lg`, sidebar is unchanged.
|
||||
- A resize-aware `useMediaQuery('(min-width: 1024px)')` swaps between layouts inside `AdminShell`.
|
||||
|
||||
### New shared components
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `src/components/admin/AdminShell.tsx` | Server-component shell that picks nav based on breakpoint |
|
||||
| `src/components/admin/MobileTabBar.tsx` | Client component, fixed bottom, active state from `usePathname()` |
|
||||
| `src/components/admin/MoreSheet.tsx` | Drag-up sheet for secondary nav, uses native `<dialog>` + View Transitions |
|
||||
| `src/components/admin/PageHeader.tsx` | Consistent page title + actions row |
|
||||
| `src/components/admin/StatusPill.tsx` | Color-coded pill (uses new status tokens) |
|
||||
| `src/components/admin/EmptyState.tsx` | Used by all 4 key pages |
|
||||
| `src/components/admin/CardList.tsx` + `CardListItem` | Replaces tables on mobile |
|
||||
| `src/components/admin/StickyActionBar.tsx` | Primary action pinned to bottom of detail screens |
|
||||
| `src/components/admin/OfflineBanner.tsx` | Top-of-screen offline indicator with pending sync count |
|
||||
|
||||
### Routing
|
||||
|
||||
No URL changes. The same `/admin/orders`, `/admin/stops`, `/admin/products` routes serve both layouts; the shell is purely presentational. Server components stay server components — the mobile shell is a thin client wrapper around them.
|
||||
|
||||
### Data flow
|
||||
|
||||
| Page | Server source | Mobile client behavior |
|
||||
| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------- |
|
||||
| Dashboard | `get_dashboard_summary(brand_id)` (new RPC) | Cards, 2-col grid on tablet, 1-col on phone |
|
||||
| Orders list | `get_orders_for_brand(brand_id, …)` (existing) | CardList with status pill, customer, total, time |
|
||||
| Order detail | `get_order_detail(order_id)` (existing) | Header card + line items + timeline + StickyActionBar |
|
||||
| Stops | `get_stops_for_brand(brand_id, …)` (existing) | CardList with time, address, status, "navigate" action |
|
||||
| Products | `get_products_for_brand(brand_id, …)` (existing) | CardList with image, name, price, stock |
|
||||
|
||||
### Offline mutation queue
|
||||
|
||||
- `src/lib/offline/queue.ts` — IndexedDB store keyed by `clientActionId`
|
||||
- `src/lib/offline/sync.ts` — replays queued actions on `online` event with exponential backoff
|
||||
- Server actions stay the source of truth; the queue records `actionName + payload + clientActionId + createdAt` and posts them when online
|
||||
- Each queued action shows in the UI with a "pending / synced / conflict" badge in `StickyActionBar`
|
||||
- `OfflineBanner` shows when `navigator.onLine === false` and surfaces pending sync count
|
||||
- **Conflict resolution:** last-write-wins keyed on `updated_at` returned by the server. After each sync, the client refetches the affected entity and merges.
|
||||
- **Backoff:** 1s, 4s, 16s, 60s, 5min; give up after 5 attempts, surface a "Manual retry" button on the conflicting item.
|
||||
- **Idempotency:** every queued action carries a `clientActionId` (uuid v4 generated at enqueue time). The server's RPC layer is updated to accept and dedupe on this id.
|
||||
|
||||
### New RPC (for dashboard)
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'orders_today', (SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE),
|
||||
'revenue_today', (SELECT COALESCE(SUM(total), 0) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE AND status NOT IN ('cancelled')),
|
||||
'pending_fulfillment', (SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND status IN ('placed', 'ready')),
|
||||
'stops_today', (SELECT COUNT(*) FROM stops WHERE brand_id = p_brand_id AND scheduled_at::date = CURRENT_DATE),
|
||||
'orders_last_7_days', (
|
||||
SELECT jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0)))
|
||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d
|
||||
LEFT JOIN (
|
||||
SELECT created_at::date AS day, COUNT(*) AS cnt
|
||||
FROM orders
|
||||
WHERE brand_id = p_brand_id AND created_at >= CURRENT_DATE - INTERVAL '7 days'
|
||||
GROUP BY 1
|
||||
) o ON o.day = d::date
|
||||
)
|
||||
) INTO result;
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. PWA, offline, and production hardening
|
||||
|
||||
### PWA — what's broken and what we're fixing
|
||||
|
||||
| Issue today | Fix |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| `public/icons/` doesn't exist | Generate all 8 sizes (72–512) PNG + maskable + badge + 3 shortcut icons |
|
||||
| `public/screenshots/` doesn't exist | Generate `dashboard.png` (1280×720) and `mobile-storefront.png` (390×844) |
|
||||
| `apple-touch-icon.png` missing | Generate 180×180 + 167×167 + 152×152 |
|
||||
| `favicon.ico` missing | Generate from SVG |
|
||||
| `sw.js` references `/offline` page that doesn't exist | Create `public/offline.html` (clean cream HIG-style page) |
|
||||
| `sw.js` references `/og-default.jpg` (only .svg exists)| Update ref to `/og-default.svg` |
|
||||
| `registerServiceWorker()` exported but never called | Call it from `src/components/Providers.tsx` (client side) |
|
||||
| `PWAInstallPrompt` never mounted | Mount in `src/app/admin/layout.tsx` (admin is the installable surface) |
|
||||
| `themeColor` mismatch (`#0a0a0a` vs `#1a4d2e`) | Standardize on `#166534` (forest-800) |
|
||||
| Missing PWA meta tags | Add `apple-mobile-web-app-capable`, `application-name`, `mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style` |
|
||||
| `viewport.maximumScale = 5` but no `viewportFit: cover`| Add `viewportFit: 'cover'` for notched devices |
|
||||
|
||||
### Service worker rewrite
|
||||
|
||||
Replace `public/sw.js` with a versioned, two-cache SW:
|
||||
|
||||
- **App shell cache** (e.g. `rc-shell-v3`): pre-caches `/`, `/admin`, `/manifest.json`, fonts, icons, offline page — install-time `cache.addAll(…)`.
|
||||
- **Data cache** (e.g. `rc-data-v3`): stale-while-revalidate for `/api/orders`, `/api/stops`, `/api/products`, `/api/dashboard` — read offline, refetch in background.
|
||||
- **Cache strategies:**
|
||||
- Navigations → network-first, fall back to cache, fall back to `/offline.html`
|
||||
- Static assets (`/_next/static/*`, `/icons/*`, `/screenshots/*`) → cache-first
|
||||
- API GETs (`/api/*` non-mutation) → stale-while-revalidate
|
||||
- API mutations (`POST`/`PATCH`/`DELETE`) → never cached, pass-through; offline behavior handled by the client queue
|
||||
- **Versioning:** `CACHE_NAME` bumped on each release; activate handler deletes old caches; `skipWaiting()` + `clients.claim()` to roll out updates fast.
|
||||
- **Logging:** all `console.log` calls guarded by `process.env.NODE_ENV !== 'production'`.
|
||||
|
||||
### Production hardening
|
||||
|
||||
- `next.config.ts` — add `headers()` for `Cache-Control: public, max-age=31536000, immutable` on `/_next/static/*` and `/icons/*`
|
||||
- Font preloading: `next/font` already inlines the CSS — verify the `display: swap` strategy stays
|
||||
- Image optimization: existing `next/image` config; add `priority` to the admin logo in the top bar
|
||||
- Code splitting: admin layout stays in its own chunk; mobile shell is a dynamic import gated on viewport
|
||||
- No layout shift on font load: `next/font` reserves metric-based space
|
||||
|
||||
### Files touched in this section
|
||||
|
||||
- `public/manifest.json` (theme_color + new screenshot paths)
|
||||
- `public/sw.js` (full rewrite)
|
||||
- `public/offline.html` (new)
|
||||
- `public/icons/*.png` (8 + maskable + badge + 3 shortcuts = 13 new files)
|
||||
- `public/screenshots/*.png` (2 new)
|
||||
- `public/apple-touch-icon.png` + variants (3 new)
|
||||
- `public/favicon.ico` (1 new)
|
||||
- `src/app/layout.tsx` (add PWA meta + register SW via Providers)
|
||||
- `src/components/Providers.tsx` (call `registerServiceWorker()` on mount)
|
||||
- `src/app/admin/layout.tsx` (mount `PWAInstallPrompt`)
|
||||
- `next.config.ts` (cache headers for static assets)
|
||||
|
||||
---
|
||||
|
||||
## 4. Page-by-page layouts
|
||||
|
||||
All pages share: top bar, page header (title + primary action), card-stacked body (no tables on phones), bottom tab bar. The desktop layout is unchanged above `lg`.
|
||||
|
||||
### Dashboard (`/admin`)
|
||||
|
||||
- **Page header:** "Today" (Fraunces 32pt) + date in mono + brand selector (in top bar)
|
||||
- **Stat row** (2×2 grid on phone, 4×1 on tablet+): Orders Today · Revenue · Pending Fulfillment · Stops Today — each card is `surface-2` with a big Fraunces number, Manrope label, and a 7-day sparkline in mono
|
||||
- **"Needs you" card** (only if pending actions > 0): top-of-page warning card with count + "View all" CTA
|
||||
- **Today's stops timeline:** vertical timeline of upcoming stops, each row tappable
|
||||
- **Aesthetic:** calm, editorial, "open the laptop on the couch" feel. Numbers in Fraunces do the talking.
|
||||
|
||||
### Orders list (`/admin/orders`)
|
||||
|
||||
- **Page header:** "Orders" + filter button (opens bottom sheet) + "+ New order"
|
||||
- **Filter sheet:** status, date range, customer, stop — drag-up sheet, sticky apply bar
|
||||
- **Card list** (one per order):
|
||||
- Top row: status pill (left) · order # in mono · time-ago (right)
|
||||
- Middle: customer name (Manrope 700 19pt) + item count ("4 items")
|
||||
- Bottom: total (Fraunces 22pt) · arrow chevron
|
||||
- Whole row: 72pt tap zone
|
||||
- **Empty state:** "No orders yet" + illustration + "+ New order" CTA
|
||||
- **Pull-to-refresh** at the top
|
||||
|
||||
### Order detail (`/admin/orders/[id]`)
|
||||
|
||||
- **Header card:** customer name (Fraunces 28pt), phone + email (tap-to-call / tap-to-email), order # in mono, status pill
|
||||
- **Line items card:** each item is a sub-row — image (56pt square), name, qty × price, fulfillment icon (pickup vs ship)
|
||||
- **Fulfillment timeline card:** horizontal stepper (Placed → Ready → Picked up) with timestamps in mono
|
||||
- **Customer notes card** (if present): quoted, italic
|
||||
- **StickyActionBar** (bottom, above tab bar): primary action changes by status:
|
||||
- Placed → "Mark Ready" (forest-800 fill, full width, 56pt)
|
||||
- Ready → "Mark Picked Up" (forest-800 fill)
|
||||
- Picked up → "View Receipt" (outlined)
|
||||
- **Secondary actions** (refund, edit, contact customer) live in a top-right "•••" menu
|
||||
- **Offline pending badge:** if the order was just updated offline, the action bar shows "Pending sync" with a tiny spinner until the queue replays
|
||||
|
||||
### Stops (`/admin/stops`)
|
||||
|
||||
- **Page header:** "Stops" + date picker (large, tappable) + "+ New stop"
|
||||
- **Time-grouped sections:** "Morning" / "Afternoon" / "Evening" with sticky section headers (Manrope 700 15pt, surface-50 bg, 8pt vertical padding)
|
||||
- **Stop card:**
|
||||
- Time (Fraunces 28pt, left) · address (Manrope 600 17pt) · customer count
|
||||
- Status pill + "Navigate" icon button (opens maps app via `navigator.share` fallback to URL)
|
||||
- Inline status changer: tap status pill → bottom sheet with status options
|
||||
- Whole row: 88pt to accommodate time + address comfortably
|
||||
- **Empty state:** "No stops scheduled" + "+ New stop"
|
||||
|
||||
### Products (`/admin/products`)
|
||||
|
||||
- **Page header:** "Products" + search input (always visible, prominent) + "+ New product"
|
||||
- **Card list:**
|
||||
- Image (64pt square, rounded-2xl, object-cover) · name (Manrope 700 17pt) · price (Fraunces 20pt)
|
||||
- Stock row: "142 in stock" or "⚠ 8 left" or "✕ Out of stock" — color + icon, never color alone
|
||||
- "Adjust stock" via long-press → bottom sheet with +/− stepper
|
||||
- **Filter chips above list:** All · In stock · Low · Out · Hidden
|
||||
- **Empty state:** "No products" + "+ New product"
|
||||
|
||||
### Motion (per page)
|
||||
|
||||
- Card list items stagger in (16ms delay each, max 240ms total) on first mount
|
||||
- Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out)
|
||||
- Pull-to-refresh: custom indicator showing last-synced time
|
||||
- StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out)
|
||||
- All motion respects `prefers-reduced-motion`
|
||||
|
||||
### Aesthetic summary
|
||||
|
||||
Editorial-meets-field. Fraunces for numbers, prices, and the few serif headlines; Manrope for everything else; Fragment Mono for IDs, totals, timestamps. Warm cream surfaces, deeply saturated status colors, generous breathing room. Reads like a high-end tool someone made for themselves — not a generic admin template.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing & verification
|
||||
|
||||
### Unit tests (Vitest)
|
||||
|
||||
- `src/lib/offline/queue.test.ts` — enqueue, dequeue, persist across page reload, idempotency on `clientActionId`
|
||||
- `src/lib/offline/sync.test.ts` — replays on `online` event, exponential backoff (1s, 4s, 16s, 60s, 5min, give up at 5 attempts), conflict surfacing
|
||||
- `src/lib/format-date.test.ts` — extend for new relative formats ("just now", "5m ago", "2h ago", "yesterday")
|
||||
- `src/lib/__tests__/design-tokens.test.ts` — asserts all token combinations meet WCAG contrast on the surfaces they actually render on (body text on all 4 surfaces at AAA; status text on `bg` + `surface` + their `-soft` pill backgrounds at AAA; `text-faint` on all 4 surfaces at AA; `accent-2` as a button background with white text at AAA); fails CI if a new color regresses
|
||||
|
||||
### Component tests (Vitest + Testing Library)
|
||||
|
||||
- `MobileTabBar` — active state, more-sheet trigger, keyboard nav
|
||||
- `StickyActionBar` — pending badge, conflict state
|
||||
- `OfflineBanner` — appears on `offline` event, hidden on `online`
|
||||
- `StatusPill` — every status × every color combination renders the right aria-label
|
||||
- `MoreSheet` — opens via More button, traps focus, closes on Escape and backdrop click, restores focus on close
|
||||
|
||||
**UI primitives:** No dialog/sheet library (vaul, radix, headlessui) is currently in the project. `MoreSheet` and the various bottom sheets (filter, status changer, stock stepper) use the native `<dialog>` element with `showModal()` + the View Transitions API for open/close animation. This is a deliberate choice to keep the bundle small for the PWA. The native `<dialog>` is well-supported in Safari 15.4+, Chrome 37+, and Firefox 98+ — all versions we target.
|
||||
|
||||
**Pull-to-refresh:** Custom implementation using pointer events on a sentinel element at the top of each list. We avoid adding a third-party pull-to-refresh library to keep the bundle small. The custom implementation is ~80 lines, respects `overscroll-behavior: contain`, and falls back to no-op if the user has indicated motion sensitivity.
|
||||
|
||||
**Tap-to-call / tap-to-email:** Implemented with explicit `tel:` and `mailto:` URL schemes on `<a>` tags (not buttons that call `window.location`). This ensures the OS handles the routing (e.g., FaceTime prompts on iOS, Gmail/Outlook chooser on Android) and is accessible to screen readers out of the box.
|
||||
|
||||
**Color contrast test exclusions:** `text-faint` is the only token below WCAG AAA (it passes AA at ≥ 5.28:1). The test in `src/lib/__tests__/design-tokens.test.ts` will assert AAA (7:1) for every token combination *except* `text-faint`, which is tested at AA (4.5:1). This is documented in the test file as an intentional exception.
|
||||
|
||||
### E2E (Playwright)
|
||||
|
||||
New spec: `tests/mobile-admin/pwa-install.spec.ts`
|
||||
- Loads `/admin` on iPhone 13 viewport
|
||||
- Asserts manifest link present, manifest is valid JSON, all icons return 200
|
||||
- Asserts `apple-touch-icon` link present
|
||||
- Asserts service worker registers successfully (via `navigator.serviceWorker.ready`)
|
||||
|
||||
New spec: `tests/mobile-admin/orders-list.spec.ts`
|
||||
- Loads `/admin/orders` on iPhone 13 viewport
|
||||
- Asserts no horizontal scroll (page width ≤ viewport)
|
||||
- Asserts every interactive element has touch target ≥ 48pt
|
||||
- Asserts all text passes axe-core
|
||||
|
||||
New spec: `tests/mobile-admin/offline-queue.spec.ts`
|
||||
- Loads `/admin/orders/<id>` on iPhone 13 viewport
|
||||
- Goes offline (Playwright `context.setOffline(true)`)
|
||||
- Clicks "Mark Ready"
|
||||
- Asserts the optimistic UI updates
|
||||
- Asserts the action bar shows "Pending sync" badge
|
||||
- Goes back online
|
||||
- Asserts the badge clears within 5s and the server has the new status
|
||||
|
||||
### Visual regression (Playwright `toHaveScreenshot`)
|
||||
|
||||
- All 4 key page screens at 390×844 (iPhone 13)
|
||||
- All 4 key page screens at 1024×768 (iPad)
|
||||
- MobileTabBar — light + dark
|
||||
- OfflineBanner — visible, hidden
|
||||
|
||||
### Manual QA checklist (added to PR template)
|
||||
|
||||
- [ ] Read every new screen in direct sunlight (or under a 5000K desk lamp at full brightness)
|
||||
- [ ] Tap every interactive element while wearing work gloves
|
||||
- [ ] Verify install-to-home-screen on a real iPhone (Safari → Share → Add to Home Screen) and confirm the splash + app icon are correct
|
||||
- [ ] Toggle airplane mode and exercise: orders list, order detail, stops, products, mark-ready, mark-picked-up, stop status change, stock adjust
|
||||
- [ ] Run `npx lighthouse http://localhost:3000/admin --preset=mobile --view` and confirm Performance ≥ 90, Accessibility ≥ 95, PWA checks pass
|
||||
|
||||
### Lighthouse / PWA gate (CI)
|
||||
|
||||
- Add a CI job (`.gitea/workflows/pwa-audit.yml`) that runs `lighthouse-ci` against the preview URL on every PR
|
||||
- PWA category must pass (installable, manifest valid, service worker registered, themed)
|
||||
- Mobile accessibility score must be ≥ 95
|
||||
|
||||
### Documentation
|
||||
|
||||
- `docs/pwa-testing.md` — how to manually test PWA + offline on a real device
|
||||
- `docs/admin-mobile.md` — design rationale + token reference for the team
|
||||
|
||||
### Files touched in this section
|
||||
|
||||
~8 new test files, 1 CI workflow, 2 docs, plus assertions added to `playwright.config.ts` to include the `mobile-admin` project at iPhone 13 viewport by default.
|
||||
|
||||
---
|
||||
|
||||
## File inventory (consolidated)
|
||||
|
||||
### New files
|
||||
|
||||
- `public/icons/icon-{72,96,128,144,152,192,384,512}x{72,96,128,144,152,192,384,512}.png` (8)
|
||||
- `public/icons/icon-maskable-512x512.png` (1)
|
||||
- `public/icons/badge-72x72.png` (1)
|
||||
- `public/icons/{orders,products,stops}-shortcut.png` (3)
|
||||
- `public/screenshots/dashboard.png` (1)
|
||||
- `public/screenshots/mobile-storefront.png` (1)
|
||||
- `public/apple-touch-icon.png` (180×180), `public/apple-touch-icon-167.png`, `public/apple-touch-icon-152.png` (3)
|
||||
- `public/favicon.ico` (1)
|
||||
- `public/offline.html` (1)
|
||||
- `src/components/admin/AdminShell.tsx`
|
||||
- `src/components/admin/MobileTabBar.tsx`
|
||||
- `src/components/admin/MoreSheet.tsx`
|
||||
- `src/components/admin/PageHeader.tsx`
|
||||
- `src/components/admin/StatusPill.tsx`
|
||||
- `src/components/admin/EmptyState.tsx`
|
||||
- `src/components/admin/CardList.tsx`
|
||||
- `src/components/admin/StickyActionBar.tsx`
|
||||
- `src/components/admin/OfflineBanner.tsx`
|
||||
- `src/lib/offline/queue.ts`
|
||||
- `src/lib/offline/sync.ts`
|
||||
- `src/lib/offline/db.ts` (IndexedDB wrapper)
|
||||
- `src/lib/__tests__/design-tokens.test.ts`
|
||||
- `src/lib/offline/queue.test.ts`
|
||||
- `src/lib/offline/sync.test.ts`
|
||||
- `tests/mobile-admin/pwa-install.spec.ts`
|
||||
- `tests/mobile-admin/orders-list.spec.ts`
|
||||
- `tests/mobile-admin/offline-queue.spec.ts`
|
||||
- `.gitea/workflows/pwa-audit.yml`
|
||||
- `docs/pwa-testing.md`
|
||||
- `docs/admin-mobile.md`
|
||||
- `db/migrations/NNN_dashboard_summary_rpc.sql` (new RPC for dashboard)
|
||||
|
||||
### Modified files
|
||||
|
||||
- `public/manifest.json` (theme_color, screenshot paths)
|
||||
- `public/sw.js` (full rewrite)
|
||||
- `src/app/layout.tsx` (PWA meta tags, viewport.fit, themeColor)
|
||||
- `src/components/Providers.tsx` (call `registerServiceWorker()`)
|
||||
- `src/app/admin/layout.tsx` (mount `PWAInstallPrompt` + `OfflineBanner`, swap sidebar for `AdminShell`)
|
||||
- `src/app/admin/v2/page.tsx` (Dashboard card layout — new v2 route)
|
||||
- `src/app/admin/v2/orders/page.tsx` (CardList — new v2 route)
|
||||
- `src/app/admin/v2/orders/[id]/page.tsx` (card layout + StickyActionBar + offline-queue integration — new v2 route)
|
||||
- `src/app/admin/v2/stops/page.tsx` (time-grouped card list — new v2 route)
|
||||
- `src/app/admin/v2/products/page.tsx` (image-forward card list — new v2 route)
|
||||
- `next.config.ts` (cache headers for static assets; cutover redirects in PRs 6 and 8)
|
||||
- `playwright.config.ts` (add `mobile-admin` project at iPhone 13 viewport)
|
||||
- `src/lib/pwa.ts` (small fixes: `load` listener guard, dev-only logging)
|
||||
- `src/app/page.tsx` (favicon ref fix)
|
||||
|
||||
Note: the v1 admin pages (`src/app/admin/page.tsx`, `src/app/admin/orders/page.tsx`, etc.) are **not** modified by PRs 1–5. They continue to serve the existing desktop layout until the cutover PRs redirect their routes to v2. After cutover, the v1 files are deleted in a follow-up commit.
|
||||
|
||||
---
|
||||
|
||||
## Open questions (deferred)
|
||||
|
||||
- **Web Push notifications** — SW has a handler scaffolded, but enabling real pushes requires VAPID keys, a push service, and a UX decision. Tabled for a follow-up spec.
|
||||
- **Wholesale portal mobile** — out of scope here; would benefit from the same design system once the admin is shipped.
|
||||
- **App Store / Play Store packaging** — Capacitor / TWA is the obvious next step once the PWA is solid. Not in this spec.
|
||||
- **Image upload from camera** — products/stops could use this; separate spec.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
1. **The existing admin pages have a lot of bespoke UI** (drag-and-drop, complex tables, modals). Some of it may need to be simplified for the mobile-first treatment, not just wrapped. We'll discover this during implementation; the design language and shared components are designed to absorb it.
|
||||
2. **The IndexedDB queue + idempotency requires server-side support** (`clientActionId` parameter on the relevant RPCs). The migration for the new RPCs is in scope; the RPC changes for idempotency are flagged here so they don't surprise us.
|
||||
3. **iOS PWA limitations** — no push, no background sync (until iOS 16.4+ and even then limited), no install prompt via API (we use the manual `beforeinstallprompt` only, plus the iOS "Add to Home Screen" instructions in our install prompt copy). We document these limitations in the install prompt UI.
|
||||
4. **Outdoor screen testing is subjective.** The 5000K lamp proxy won't catch every case. Real device testing in sunlight is required for sign-off.
|
||||
|
||||
---
|
||||
|
||||
## Rollout plan
|
||||
|
||||
> **Note on feature flags:** The project has a `src/lib/feature-flags.ts` system, but it's for **brand-scoped add-on features** (Harvest Reach, Square Sync, etc.), not for app-wide UI rollouts. There is no general-purpose feature flag system in this codebase. We will use a **route-based dual-deployment** strategy for rollout:
|
||||
>
|
||||
> - PRs 1–2 (design system + PWA scaffolding) are non-breaking; they add the new infrastructure without changing existing routes.
|
||||
> - PRs 3–5 (Orders, Stops, Products) ship the new mobile-first versions at **`/admin/v2/orders`**, **`/admin/v2/stops`**, **`/admin/v2/products`**, and **`/admin/v2/orders/[id]`** initially. The old `/admin/orders` etc. routes continue to work unchanged. Internal team dogfoods at `/admin/v2/*` for 1–2 weeks.
|
||||
> - PR 6 cuts over: a redirect in `next.config.ts` sends `/admin/orders*` → `/admin/v2/orders*`, etc. The old routes are removed in a follow-up commit.
|
||||
> - PR 7 (Dashboard at `/admin/v2`) follows the same pattern.
|
||||
>
|
||||
> This avoids the need to introduce a feature flag system, which would be a separate piece of work.
|
||||
|
||||
1. **PR 1 — Design system + admin shell** — type scale, color tokens, AdminShell, MobileTabBar, MoreSheet, offline queue + sync. No page changes yet. Lighthouse PWA gate added.
|
||||
2. **PR 2 — PWA manifest + SW + icons + offline page** — PWA installable, splash, themed status bar. All tests pass.
|
||||
3. **PR 3 — Orders v2 (list + detail)** at `/admin/v2/orders/*` — first page on the new design language. Validates the components in real use.
|
||||
4. **PR 4 — Stops v2** at `/admin/v2/stops`
|
||||
5. **PR 5 — Products v2** at `/admin/v2/products`
|
||||
6. **PR 6 — Cutover Orders + Stops + Products** — redirects in `next.config.ts`; old routes removed in a follow-up commit
|
||||
7. **PR 7 — Dashboard v2** at `/admin/v2` (depends on the new `get_dashboard_summary` RPC migration)
|
||||
8. **PR 8 — Dashboard cutover** — redirect `/admin` → `/admin/v2`; remove old dashboard
|
||||
|
||||
Each PR is independently shippable and reversible. The desktop layout is never affected until cutover PRs land.
|
||||
|
||||
---
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [ ] All 4 key pages ship the mobile-first treatment
|
||||
- [ ] PWA installs on iOS Safari and Android Chrome
|
||||
- [ ] Service worker pre-caches the app shell, swr-caches the data, falls back to `/offline` on navigation
|
||||
- [ ] Offline mutation queue replays actions on reconnect with no data loss
|
||||
- [ ] All interactive elements ≥ 48pt (aim 56pt) on mobile
|
||||
- [ ] All text passes WCAG AAA (7:1) against its background
|
||||
- [ ] Lighthouse mobile audit: Performance ≥ 90, Accessibility ≥ 95, PWA passes
|
||||
- [ ] No horizontal scroll on any of the 4 key pages at iPhone 13 viewport
|
||||
- [ ] `prefers-reduced-motion` honored
|
||||
- [ ] All Playwright specs (PWA install, orders list, offline queue) pass in CI
|
||||
- [ ] Visual regression baselines committed for all 4 key pages × 2 viewports
|
||||
- [ ] Real-device install + offline testing documented and signed off
|
||||
@@ -0,0 +1,135 @@
|
||||
# Admin Redesign — 2026-06-17
|
||||
|
||||
> Branch: `design/ui-revamp-2026-06`
|
||||
> Type: Hot take. Tomorrow deadline. Approved yolo.
|
||||
> Goal: polish the public side, reimagine the admin, fix the colors, make things findable.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
- **Public side (`/`, `/tuxedo`, `/indian-river-direct`, pricing, blog, contact)** — "good, just needs polishing." The "Atelier des Récoltes" / "Field Almanac" editorial language is in place (cream + forest + gold + Fraunces + Manrope + Fragment Mono). Don't break it.
|
||||
- **Admin (`/admin/*`)** — the surface that needs the most work.
|
||||
- 15+ admin sections are split across 4 dashboard tabs (Operations / Fulfillment / Management / Tools). **The user can't find things.**
|
||||
- Color palette mixes forest + citrus + sage + gold + warmred + surface greys. **The colors fight each other and feel muddy.**
|
||||
- Sidebar has 6 flat items. "Settings" sub-pages are reachable only by knowing they exist. **Discoverability is bad.**
|
||||
- The "Harvest Almanac" design system (`.ha-*` classes) exists but is underused. Most admin pages fall back to defaults.
|
||||
|
||||
## 2. Aesthetic direction (one paragraph, committed)
|
||||
|
||||
> **Editorial operations.** The admin should feel like the back office of an almanac press, not a generic SaaS dashboard. Warm cream canvas, deep botanical green for primary action, amber gold for the *one* thing you should do next, ink-black for text, a single soft beige for borders. The same typeface family as the public side (Fraunces / Manrope / Fragment Mono). Generous use of numerals in Fragment Mono so prices and counts read like a ledger. **One primary, one accent, four neutrals. That's it.** No purple, no teal, no citrus orange as a primary.
|
||||
|
||||
Commit to this and stop adding new colors.
|
||||
|
||||
## 3. Color system (final, do not deviate)
|
||||
|
||||
| Token | Hex | Use |
|
||||
|---|---|---|
|
||||
| `--admin-bg` | `#FAF7F0` | Page background (atelier cream) |
|
||||
| `--admin-surface` | `#FFFFFF` | Cards, modals, sheets |
|
||||
| `--admin-surface-sunken` | `#F4F1E8` | Inset wells, code blocks |
|
||||
| `--admin-ink` | `#1A1814` | Primary text |
|
||||
| `--admin-ink-muted` | `#73706B` | Secondary text |
|
||||
| `--admin-ink-faint` | `#A8A29E` | Tertiary / placeholders |
|
||||
| `--admin-line` | `#E8E4D7` | Borders, dividers |
|
||||
| `--admin-line-strong` | `#D4CFBE` | Emphasized borders |
|
||||
| `--admin-primary` | `#1F4D2A` | Primary action (deep botanical) |
|
||||
| `--admin-primary-hover` | `#163C20` | Primary hover |
|
||||
| `--admin-primary-soft` | `#E8F0E5` | Primary at 8% (selected rows, chips) |
|
||||
| `--admin-accent` | `#B8761E` | Amber — "the one thing to do" |
|
||||
| `--admin-accent-soft` | `#F7EBD5` | Amber at 12% |
|
||||
| `--admin-success` | `#1F4D2A` | (same as primary; semantic) |
|
||||
| `--admin-warning` | `#B8761E` | (same as accent; semantic) |
|
||||
| `--admin-danger` | `#A8321C` | Errors / destructive |
|
||||
| `--admin-danger-soft` | `#F7E3DE` | Danger at 12% |
|
||||
|
||||
**Drop**: `--admin-warning: #aba278` (looks like dirt), the old `citrus` orange in the active state, the blue/purple stat icons. Replace with semantic use of the new palette.
|
||||
|
||||
## 4. IA decision
|
||||
|
||||
Sidebar groups, top to bottom, with section dividers:
|
||||
|
||||
| Group | Items |
|
||||
|---|---|
|
||||
| **Workspace** | Dashboard · Command Center (platform_admin only) |
|
||||
| **Operations** | Orders · Stops & Routes · Products · Driver Pickup · Shipping |
|
||||
| **Communications** | Harvest Reach (campaigns, templates, contacts, segments, logs) |
|
||||
| **Growth** | Wholesale · Import Center · AI Intelligence |
|
||||
| **Tracking** | Time Tracking · Water Log · Route Trace |
|
||||
| **Insights** | Reports · Tax Dashboard |
|
||||
| **Settings** | General · Brand · Billing · Users & Permissions · Integrations · Payments · Shipping · Add-ons |
|
||||
|
||||
The old "Workers & PINs" sidebar item is a tab on Time Tracking now. Settings is one click → secondary nav inside settings (tabs at the top of the page).
|
||||
|
||||
**Cmd+K command palette** for global search across:
|
||||
- Pages (jumps to any admin route)
|
||||
- Recent orders / products / stops / customers (live search)
|
||||
- Quick actions ("Create order", "Add product", "Send blast")
|
||||
|
||||
## 5. Component patterns (consistency pass)
|
||||
|
||||
| Pattern | Owner | Status |
|
||||
|---|---|---|
|
||||
| `PageHeader` (eyebrow + title + subtitle + actions) | `src/components/admin/design-system/PageHeader.tsx` | exists, audit usage |
|
||||
| `EmptyState` (icon + title + body + CTA) | same dir, currently `EmptyState.tsx` exists for shared, admin needs its own | add `.ha-empty` |
|
||||
| `LoadingState` (skeleton + label) | `.ha-skeleton` exists in CSS, no component | wrap |
|
||||
| `ConfirmDialog` (destructive action) | `AdminDeleteConfirm.tsx` exists | audit usage |
|
||||
| `StatusPill` (success/warn/danger/info/neutral) | `AdminBadge.tsx` exists, variants are off | tighten variants |
|
||||
| `KPIStat` (label + value + trend) | inline in `DashboardClient` | extract to component |
|
||||
| `CommandPalette` (Cmd+K) | **new** | new component |
|
||||
| `SideNavGroup` (label + items) | **new** | replaces flat NAV_ITEMS |
|
||||
| `DataTable` (sortable, filterable, paginated) | `shared/DataTable.tsx` exists | tighten + use everywhere |
|
||||
|
||||
## 6. Phased execution (yolo, one day)
|
||||
|
||||
**Phase 1 — Foundation (commit per file)**
|
||||
- Update `src/styles/admin-design-system.css` color tokens
|
||||
- New `SideNavGroup` component
|
||||
- Replace `AdminSidebar` flat list with grouped nav
|
||||
- New `CommandPalette` component (mounted in admin layout)
|
||||
- Refactor `DashboardClient` to unified command center (drop 4 tabs, single column feed)
|
||||
|
||||
**Phase 2 — Pattern extraction (commit per file)**
|
||||
- Extract `KPIStat` from dashboard
|
||||
- Refine `EmptyState` admin variant
|
||||
- `LoadingState` wrapper
|
||||
- `StatusPill` tighten variants
|
||||
|
||||
**Phase 3 — Apply to highest-traffic pages**
|
||||
- `/admin/orders` + detail
|
||||
- `/admin/products` + new + edit
|
||||
- `/admin/stops` calendar
|
||||
- `/admin/communications` composer
|
||||
- `/admin/settings/*` tabs
|
||||
|
||||
**Phase 4 — Frontend polish**
|
||||
- Public page spot-check: hero spacing, section rhythm
|
||||
- Verify TypeScript + build green
|
||||
|
||||
## 7. Risk & revert
|
||||
|
||||
- Working on branch `design/ui-revamp-2026-06` (already created off `main`).
|
||||
- **Every milestone is its own commit.** `git reset --hard <sha>` reverts to that point.
|
||||
- **One-click full revert:** `git checkout main && git branch -D design/ui-revamp-2026-06` — but only after a failed `git push`.
|
||||
- Type-check (`npx tsc --noEmit`) + `npm run build` are the gate at the end of each phase. Build green = milestone accepted.
|
||||
- If something breaks the admin layout at runtime, `git revert HEAD` unwinds the most recent commit without losing history.
|
||||
- **Do not delete or rename files in this branch.** Only add, only modify. Easier to revert.
|
||||
|
||||
## 8. Out of scope (yolo tomorrow, not now)
|
||||
|
||||
- Wholesale portal pages (`/wholesale/*`)
|
||||
- Water-log standalone pages
|
||||
- Public marketing pages (pricing, blog, changelog) — polish later
|
||||
- Wholesale auth migration (separate task per MEMORY.md)
|
||||
- Migrating remaining legacy `supabase.from()` calls (separate task per MEMORY.md)
|
||||
|
||||
## 9. What "done" looks like
|
||||
|
||||
- [ ] Color tokens unified, no muddy mix
|
||||
- [ ] Sidebar has 6 visible groups with section labels
|
||||
- [ ] Cmd+K opens a command palette that can navigate to any admin page
|
||||
- [ ] Dashboard is a single feed, no 4-tab structure
|
||||
- [ ] Top 6 admin pages use the new PageHeader / StatusPill / EmptyState consistently
|
||||
- [ ] `npx tsc --noEmit` clean
|
||||
- [ ] `npm run build` clean
|
||||
- [ ] Dev server boots, `/admin` renders, sidebar works, Cmd+K opens
|
||||
@@ -0,0 +1,337 @@
|
||||
# Water Log
|
||||
|
||||
A standalone irrigation / water-usage tracker for ditch riders, water
|
||||
admins, and farm operators. Tracks flow measurements against physical
|
||||
headgates, attributes them to named water users, and rolls them up for
|
||||
reporting.
|
||||
|
||||
The module is **PIN-based** and lives entirely outside the platform
|
||||
admin auth: an irrigator with a 4-digit PIN can submit entries from a
|
||||
phone in the field without an account on the platform. Site admins
|
||||
manage headgates, users, and settings from `/admin/water-log`.
|
||||
|
||||
## When to use this
|
||||
|
||||
- You run irrigation ditches / headgates and need to track daily
|
||||
flow with named operators.
|
||||
- You need a mobile-friendly entry surface that works in low
|
||||
connectivity (no login, just a PIN).
|
||||
- You want a per-brand, brand-scoped record of who recorded what,
|
||||
with audit trail + CSV export for water-rights reporting.
|
||||
|
||||
## When NOT to use this
|
||||
|
||||
- For sensor/IoT integrations, use the time-series / `Square Sync`
|
||||
flow rather than this module — entries here are hand-typed.
|
||||
- For multi-tenant water-rights billing, use the wholesale deposit
|
||||
flow; this module is a measurement ledger, not a billing system.
|
||||
|
||||
---
|
||||
|
||||
## Architecture at a glance
|
||||
|
||||
```
|
||||
┌────────────────────────┐ ┌─────────────────────────┐
|
||||
│ /water (PIN form) │ │ /water/admin/login │
|
||||
│ irrigator → submit │ │ (water_admin PIN) │
|
||||
│ → wl_session cookie │ │ → wl_admin_session │
|
||||
└──────────┬─────────────┘ └──────────┬──────────────┘
|
||||
│ │
|
||||
│ ┌─────────────────────────┘
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Postgres (RLS) — brand scoped │
|
||||
│ water_headgates, water_irrigators, │
|
||||
│ water_log_entries, water_sessions, │
|
||||
│ water_admin_sessions, water_ │
|
||||
│ audit_log, water_alert_log, │
|
||||
│ water_admin_settings │
|
||||
└─────────────────────────────────────┘
|
||||
▲
|
||||
│
|
||||
┌─────────────┴──────────────┐
|
||||
│ /admin/water-log │
|
||||
│ Site-admin CRUD + exports │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
**Two separate PIN surfaces:**
|
||||
|
||||
| Surface | Cookie | TTL | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/water` (irrigator) | `wl_session` | 8 h | Submit flow entries |
|
||||
| `/water/admin` (admin) | `wl_admin_session` | configurable (1–168 h) | Manage headgates/users, view all entries |
|
||||
|
||||
Both cookies are `httpOnly`, `sameSite=lax`, and `secure` in production.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
### `water_headgates`
|
||||
Physical gates a measurement is tied to.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | uuid PK | |
|
||||
| `brand_id` | uuid FK → brands | brand scope (RLS) |
|
||||
| `name` | text | e.g. "Upper Ditch Headgate" |
|
||||
| `headgate_token` | text UNIQUE | opaque token used in the QR code |
|
||||
| `status` | text | `open` / `closed` / `maintenance` |
|
||||
| `unit` | text | default measurement unit (CFS, GPM, AF, …) |
|
||||
| `max_flow_gpm` | numeric | optional marker |
|
||||
| `high_threshold` | numeric | alert when reading > this |
|
||||
| `low_threshold` | numeric | alert when reading < this |
|
||||
| `notes` | text | |
|
||||
| `active` | bool | soft-delete |
|
||||
| `last_used_at` | timestamptz | updated on each entry |
|
||||
| `created_at` | timestamptz | |
|
||||
|
||||
### `water_irrigators`
|
||||
Field workers with PIN access.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | uuid PK | |
|
||||
| `brand_id` | uuid FK → brands | |
|
||||
| `name` | text | |
|
||||
| `pin_hash` | text | scrypt `$N$r$p$salt$hash` — never plain |
|
||||
| `role` | text | `irrigator` (submit only) or `water_admin` (manage) |
|
||||
| `language_preference` | text | `en` / `es` |
|
||||
| `phone` | text | optional |
|
||||
| `notes` | text | |
|
||||
| `active` | bool | soft-delete |
|
||||
| `last_used_at` | timestamptz | |
|
||||
| `created_at` | timestamptz | |
|
||||
|
||||
### `water_log_entries`
|
||||
The actual readings.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | uuid PK | |
|
||||
| `brand_id` | uuid FK → brands | |
|
||||
| `headgate_id` | uuid FK | |
|
||||
| `irrigator_id` | uuid FK | who submitted |
|
||||
| `measurement` | numeric | raw value in `unit` |
|
||||
| `unit` | text | CFS, GPM, AF/Day, etc. |
|
||||
| `method` | text | `manual` / `meter` / `estimate` / `qr` |
|
||||
| `total_gallons` | numeric | auto-computed when CFS × duration is known |
|
||||
| `notes` | text | |
|
||||
| `submitted_via` | text | `field` / `admin` / `qr` |
|
||||
| `photo_url` | text | optional |
|
||||
| `latitude` / `longitude` | double precision | optional GPS |
|
||||
| `logged_date` | date | date-only mirror for fast grouping |
|
||||
| `logged_at` | timestamptz | the actual time |
|
||||
| `logged_by` | uuid FK → admin_users | set when a site admin enters data |
|
||||
|
||||
### Sessions
|
||||
- `water_sessions` — short-lived (8 h) PIN sessions for irrigators.
|
||||
- `water_admin_sessions` — admin sign-in sessions, TTL from
|
||||
`water_admin_settings.session_duration_hours`.
|
||||
|
||||
### Audit + alerts
|
||||
- `water_audit_log` — who changed what, when. Captures every
|
||||
headgate/user/setting mutation with actor + JSON details.
|
||||
- `water_alert_log` — high/low threshold breach history.
|
||||
- `water_admin_settings` — per-brand admin PIN, permission flags,
|
||||
alert config, session duration.
|
||||
|
||||
---
|
||||
|
||||
## Security model
|
||||
|
||||
### PIN hashing
|
||||
PINs are hashed with **scrypt** (Node built-in `crypto.scryptSync`)
|
||||
at N=16384, r=8, p=1, 32-byte key, with a per-PIN random salt. The
|
||||
hash is self-describing: `scrypt$N$r$p$salt_b64$hash_b64`. We avoid
|
||||
adding a `bcrypt` or `argon2` dependency — `scrypt` is in the Node
|
||||
core and matches the password module already used elsewhere.
|
||||
|
||||
### Brute-force hardening
|
||||
- 4–8 digit PINs are low entropy, so we:
|
||||
- Reject "weak" PINs at generation (`generatePin` skips 1111, 1234,
|
||||
palindromes, monotonic sequences).
|
||||
- Add a 200 ms delay on every failed `verifyPin` call to slow
|
||||
online guessing.
|
||||
- Use `timingSafeEqual` for the hash comparison.
|
||||
- Sessions are short-lived (8 h for irrigators, configurable 1–168 h
|
||||
for admins).
|
||||
- The admin PIN can be regenerated from `/admin/water-log/settings` —
|
||||
regenerating invalidates all existing admin sessions.
|
||||
|
||||
### Auth gates
|
||||
Every server action enforces one of:
|
||||
- `requireFieldSession()` — reads `wl_session` cookie, looks up
|
||||
`water_sessions` row, verifies `expires_at`. Returns userId +
|
||||
brandId.
|
||||
- `requireWaterAdminPermission()` — calls `getAdminUser()` and checks
|
||||
`can_manage_water_log` OR `role === "platform_admin"`.
|
||||
- `requireWaterAdminSession()` — reads `wl_admin_session` cookie and
|
||||
verifies the row.
|
||||
|
||||
There is **no implicit "logged in"** state. Each action that mutates
|
||||
data explicitly checks its gate.
|
||||
|
||||
### Data isolation
|
||||
- All tables have RLS policies (`withBrand()` helper sets a
|
||||
per-request GUC; the policy reads it).
|
||||
- `globalThis.__TUXEDO_BRAND_ID__` is a one-time Tuxedo-brand hint for
|
||||
cold-start paths; the active `getAdminUser().brand_id` always wins
|
||||
on later requests.
|
||||
- No Supabase REST, no service-role keys — all access is direct
|
||||
through Drizzle over a single `pg` Pool.
|
||||
|
||||
---
|
||||
|
||||
## Day-to-day usage
|
||||
|
||||
### Add a headgate
|
||||
1. `/admin/water-log` → scroll to **Headgates** → **+ Add Headgate**.
|
||||
2. Enter a unique name (e.g. "North Field Gate 1"), default unit,
|
||||
optional thresholds.
|
||||
3. The new headgate gets a printable QR code. Print it and stick it
|
||||
on the gate.
|
||||
|
||||
### Add a water user
|
||||
1. **Water Users** → **+ Add User**.
|
||||
2. Enter name, choose role (Admin or Irrigator), pick language.
|
||||
3. A 4-digit PIN is generated. **Write it down now** — it is shown
|
||||
once and never recoverable.
|
||||
4. Hand the PIN to the worker. They sign in at `/water` with just
|
||||
that.
|
||||
|
||||
### Submit a flow entry (irrigator)
|
||||
1. Open `/water` on a phone → enter 4-digit PIN.
|
||||
2. Pick the headgate (or scan the QR code — it deep-links to that
|
||||
gate).
|
||||
3. Enter the measurement, duration, method, optional notes/photo.
|
||||
4. Tap **Submit**. Total gallons is auto-computed when CFS × duration
|
||||
is known.
|
||||
5. If the reading crosses a high/low threshold, an alert row is
|
||||
written to `water_alert_log` and (if enabled) an SMS is dispatched
|
||||
to the configured phone.
|
||||
|
||||
### Edit or delete an entry (admin)
|
||||
- From the **Recent Entries** table, click any row.
|
||||
- Edit form is gated by `canEditEntries` / `canDeleteEntries` in
|
||||
`water_admin_settings`. Defaults: edit ✅, delete ✅.
|
||||
|
||||
### Reset a PIN
|
||||
- **Water Users** row → **Reset PIN** → new PIN is generated and
|
||||
shown once.
|
||||
|
||||
### Export CSV
|
||||
- **Recent Entries** → **Export CSV** button. Or hit
|
||||
`GET /api/water-logs/export?format=csv` (auth-gated, requires
|
||||
`can_manage_water_log`).
|
||||
|
||||
---
|
||||
|
||||
## API surface
|
||||
|
||||
| Route | Method | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/api/water-admin-auth` | POST | none (PIN-protected) | Exchange admin PIN for `wl_admin_session` cookie |
|
||||
| `/api/water-logs/export` | GET | admin | Stream entries as JSON or CSV |
|
||||
|
||||
`POST /api/water-admin-auth` body:
|
||||
```json
|
||||
{ "brandId": "64294306-5f42-463d-a5e8-2ad6c81a96de", "pin": "1234" }
|
||||
```
|
||||
|
||||
On success, sets the `wl_admin_session` cookie. On failure, returns
|
||||
a generic error (we don't leak whether the brand has a PIN configured
|
||||
vs. whether the PIN is wrong).
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit (Vitest)
|
||||
```bash
|
||||
npm test -- tests/unit/water-log
|
||||
```
|
||||
Covers:
|
||||
- PIN format validation + weak-PIN detection
|
||||
- scrypt round-trip + tampering
|
||||
- Reporting utilities (CSV escaping, date filters, season detection)
|
||||
- Display age helpers
|
||||
|
||||
### E2E (Playwright)
|
||||
```bash
|
||||
npx playwright test water-log
|
||||
```
|
||||
Covers:
|
||||
- `/water` and `/water/admin/login` render with PIN form
|
||||
- PIN input strips non-digits and caps at 4 chars
|
||||
- Wrong PIN does not navigate
|
||||
- `/admin/water-log/*` redirects unauthenticated users
|
||||
- `/api/water-logs/export` and `/api/water-admin-auth` are auth-gated
|
||||
|
||||
For the full DB-backed workflow (add headgate → add user → submit →
|
||||
export), set `WATER_LOG_E2E_DB=1` and run the same command against a
|
||||
test database.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
No Water Log–specific env vars are required. The module uses the
|
||||
existing `DATABASE_URL` and (optionally) the MinIO bucket
|
||||
`MINIO_BUCKET_WATER_LOGS` for photo uploads.
|
||||
|
||||
If you want high/low threshold SMS alerts, configure
|
||||
`/admin/water-log/settings` with a phone number — SMS dispatch uses
|
||||
the brand's existing Twilio config (same as Harvest Reach).
|
||||
|
||||
---
|
||||
|
||||
## Migration history
|
||||
|
||||
- `0001_init.sql` — initial `water_*` tables + RLS policies.
|
||||
- `0090_water_log_completion.sql` — adds `headgate_token`, threshold
|
||||
fields, `role` on irrigators, `method` + `logged_date` on entries,
|
||||
plus the `water_admin_*`, `water_audit_log`, and `water_alert_log`
|
||||
tables.
|
||||
|
||||
---
|
||||
|
||||
## Admin function checklist
|
||||
|
||||
Manual regression pass after changes:
|
||||
|
||||
### Field side
|
||||
- [ ] `/water` PIN screen loads, accepts 4 digits, rejects letters
|
||||
- [ ] Wrong PIN shows an error, does not redirect
|
||||
- [ ] Correct PIN shows the entry form with the user's headgates
|
||||
- [ ] Submitting an entry creates a row, shows confirmation, returns
|
||||
to the form
|
||||
- [ ] QR-code link (`/water?h=TOKEN`) pre-selects the headgate
|
||||
- [ ] High/low threshold entries write a `water_alert_log` row
|
||||
|
||||
### Admin side
|
||||
- [ ] `/admin/water-log` shows headgates, users, recent entries
|
||||
- [ ] Add headgate → appears in list + QR can be generated
|
||||
- [ ] Edit headgate thresholds → reflected in the field form
|
||||
- [ ] Add user → PIN shown once, user appears in list
|
||||
- [ ] Reset PIN → new PIN shown, old sessions invalidated
|
||||
- [ ] Edit entry → measurement, notes, method all updatable
|
||||
- [ ] Delete entry → row removed (or soft-flagged per audit policy)
|
||||
- [ ] Filter by date range / headgate / user / method works
|
||||
- [ ] CSV export downloads valid CSV
|
||||
- [ ] Audit log shows the actor for every change
|
||||
|
||||
### Settings + portal
|
||||
- [ ] `/admin/water-log/settings` shows current config
|
||||
- [ ] Toggling "Enable Admin Portal" blocks `/water/admin/login`
|
||||
- [ ] Regenerating admin PIN signs out all current admin sessions
|
||||
- [ ] Session duration slider clamps to 1–168 hours
|
||||
|
||||
### Edge cases
|
||||
- [ ] Zero data: empty states render, no crashes
|
||||
- [ ] Invalid PIN: form rejects, error is friendly
|
||||
- [ ] Duplicate headgate name: server rejects, UI shows error
|
||||
- [ ] Very large measurement (1e9): renders, doesn't blow up float
|
||||
- [ ] 1,000+ entries: list paginates, filters stay responsive
|
||||
- [ ] Concurrent submissions: both rows present, no lost writes
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"url": [
|
||||
"http://localhost:3000/admin/v2/orders",
|
||||
"http://localhost:3000/admin/v2/stops",
|
||||
"http://localhost:3000/admin/v2/products"
|
||||
],
|
||||
"numberOfRuns": 1,
|
||||
"settings": {
|
||||
"preset": "mobile",
|
||||
"emulatedFormFactor": "mobile"
|
||||
}
|
||||
},
|
||||
"assert": {
|
||||
"assertions": {
|
||||
"categories:performance": ["error", { "minScore": 0.9 }],
|
||||
"categories:accessibility": ["error", { "minScore": 0.95 }],
|
||||
"categories:pwa": "error",
|
||||
"installable-manifest": "error",
|
||||
"service-worker": "error",
|
||||
"themed-omnibox": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,9 @@ const nextConfig: NextConfig = {
|
||||
// /home/tyler/package-lock.json as the root directory."
|
||||
// The deploy runner's APP_DIR is /home/tyler/route-commerce, so
|
||||
// resolving relative to the project root is correct both locally and
|
||||
// in CI.
|
||||
outputFileTracingRoot: ".",
|
||||
// in CI. We resolve to an absolute path to avoid the warning in
|
||||
// Next.js 16 which prefers absolute paths here.
|
||||
outputFileTracingRoot: __dirname,
|
||||
|
||||
// Enable strict mode
|
||||
reactStrictMode: true,
|
||||
@@ -85,18 +86,44 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/_next/static/:path*",
|
||||
headers: [
|
||||
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/icons/:path*",
|
||||
headers: [
|
||||
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/screenshots/:path*",
|
||||
headers: [
|
||||
{ key: "Cache-Control", value: "public, max-age=604800" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Redirects
|
||||
async redirects() {
|
||||
return [
|
||||
// Redirect old paths if needed
|
||||
// {
|
||||
// source: '/old-path',
|
||||
// destination: '/new-path',
|
||||
// permanent: true,
|
||||
// },
|
||||
// Cutover (PR 6, Task 6.1): mobile-first admin lives at /admin/v2/*.
|
||||
// Redirect the v1 list/detail paths to their v2 equivalents.
|
||||
// Use `permanent: false` (307) so we can revert easily if a
|
||||
// regression is caught in monitoring before the v1 files are
|
||||
// removed in Task 6.2.
|
||||
// PR 8, Task 8.1: redirect the bare /admin path to the v2 dashboard.
|
||||
// The dashboard itself still lives at /admin (as the v1 page)
|
||||
// until Task 8.2 removes it after a 3-day monitoring window —
|
||||
// this redirect just points the v1 entry point at v2.
|
||||
{ source: "/admin", destination: "/admin/v2", permanent: false },
|
||||
{ source: "/admin/orders", destination: "/admin/v2/orders", permanent: false },
|
||||
{ source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false },
|
||||
{ source: "/admin/stops", destination: "/admin/v2/stops", permanent: false },
|
||||
{ source: "/admin/products", destination: "/admin/v2/products", permanent: false },
|
||||
];
|
||||
},
|
||||
|
||||
@@ -111,6 +138,11 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
// Enable optimizePackageImports for better bundle size
|
||||
optimizePackageImports: ["lucide-react", "@radix-ui/react-icons", "framer-motion"],
|
||||
// Enable React's <ViewTransition> and Next.js' automatic route
|
||||
// transitions. Combined with the smooth-transition wrappers around
|
||||
// page content (see src/components/transitions), navigation feels
|
||||
// like a single continuous app rather than a sequence of page loads.
|
||||
viewTransition: true,
|
||||
},
|
||||
|
||||
// Compiler options
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"migrate:one": "node scripts/migrate.js",
|
||||
"db:migrate": "node scripts/migrate.js",
|
||||
"db:seed": "tsx db/seed.ts",
|
||||
"db:seed:tour": "node scripts/seed-tuxedo-2026.js",
|
||||
"db:reset": "node scripts/db-reset.js",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"type-check": "npx tsc --noEmit",
|
||||
@@ -29,6 +30,7 @@
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@neondatabase/auth": "^0.4.2-beta",
|
||||
"@neondatabase/serverless": "^1.1.0",
|
||||
"@sentry/nextjs": "^10.55.0",
|
||||
"@stripe/react-stripe-js": "^6.6.0",
|
||||
"@stripe/stripe-js": "^9.7.0",
|
||||
@@ -40,6 +42,7 @@
|
||||
"exceljs": "^4.4.0",
|
||||
"framer-motion": "^12.40.0",
|
||||
"gsap": "^3.15.0",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "^16.2.6",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
@@ -59,6 +62,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
@@ -73,6 +77,7 @@
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.5",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^25.0.1",
|
||||
"pg": "^8.20.0",
|
||||
"playwright": "^1.59.1",
|
||||
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 590 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -2,100 +2,32 @@
|
||||
"name": "Route Commerce",
|
||||
"short_name": "Route Commerce",
|
||||
"description": "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution",
|
||||
"start_url": "/",
|
||||
"start_url": "/admin",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#faf8f5",
|
||||
"theme_color": "#1a4d2e",
|
||||
"theme_color": "#166534",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
{ "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"categories": ["business", "productivity"],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "View Orders",
|
||||
"short_name": "Orders",
|
||||
"description": "View and manage orders",
|
||||
"url": "/admin/orders",
|
||||
"icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "Add Product",
|
||||
"short_name": "Add Product",
|
||||
"description": "Add a new product",
|
||||
"url": "/admin/products/new",
|
||||
"icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "Create Stop",
|
||||
"short_name": "Create Stop",
|
||||
"description": "Schedule a new pickup stop",
|
||||
"url": "/admin/stops/new",
|
||||
"icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }]
|
||||
}
|
||||
{ "name": "View Orders", "short_name": "Orders", "url": "/admin/v2/orders", "icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }] },
|
||||
{ "name": "Add Product", "short_name": "Add Product", "url": "/admin/v2/products/new", "icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }] },
|
||||
{ "name": "Create Stop", "short_name": "Create Stop", "url": "/admin/v2/stops/new", "icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }] }
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshots/dashboard.png",
|
||||
"sizes": "1280x720",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide",
|
||||
"label": "Admin Dashboard"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/mobile-storefront.png",
|
||||
"sizes": "390x844",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow",
|
||||
"label": "Mobile Storefront"
|
||||
}
|
||||
{ "src": "/screenshots/dashboard.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide", "label": "Admin Dashboard" },
|
||||
{ "src": "/screenshots/mobile-storefront.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow", "label": "Mobile Storefront" }
|
||||
],
|
||||
"related_applications": [],
|
||||
"prefer_related_applications": false
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#166534">
|
||||
<title>Offline — Route Commerce</title>
|
||||
<style>
|
||||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #faf8f5;
|
||||
--color-text: #1d1d1f;
|
||||
--color-text-muted: #424245;
|
||||
--color-accent: #166534;
|
||||
--color-warning: #854d0e;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Manrope, system-ui, sans-serif;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.card {
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
background: var(--color-bg);
|
||||
border-radius: 24px;
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
}
|
||||
.icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 24px;
|
||||
background: var(--color-warning);
|
||||
color: white;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
}
|
||||
h1 {
|
||||
font-family: Fraunces, Georgia, serif;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
p {
|
||||
font-size: 18px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
button {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 16px 32px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
min-height: 56px;
|
||||
}
|
||||
button:active { opacity: 0.85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" role="status">
|
||||
<div class="icon" aria-hidden="true">📡</div>
|
||||
<h1>You're offline</h1>
|
||||
<p>This page isn't available without a connection. Cached pages will still work. Your changes will sync when you're back online.</p>
|
||||
<button type="button" onclick="location.reload()">Try again</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -1,130 +1,108 @@
|
||||
// Service Worker for PWA - Caching and offline support
|
||||
// public/sw.js
|
||||
// Route Commerce Service Worker
|
||||
// Two-cache strategy: shell (cache-first) + data (stale-while-revalidate)
|
||||
|
||||
const CACHE_NAME = "route-commerce-v1";
|
||||
const OFFLINE_URL = "/offline";
|
||||
const SHELL_CACHE = "rc-shell-v3";
|
||||
const DATA_CACHE = "rc-data-v3";
|
||||
const OFFLINE_URL = "/offline.html";
|
||||
|
||||
const STATIC_ASSETS = [
|
||||
const SHELL_ASSETS = [
|
||||
"/",
|
||||
"/manifest.json",
|
||||
"/favicon.svg",
|
||||
"/og-default.jpg",
|
||||
"/og-default.svg",
|
||||
"/offline.html",
|
||||
"/icons/icon-192x192.png",
|
||||
"/icons/icon-512x512.png",
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - clean old caches
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
})
|
||||
Promise.all([
|
||||
caches.keys().then((cacheNames) =>
|
||||
Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== SHELL_CACHE && name !== DATA_CACHE)
|
||||
.map((name) => caches.delete(name))
|
||||
)
|
||||
),
|
||||
self.clients.claim(),
|
||||
])
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch event - network first, fallback to cache
|
||||
self.addEventListener("fetch", (event) => {
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== "GET") return;
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Skip API requests
|
||||
if (event.request.url.includes("/api/")) return;
|
||||
// Navigations → network-first, fall back to cache, fall back to offline page
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(req)
|
||||
.then((res) => {
|
||||
const clone = res.clone();
|
||||
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
|
||||
return res;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.match(req).then((cached) => cached || caches.match(OFFLINE_URL))
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip cross-origin requests
|
||||
if (!event.request.url.startsWith(self.location.origin)) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// Clone response for caching
|
||||
const responseClone = response.clone();
|
||||
|
||||
// Cache successful responses
|
||||
if (response.status === 200) {
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseClone);
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Return cached response or offline page
|
||||
return caches.match(event.request).then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
// Static assets → cache-first
|
||||
if (
|
||||
url.pathname.startsWith("/_next/static/") ||
|
||||
url.pathname.startsWith("/icons/") ||
|
||||
url.pathname.startsWith("/screenshots/") ||
|
||||
url.pathname.endsWith(".svg") ||
|
||||
url.pathname.endsWith(".woff2")
|
||||
) {
|
||||
event.respondWith(
|
||||
caches.match(req).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(req).then((res) => {
|
||||
const clone = res.clone();
|
||||
if (res.status === 200) {
|
||||
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
|
||||
}
|
||||
// Return offline page for navigation requests
|
||||
if (event.request.mode === "navigate") {
|
||||
return caches.match(OFFLINE_URL);
|
||||
}
|
||||
return new Response("Network error", { status: 408 });
|
||||
return res;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Push notification handling
|
||||
self.addEventListener("push", (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
const data = event.data.json();
|
||||
|
||||
const options = {
|
||||
body: data.body,
|
||||
icon: "/icons/icon-192x192.png",
|
||||
badge: "/icons/badge-72x72.png",
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
url: data.url || "/",
|
||||
},
|
||||
actions: data.actions || [],
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(data.title, options));
|
||||
});
|
||||
|
||||
// Notification click handling
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const url = event.notification.data?.url || "/";
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
||||
// Focus existing window or open new one
|
||||
for (const client of clientList) {
|
||||
if (client.url === url && "focus" in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Background sync for offline actions
|
||||
self.addEventListener("sync", (event) => {
|
||||
if (event.tag === "sync-orders") {
|
||||
event.waitUntil(syncOrders());
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
async function syncOrders() {
|
||||
// Implement order sync logic
|
||||
console.log("Syncing orders in background...");
|
||||
}
|
||||
// API GETs (read-only data) → stale-while-revalidate
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
event.respondWith(
|
||||
caches.open(DATA_CACHE).then((cache) =>
|
||||
cache.match(req).then((cached) => {
|
||||
const network = fetch(req)
|
||||
.then((res) => {
|
||||
if (res.status === 200) cache.put(req, res.clone());
|
||||
return res;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached || network;
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: try network, fall back to cache
|
||||
event.respondWith(
|
||||
fetch(req).catch(() => caches.match(req))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Cleanup script: remove duplicate stops from the Tuxedo Corn 2026 tour.
|
||||
* Keeps the row with the latest created_at per (brand_id, date, city, state, location, time).
|
||||
* Run: DATABASE_URL="postgresql://..." npx tsx scripts/cleanup-duplicate-stops.ts
|
||||
*/
|
||||
import { Pool } from "pg";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
process.env.DATABASE_ADMIN_URL ??
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// Find duplicate groups (same brand_id, date, city, state, location, time)
|
||||
const dupes = await client.query(`
|
||||
SELECT brand_id::text, date, city, state, location, time, count(*) as cnt, min(created_at::text) as oldest, max(created_at::text) as newest
|
||||
FROM stops
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY brand_id, date, city, state, location, time
|
||||
HAVING count(*) > 1
|
||||
ORDER BY date, city
|
||||
`);
|
||||
|
||||
console.log(`Found ${dupes.rowCount} duplicate groups`);
|
||||
if (dupes.rowCount === 0) {
|
||||
await client.query("COMMIT");
|
||||
console.log("No duplicates found.");
|
||||
return;
|
||||
}
|
||||
|
||||
let totalDeleted = 0;
|
||||
for (const row of dupes.rows) {
|
||||
// Keep the row with the latest created_at, delete the rest
|
||||
const del = await client.query(`
|
||||
DELETE FROM stops
|
||||
WHERE brand_id = $1
|
||||
AND date = $2
|
||||
AND city = $3
|
||||
AND state = $4
|
||||
AND location = $5
|
||||
AND time IS NOT DISTINCT FROM $6
|
||||
AND deleted_at IS NULL
|
||||
AND created_at < (
|
||||
SELECT max(created_at) FROM stops s2
|
||||
WHERE s2.brand_id = stops.brand_id
|
||||
AND s2.date = stops.date
|
||||
AND s2.city = stops.city
|
||||
AND s2.state = stops.state
|
||||
AND s2.location = stops.location
|
||||
AND (s2.time IS NOT DISTINCT FROM stops.time)
|
||||
AND s2.deleted_at IS NULL
|
||||
)
|
||||
`, [row.brand_id, row.date, row.city, row.state, row.location, row.time]);
|
||||
totalDeleted += del.rowCount ?? 0;
|
||||
console.log(` Deleted ${del.rowCount} duplicate(s) for ${row.city}, ${row.state} on ${row.date}`);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log(`\nDone. Total duplicates removed: ${totalDeleted}`);
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// scripts/generate-pwa-icons.js
|
||||
// Generates the full PWA icon set from public/favicon.svg
|
||||
// Run: node scripts/generate-pwa-icons.js
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const sharp = require("sharp");
|
||||
|
||||
const SIZES = [72, 96, 128, 144, 152, 192, 384, 512];
|
||||
const OUT_DIR = path.join(__dirname, "..", "public", "icons");
|
||||
|
||||
const SVG_PATH = path.join(__dirname, "..", "public", "favicon.svg");
|
||||
const svg = fs.readFileSync(SVG_PATH);
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
for (const size of SIZES) {
|
||||
await sharp(svg).resize(size, size).png().toFile(path.join(OUT_DIR, `icon-${size}x${size}.png`));
|
||||
console.log(`✓ icon-${size}x${size}.png`);
|
||||
}
|
||||
// Maskable: same icon, with 20% safe-area padding baked in
|
||||
await sharp(svg).resize(410, 410).extend({
|
||||
top: 51, bottom: 51, left: 51, right: 51,
|
||||
background: { r: 22, g: 77, b: 46, alpha: 1 },
|
||||
}).png().toFile(path.join(OUT_DIR, "icon-maskable-512x512.png"));
|
||||
console.log("✓ icon-maskable-512x512.png");
|
||||
// Badge
|
||||
await sharp(svg).resize(72, 72).png().toFile(path.join(OUT_DIR, "badge-72x72.png"));
|
||||
console.log("✓ badge-72x72.png");
|
||||
// Shortcuts (same icon, 96x96)
|
||||
for (const name of ["orders-shortcut", "products-shortcut", "stops-shortcut"]) {
|
||||
await sharp(svg).resize(96, 96).png().toFile(path.join(OUT_DIR, `${name}.png`));
|
||||
console.log(`✓ ${name}.png`);
|
||||
}
|
||||
// Apple touch icons
|
||||
for (const [name, size] of [["apple-touch-icon", 180], ["apple-touch-icon-167", 167], ["apple-touch-icon-152", 152]]) {
|
||||
await sharp(svg).resize(size, size).png().toFile(path.join(__dirname, "..", "public", `${name}.png`));
|
||||
console.log(`✓ ${name}.png`);
|
||||
}
|
||||
// favicon.ico (32x32)
|
||||
await sharp(svg).resize(32, 32).png().toFile(path.join(__dirname, "..", "public", "favicon.ico"));
|
||||
console.log("✓ favicon.ico");
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,39 @@
|
||||
// scripts/generate-pwa-screenshots.js
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const sharp = require("sharp");
|
||||
|
||||
const OUT_DIR = path.join(__dirname, "..", "public", "screenshots");
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
async function main() {
|
||||
// Desktop dashboard placeholder
|
||||
await sharp({
|
||||
create: { width: 1280, height: 720, channels: 3, background: { r: 250, g: 248, b: 245 } }
|
||||
})
|
||||
.composite([{
|
||||
input: Buffer.from(`<svg width="1280" height="720" xmlns="http://www.w3.org/2000/svg">
|
||||
<text x="640" y="360" font-family="serif" font-size="48" fill="#1a4d2e" text-anchor="middle">Route Commerce</text>
|
||||
</svg>`),
|
||||
top: 0, left: 0,
|
||||
}])
|
||||
.png()
|
||||
.toFile(path.join(OUT_DIR, "dashboard.png"));
|
||||
console.log("✓ dashboard.png");
|
||||
|
||||
// Mobile storefront placeholder
|
||||
await sharp({
|
||||
create: { width: 390, height: 844, channels: 3, background: { r: 250, g: 248, b: 245 } }
|
||||
})
|
||||
.composite([{
|
||||
input: Buffer.from(`<svg width="390" height="844" xmlns="http://www.w3.org/2000/svg">
|
||||
<text x="195" y="422" font-family="serif" font-size="32" fill="#1a4d2e" text-anchor="middle">Route Commerce</text>
|
||||
</svg>`),
|
||||
top: 0, left: 0,
|
||||
}])
|
||||
.png()
|
||||
.toFile(path.join(OUT_DIR, "mobile-storefront.png"));
|
||||
console.log("✓ mobile-storefront.png");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,448 @@
|
||||
// dotenv load temporarily disabled so shell-provided DATABASE_URL wins for this run
|
||||
// import { config } from "dotenv";
|
||||
// config({ path: ".env.local" });
|
||||
import { Pool } from "pg";
|
||||
import ExcelJS from "exceljs";
|
||||
import * as fs from "fs";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const YEAR = 2026;
|
||||
|
||||
const MONTH_MAP: Record<string, string> = {
|
||||
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
||||
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
|
||||
};
|
||||
|
||||
interface ParsedStop {
|
||||
week: string;
|
||||
region: string;
|
||||
date: string;
|
||||
day: string;
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
time: string;
|
||||
timeRange: string;
|
||||
truck: string;
|
||||
statusText: string;
|
||||
notes: string;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
contact: string | null;
|
||||
}
|
||||
|
||||
function parseExcelDate(s: unknown): string | null {
|
||||
if (!s) return null;
|
||||
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
|
||||
if (!m) return null;
|
||||
const mm = MONTH_MAP[m[1]];
|
||||
if (!mm) return null;
|
||||
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseTimeRange(s: unknown): string {
|
||||
if (!s) return "";
|
||||
let c = String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
|
||||
const m = /^(\d{1,2}:\d{2}\s*[AP]M)/i.exec(c);
|
||||
// Prefer full range for display (e.g. "10:00 AM – 1:00 PM"); fall back to start only
|
||||
return c || (m ? m[1].toUpperCase().replace(" ", " ") : "");
|
||||
}
|
||||
|
||||
function splitCityState(s: unknown): [string, string] {
|
||||
if (!s) return ["", ""];
|
||||
const parts = String(s).split(",").map((p) => p.trim());
|
||||
if (parts.length === 1) return [parts[0], ""];
|
||||
return [parts[0], parts[1]];
|
||||
}
|
||||
|
||||
function isWeekHeader(cells: string[]): boolean {
|
||||
return /^Wk\s/i.test(cells[0] || "") && (cells[3] || "").trim() === "";
|
||||
}
|
||||
|
||||
function isOffRow(cells: string[]): boolean {
|
||||
const d = (cells[3] || "").trim();
|
||||
return /OFF|Cross-?Dock/i.test(d);
|
||||
}
|
||||
|
||||
function isDataRow(cells: string[]): boolean {
|
||||
const day = (cells[3] || "").trim();
|
||||
const city = (cells[4] || "").trim();
|
||||
return !!(day && city && city.includes(","));
|
||||
}
|
||||
|
||||
function slugify(s: string): string {
|
||||
let out = (s || "").toLowerCase();
|
||||
out = out.replace(/[^a-z0-9]+/g, "-");
|
||||
return out.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
async function loadStops(xlsxPath: string): Promise<{ stops: ParsedStop[]; skipped: Record<string, number>; dirCount: number; locations: ParsedLocation[] }> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.readFile(xlsxPath);
|
||||
|
||||
const schedule = wb.getWorksheet("Full Schedule");
|
||||
const directory = wb.getWorksheet("Stop Directory");
|
||||
if (!schedule) throw new Error("Missing 'Full Schedule' sheet");
|
||||
|
||||
// Build directory lookup for address enrichment: "T1|host lower" -> info
|
||||
const dirMap = new Map<string, { address: string | null; phone: string | null; contact: string | null; state: string }>();
|
||||
if (directory) {
|
||||
for (let r = 2; r <= directory.rowCount; r++) {
|
||||
const row = directory.getRow(r);
|
||||
const truck = String(row.getCell(1).value || "").trim();
|
||||
const host = String(row.getCell(4).value || "").trim().toLowerCase();
|
||||
if (!truck || !host) continue;
|
||||
dirMap.set(`${truck}|${host}`, {
|
||||
address: String(row.getCell(5).value || "").trim() || null,
|
||||
phone: String(row.getCell(6).value || "").trim() || null,
|
||||
contact: String(row.getCell(7).value || "").trim() || null,
|
||||
state: String(row.getCell(3).value || "").trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stops: ParsedStop[] = [];
|
||||
const skipped: Record<string, number> = { weekHeader: 0, off: 0, invalid: 0, noDate: 0 };
|
||||
|
||||
for (let r = 4; r <= schedule.rowCount; r++) {
|
||||
const row = schedule.getRow(r);
|
||||
const cells = Array.from({ length: 10 }, (_, i) => String(row.getCell(i + 1).value ?? "").trim());
|
||||
|
||||
if (isWeekHeader(cells)) {
|
||||
skipped.weekHeader++;
|
||||
continue;
|
||||
}
|
||||
if (isOffRow(cells)) {
|
||||
skipped.off++;
|
||||
continue;
|
||||
}
|
||||
if (!isDataRow(cells)) {
|
||||
skipped.invalid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const [wk, region, dateText, day, cityState, host, time, truck, status, notes] = cells;
|
||||
const dateIso = parseExcelDate(dateText);
|
||||
if (!dateIso) {
|
||||
skipped.noDate++;
|
||||
continue;
|
||||
}
|
||||
const [city, state] = splitCityState(cityState);
|
||||
if (!city) {
|
||||
skipped.invalid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${truck}|${host.toLowerCase()}`;
|
||||
const d = dirMap.get(key);
|
||||
|
||||
stops.push({
|
||||
week: wk,
|
||||
region,
|
||||
date: dateIso,
|
||||
day,
|
||||
city,
|
||||
state: state || (d?.state || ""),
|
||||
location: host,
|
||||
time: parseTimeRange(time),
|
||||
timeRange: time,
|
||||
truck,
|
||||
statusText: status,
|
||||
notes: notes || "",
|
||||
address: d?.address || null,
|
||||
phone: d?.phone || null,
|
||||
contact: d?.contact || null,
|
||||
});
|
||||
}
|
||||
|
||||
return { stops, skipped, dirCount: dirMap.size, locations: loadLocations(directory) };
|
||||
}
|
||||
|
||||
export type ParsedLocation = {
|
||||
name: string;
|
||||
address: string | null;
|
||||
city: string;
|
||||
state: string;
|
||||
phone: string | null;
|
||||
contactName: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
function loadLocations(directory: ExcelJS.Worksheet | undefined): ParsedLocation[] {
|
||||
if (!directory) return [];
|
||||
const byKey = new Map<string, ParsedLocation>();
|
||||
for (let r = 2; r <= directory.rowCount; r++) {
|
||||
const row = directory.getRow(r);
|
||||
const truck = String(row.getCell(1).value || "").trim();
|
||||
const city = String(row.getCell(2).value || "").trim();
|
||||
const state = String(row.getCell(3).value || "").trim();
|
||||
const host = String(row.getCell(4).value || "").trim();
|
||||
const address = String(row.getCell(5).value || "").trim() || null;
|
||||
let phone = String(row.getCell(6).value || "").trim() || null;
|
||||
const contact = String(row.getCell(7).value || "").trim() || null;
|
||||
// notes column is 12
|
||||
let notes = String(row.getCell(12).value || "").trim() || null;
|
||||
|
||||
if (!host || !city) continue;
|
||||
|
||||
// If phone field contains TBD email, move it to notes
|
||||
if (phone && /@/.test(phone) && (!notes || notes.length < 10)) {
|
||||
notes = phone;
|
||||
phone = null;
|
||||
}
|
||||
|
||||
const key = `${host.toLowerCase()}|${city.toLowerCase()}|${state.toLowerCase()}`;
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, {
|
||||
name: host,
|
||||
address,
|
||||
city,
|
||||
state,
|
||||
phone,
|
||||
contactName: contact || null,
|
||||
notes,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
|
||||
function toLocationRpcRow(l: ParsedLocation) {
|
||||
return {
|
||||
name: l.name,
|
||||
address: l.address,
|
||||
city: l.city,
|
||||
state: l.state,
|
||||
zip: null,
|
||||
phone: l.phone,
|
||||
contact_name: l.contactName,
|
||||
contact_email: null,
|
||||
notes: l.notes,
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toRpcRow(s: ParsedStop) {
|
||||
// Date format matches the python seed script expectation for the RPC
|
||||
const dateWithTz = `${s.date} 00:00:00+00`;
|
||||
const combinedNotes = [
|
||||
s.notes,
|
||||
s.truck ? `Truck: ${s.truck}` : "",
|
||||
s.statusText ? `Status: ${s.statusText}` : "",
|
||||
s.phone ? `Phone: ${s.phone}` : "",
|
||||
s.contact ? `Contact: ${s.contact}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
|
||||
return {
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
location: s.location,
|
||||
date: dateWithTz,
|
||||
time: s.time || s.timeRange || "",
|
||||
address: s.address,
|
||||
zip: null,
|
||||
cutoff_time: null,
|
||||
// active=true makes them visible on the public /tuxedo/stops storefront immediately
|
||||
active: true,
|
||||
// forward notes when the RPC supports it (enriched with truck/status/contact)
|
||||
notes: combinedNotes || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run") || args.includes("-n");
|
||||
let xlsxPath = "./Tuxedo_Corn_2026_Tour_Schedule-3.xlsx";
|
||||
const xArg = args.findIndex((a) => a === "--xlsx" || a === "-x");
|
||||
if (xArg !== -1 && args[xArg + 1]) xlsxPath = args[xArg + 1];
|
||||
const doClean = !args.includes("--no-clean");
|
||||
const emitSql = args.includes("--emit-sql") || args.includes("--sql");
|
||||
|
||||
console.log(`[import-tuxedo-stops] xlsx=${xlsxPath} dryRun=${dryRun} clean=${doClean} emitSql=${emitSql}`);
|
||||
|
||||
const { stops, skipped, dirCount, locations: parsedLocations } = await loadStops(xlsxPath);
|
||||
|
||||
console.log(`\nParsed ${stops.length} stops + ${parsedLocations.length} unique locations`);
|
||||
console.log(`Skipped: week-headers=${skipped.weekHeader}, off/cross-dock=${skipped.off}, invalid=${skipped.invalid}, no-date=${skipped.noDate}`);
|
||||
console.log(`Stop Directory raw entries: ${dirCount}\n`);
|
||||
|
||||
if (!stops.length) {
|
||||
console.error("No stops to insert. Check the xlsx path and filters.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Show samples
|
||||
console.log("Sample (first 3):");
|
||||
stops.slice(0, 3).forEach((s) => {
|
||||
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
|
||||
if (s.address) console.log(` addr: ${s.address}`);
|
||||
});
|
||||
console.log("\nSample (last 3):");
|
||||
stops.slice(-3).forEach((s) => {
|
||||
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
|
||||
});
|
||||
|
||||
// By week/region/truck
|
||||
const byWeek: Record<string, number> = {};
|
||||
const byRegion: Record<string, number> = {};
|
||||
const byTruck: Record<string, number> = {};
|
||||
for (const s of stops) {
|
||||
byWeek[s.week] = (byWeek[s.week] || 0) + 1;
|
||||
byRegion[s.region] = (byRegion[s.region] || 0) + 1;
|
||||
byTruck[s.truck] = (byTruck[s.truck] || 0) + 1;
|
||||
}
|
||||
console.log("\nBy week:", Object.fromEntries(Object.entries(byWeek).sort(([a], [b]) => Number(a) - Number(b))));
|
||||
console.log("By region:", byRegion);
|
||||
console.log("By truck:", byTruck);
|
||||
|
||||
const dates = [...stops.map((s) => s.date)].sort();
|
||||
console.log(`\nDate range: ${dates[0]} → ${dates[dates.length - 1]}`);
|
||||
|
||||
if (emitSql) {
|
||||
const BATCH = 50;
|
||||
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
|
||||
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`;
|
||||
sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`;
|
||||
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n`;
|
||||
sql += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
|
||||
sql += `BEGIN;\n\n`;
|
||||
|
||||
// Locations first (master directory)
|
||||
if (parsedLocations.length > 0) {
|
||||
sql += `-- ===== LOCATIONS (Stop Directory master records) =====\n`;
|
||||
sql += `DELETE FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}';\n\n`;
|
||||
const locPayload = parsedLocations.map(toLocationRpcRow);
|
||||
const locJson = JSON.stringify(locPayload);
|
||||
sql += `-- ${parsedLocations.length} unique locations from Stop Directory\n`;
|
||||
sql += `SELECT admin_create_locations_batch('${TUXEDO_BRAND_ID}'::uuid, $$${locJson}$$::jsonb);\n\n`;
|
||||
}
|
||||
|
||||
// Stops (dated instances)
|
||||
sql += `-- ===== STOPS (2026 tour schedule) =====\n`;
|
||||
sql += `-- Clear prior 2026 tour stops for brand (date-scoped)\n`;
|
||||
sql += `DELETE FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
|
||||
for (let i = 0; i < stops.length; i += BATCH) {
|
||||
const batchStops = stops.slice(i, i + BATCH);
|
||||
const payload = batchStops.map(toRpcRow);
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const bnum = Math.floor(i / BATCH) + 1;
|
||||
sql += `-- stops batch ${bnum}/${Math.ceil(stops.length / BATCH)} (${payload.length})\n`;
|
||||
sql += `SELECT admin_create_stops_batch('${TUXEDO_BRAND_ID}'::uuid, $$${payloadJson}$$::jsonb);\n\n`;
|
||||
}
|
||||
sql += `-- Make stops visible (RPC inserts as draft + active=true in payload)\n`;
|
||||
sql += `UPDATE stops SET status = 'active' WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
|
||||
sql += `COMMIT;\n`;
|
||||
const outPath = "db/seeds/2026-tuxedo-tour-stops.sql";
|
||||
fs.writeFileSync(outPath, sql, "utf8");
|
||||
console.log(`\nWrote ${outPath} (locations: ${parsedLocations.length}, stops: ${stops.length}).`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
const stopBatches = Math.ceil(stops.length / 50);
|
||||
console.log(`\n[DRY RUN] Would load for brand:\n - ${parsedLocations.length} locations (full replace)\n - DELETE date-scoped 2026 stops then INSERT ${stops.length} stops in ${stopBatches} batch(es)\n via admin_*_batch RPCs.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
process.env.DATABASE_ADMIN_URL ??
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||
});
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
if (doClean) {
|
||||
const del = await client.query(
|
||||
`DELETE FROM stops WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
|
||||
[TUXEDO_BRAND_ID],
|
||||
);
|
||||
console.log(`\nCleared ${del.rowCount} prior 2026-dated stops for Tuxedo brand.`);
|
||||
}
|
||||
|
||||
// Locations (from Stop Directory) — full replace for the brand from this xlsx
|
||||
if (parsedLocations.length > 0) {
|
||||
const locDel = await client.query(
|
||||
`DELETE FROM locations WHERE brand_id = $1`,
|
||||
[TUXEDO_BRAND_ID],
|
||||
);
|
||||
console.log(`Cleared ${locDel.rowCount} previous locations for Tuxedo brand.`);
|
||||
|
||||
const LOC_BATCH = 100; // locations are fewer and smaller
|
||||
let locTotal = 0;
|
||||
const locBatches = Math.ceil(parsedLocations.length / LOC_BATCH);
|
||||
for (let i = 0; i < parsedLocations.length; i += LOC_BATCH) {
|
||||
const batchLocs = parsedLocations.slice(i, i + LOC_BATCH);
|
||||
const payload = batchLocs.map(toLocationRpcRow);
|
||||
const bnum = Math.floor(i / LOC_BATCH) + 1;
|
||||
process.stdout.write(` Locations batch ${bnum}/${locBatches} (${payload.length}) via admin_create_locations_batch... `);
|
||||
try {
|
||||
await client.query(
|
||||
"SELECT admin_create_locations_batch($1::uuid, $2::jsonb)",
|
||||
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
|
||||
);
|
||||
locTotal += payload.length;
|
||||
console.log("OK");
|
||||
} catch (e: any) {
|
||||
console.log("FAIL");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
console.log(` Inserted ${locTotal} locations.`);
|
||||
}
|
||||
|
||||
const BATCH = 50;
|
||||
let total = 0;
|
||||
const batches = Math.ceil(stops.length / BATCH);
|
||||
|
||||
for (let i = 0; i < stops.length; i += BATCH) {
|
||||
const batchStops = stops.slice(i, i + BATCH);
|
||||
const payload = batchStops.map(toRpcRow);
|
||||
const bnum = Math.floor(i / BATCH) + 1;
|
||||
|
||||
process.stdout.write(` Batch ${bnum}/${batches} (${payload.length}) via admin_create_stops_batch... `);
|
||||
try {
|
||||
await client.query(
|
||||
"SELECT admin_create_stops_batch($1::uuid, $2::jsonb)",
|
||||
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
|
||||
);
|
||||
total += payload.length;
|
||||
console.log("OK");
|
||||
} catch (e: any) {
|
||||
console.log("FAIL");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// RPC inserts with status='draft' (per prior script); flip to active for public visibility.
|
||||
// Scope by the tour date range so we only touch rows from this import.
|
||||
if (total > 0) {
|
||||
const pub = await client.query(
|
||||
`UPDATE stops SET status = 'active' WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
|
||||
[TUXEDO_BRAND_ID],
|
||||
);
|
||||
console.log(`\nPublished (status=active): ${pub.rowCount} stops.`);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log(`\nDone. Inserted ${parsedLocations.length} locations + ${total} stops for Tuxedo Corn 2026 tour (brand ${TUXEDO_BRAND_ID}).`);
|
||||
console.log("Locations are the master directory; stops are the dated schedule instances.");
|
||||
console.log("They should now appear on /tuxedo/stops and in admin locations UI.");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("\nInsert failed (rolled back):", err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Seed Tuxedo Corn 2026 tour: locations + stops.
|
||||
*
|
||||
* Uses sql`...` tagged template from @neondatabase/serverless (WebSocket mode)
|
||||
* for INSERTs — this is the only Neon serverless API that actually executes writes.
|
||||
*
|
||||
* Run with:
|
||||
* node scripts/seed-tuxedo-2026.js
|
||||
*
|
||||
* Requires DATABASE_URL in .env.local (or pass explicitly).
|
||||
*/
|
||||
require("dotenv").config({ path: ".env.local" });
|
||||
const { neon } = require("@neondatabase/serverless");
|
||||
const ExcelJS = require("exceljs");
|
||||
const path = require("path");
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const YEAR = 2026;
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("❌ DATABASE_URL not set in .env.local");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cleanUrl = url.replace(/[?&]channel_binding=require/gi, "").trim();
|
||||
// Use direct compute endpoint with WebSocket for full DDL + DML support
|
||||
const directUrl = cleanUrl.replace("-pooler.", ".");
|
||||
const sql = neon(directUrl, { webSocket: true });
|
||||
|
||||
const 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",
|
||||
};
|
||||
|
||||
function parseExcelDate(s) {
|
||||
if (!s) return null;
|
||||
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
|
||||
if (!m) return null;
|
||||
const mm = MONTH_MAP[m[1]];
|
||||
if (!mm) return null;
|
||||
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseTimeRange(s) {
|
||||
if (!s) return "";
|
||||
return String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
async function parseXlsx(xlsxPath) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(xlsxPath);
|
||||
|
||||
// --- Locations from Stop Directory ---
|
||||
const locSheet = workbook.getWorksheet("Stop Directory");
|
||||
const locations = [];
|
||||
const seenLoc = new Set();
|
||||
|
||||
if (locSheet) {
|
||||
locSheet.eachRow((row, rowNum) => {
|
||||
if (rowNum === 1) return;
|
||||
// Column mapping via getCell():
|
||||
// col1=Truck, col2=City, col3=State, col4=Host(venue name), col5=Address, col6=Phone, col7=Contact, col12=Notes
|
||||
const rawCity = String(row.getCell(2).value || "").trim();
|
||||
const rawState = String(row.getCell(3).value || "").trim();
|
||||
const rawName = String(row.getCell(4).value || "").trim();
|
||||
const rawAddress = String(row.getCell(5).value || "").trim();
|
||||
if (!rawCity || !rawName) return;
|
||||
const key = `${rawName}|${rawCity}|${rawState}`;
|
||||
if (seenLoc.has(key)) return;
|
||||
seenLoc.add(key);
|
||||
locations.push({
|
||||
name: rawName,
|
||||
address: rawAddress || null,
|
||||
city: rawCity,
|
||||
state: rawState,
|
||||
zip: null,
|
||||
phone: String(row.getCell(6).value || "").trim() || null,
|
||||
contact_name: String(row.getCell(7).value || "").trim() || null,
|
||||
contact_email: null,
|
||||
notes: String(row.getCell(12).value || "").trim() || null,
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
console.log(`Parsed ${locations.length} locations`);
|
||||
|
||||
// --- Stops from Full Schedule ---
|
||||
const schedSheet = workbook.getWorksheet("Full Schedule");
|
||||
const stops = [];
|
||||
const venueAddressMap = new Map();
|
||||
|
||||
if (locSheet) {
|
||||
locSheet.eachRow((row, rowNum) => {
|
||||
if (rowNum === 1) return;
|
||||
const name = String(row.getCell(4).value || "").trim();
|
||||
const address = String(row.getCell(5).value || "").trim();
|
||||
if (name && address) venueAddressMap.set(name, address);
|
||||
});
|
||||
}
|
||||
|
||||
if (schedSheet) {
|
||||
schedSheet.eachRow((row) => {
|
||||
const rowValues = row.values || [];
|
||||
// Column mapping (1-indexed, col1=null):
|
||||
// col2=Wk, col3=Type, col4=Date, col5=Day, col6=City/Location, col7=Host/Venue, col8=Time, col9=Truck, col10=Status, col11=Notes
|
||||
const firstCell = String(rowValues[1] || "").trim();
|
||||
// Skip title rows, week headers ("Wk 1"), OFF, Cross-Dock
|
||||
if (!firstCell || /^Wk\s*\d+/i.test(firstCell) || firstCell === "OFF" || firstCell === "Cross-Dock") return;
|
||||
|
||||
const dateStr = parseExcelDate(rowValues[3]);
|
||||
if (!dateStr) return;
|
||||
|
||||
const cityStateRaw = String(rowValues[5] || "").trim();
|
||||
const cityParts = cityStateRaw.split(",").map((s) => s.trim());
|
||||
const city = cityParts[0] || "";
|
||||
const state = cityParts[1] || "";
|
||||
const location = String(rowValues[6] || "").trim();
|
||||
if (!city || !state || !location) return;
|
||||
|
||||
const rawTime = rowValues[7];
|
||||
const time = rawTime ? parseTimeRange(rawTime) : "";
|
||||
const truck = String(rowValues[8] || "").trim();
|
||||
const statusText = String(rowValues[9] || "").trim();
|
||||
const notesText = String(rowValues[10] || "").trim();
|
||||
|
||||
const venueAddress = venueAddressMap.get(location) || null;
|
||||
const notes = [truck ? `Truck: ${truck}` : "", statusText, notesText].filter(Boolean).join(" | ");
|
||||
|
||||
stops.push({
|
||||
name: `${location} - ${city}, ${state}`,
|
||||
location,
|
||||
address: venueAddress,
|
||||
city,
|
||||
state,
|
||||
zip: null,
|
||||
date: dateStr,
|
||||
time,
|
||||
cutoff_date: null,
|
||||
status: "active",
|
||||
is_public: true,
|
||||
notes: notes || null,
|
||||
});
|
||||
});
|
||||
}
|
||||
console.log(`Parsed ${stops.length} stops`);
|
||||
return { locations, stops };
|
||||
}
|
||||
|
||||
async function seedLocations(locs) {
|
||||
if (locs.length === 0) return;
|
||||
await sql`DELETE FROM locations WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid`;
|
||||
for (const loc of locs) {
|
||||
await sql`INSERT INTO locations (brand_id, name, address, city, state, zip, phone, contact_name, contact_email, notes, active)
|
||||
VALUES (${TUXEDO_BRAND_ID}::uuid, ${loc.name}, ${loc.address}, ${loc.city}, ${loc.state}, ${loc.zip}, ${loc.phone}, ${loc.contact_name}, ${loc.contact_email}, ${loc.notes}, ${loc.active})`;
|
||||
}
|
||||
console.log(`Seeded ${locs.length} locations`);
|
||||
}
|
||||
|
||||
async function seedStops(stops) {
|
||||
if (stops.length === 0) return;
|
||||
const minDate = `${YEAR}-07-01`;
|
||||
const maxDate = `${YEAR}-09-30`;
|
||||
await sql`DELETE FROM stops WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid AND date >= ${minDate} AND date <= ${maxDate}`;
|
||||
for (const stop of stops) {
|
||||
await sql`INSERT INTO stops (brand_id, name, location, address, city, state, zip, date, "time", cutoff_date, status, is_public, notes)
|
||||
VALUES (${TUXEDO_BRAND_ID}::uuid, ${stop.name}, ${stop.location}, ${stop.address}, ${stop.city}, ${stop.state}, ${stop.zip}, ${stop.date}, ${stop.time}, ${stop.cutoff_date}, ${stop.status}, ${stop.is_public}, ${stop.notes})`;
|
||||
}
|
||||
console.log(`Seeded ${stops.length} stops`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const xlsxPath = path.join(__dirname, "..", "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx");
|
||||
console.log("Parsing:", xlsxPath);
|
||||
const { locations, stops } = await parseXlsx(xlsxPath);
|
||||
if (locations.length === 0 && stops.length === 0) {
|
||||
console.error("❌ No data parsed from xlsx");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Seeding ${locations.length} locations + ${stops.length} stops for Tuxedo Corn...`);
|
||||
await seedLocations(locations);
|
||||
await seedStops(stops);
|
||||
const locCount = await sql.query(`SELECT COUNT(*) FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
|
||||
const stopCount = await sql.query(`SELECT COUNT(*) FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
|
||||
console.log(`\n✅ Done — ${locCount[0].count} locations, ${stopCount[0].count} stops in DB`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("❌ Seed failed:", e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -38,10 +38,7 @@ MONTH_MAP = {
|
||||
"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"
|
||||
)
|
||||
DEFAULT_XLSX = "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" # run from repo root; override with --xlsx /path/to/file.xlsx
|
||||
|
||||
|
||||
def parse_excel_date(s):
|
||||
|
||||
@@ -1,42 +1,149 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { randomBytes } from "crypto";
|
||||
import { query } from "@/lib/db";
|
||||
import {
|
||||
requestPasswordReset as neonAuthRequestPasswordReset,
|
||||
setUserPassword as neonAuthSetUserPassword,
|
||||
} from "@/lib/auth";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
export type ResetAdminPasswordResult =
|
||||
| { success: true; tempPassword: string }
|
||||
| { success: true; tempPassword: string; method: "set" }
|
||||
| { success: true; method: "reset_link_sent"; message: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Emergency recovery action — only usable in development or when the caller
|
||||
* already has direct DB access. Resets the password for the specified email
|
||||
* and returns the temp password so it can be displayed to the user.
|
||||
* Emergency recovery action — resets the password for the specified
|
||||
* email so the platform admin can hand a working credential to the
|
||||
* user.
|
||||
*
|
||||
* Now that Supabase auth is gone, this hits the `users` table directly and
|
||||
* calls the same `update_user_password` SECURITY DEFINER RPC used by the
|
||||
* self-service password change action.
|
||||
* Tries, in order:
|
||||
* 1. `auth.admin.setUserPassword({ userId, newPassword })` — instant,
|
||||
* works when the caller has `role='admin'` in `neon_auth.user`.
|
||||
* 2. Falls back to `requestPasswordReset({ email, redirectTo })` —
|
||||
* public Neon Auth endpoint, always works, but the user has to
|
||||
* click the link in the email. The fallback covers the common
|
||||
* case where `provision-admin.ts` did not promote the caller to
|
||||
* `role='admin'` in Neon Auth.
|
||||
*
|
||||
* Authorization: platform_admin only. The dev-session platform_admin
|
||||
* shim is honored (same as the rest of the admin actions).
|
||||
*/
|
||||
export async function resetAdminPassword(
|
||||
email: string,
|
||||
newPassword: string
|
||||
): Promise<ResetAdminPasswordResult> {
|
||||
// Look up the user by email
|
||||
// 1. Authz check.
|
||||
const me = await getAdminUser();
|
||||
if (!me) {
|
||||
return { success: false, error: "Not authenticated." };
|
||||
}
|
||||
if (me.role !== "platform_admin") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Only platform admins can reset passwords.",
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
if (!normalizedEmail) {
|
||||
return { success: false, error: "Email is required." };
|
||||
}
|
||||
|
||||
// 2. Look up the Neon Auth user id by email.
|
||||
const { rows } = await query<{ id: string }>(
|
||||
"SELECT id FROM users WHERE email = $1 LIMIT 1",
|
||||
[email.toLowerCase()],
|
||||
`SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1`,
|
||||
[normalizedEmail],
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user) {
|
||||
return { success: false, error: "No auth user found for that email address." };
|
||||
return {
|
||||
success: false,
|
||||
error: `No Neon Auth user found for ${normalizedEmail}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Update password via SECURITY DEFINER RPC
|
||||
// 3. Generate a strong temp password server-side. 16 bytes → 32
|
||||
// base64url chars; mix in a symbol so it satisfies typical
|
||||
// password policies.
|
||||
const tempPassword = `${randomBytes(18).toString("base64url")}!`;
|
||||
|
||||
// 4. Try the privileged admin endpoint.
|
||||
try {
|
||||
await query("SELECT update_user_password($1, $2)", [user.id, newPassword]);
|
||||
return { success: true, tempPassword: newPassword };
|
||||
const adminResult = await neonAuthSetUserPassword({
|
||||
userId: user.id,
|
||||
newPassword: tempPassword,
|
||||
});
|
||||
|
||||
if (adminResult?.error) {
|
||||
const code = adminResult.error.code ?? "";
|
||||
const canFallback =
|
||||
code === "FORBIDDEN" ||
|
||||
code === "UNAUTHORIZED" ||
|
||||
code === "INTERNAL_SERVER_ERROR";
|
||||
console.warn(
|
||||
"[resetAdminPassword] setUserPassword failed (code=%s), will%s fall back: %s",
|
||||
code,
|
||||
canFallback ? "" : " NOT",
|
||||
adminResult.error.message ?? "",
|
||||
);
|
||||
|
||||
if (!canFallback) {
|
||||
return {
|
||||
success: false,
|
||||
error: adminResult.error.message ?? `setUserPassword failed: ${code}`,
|
||||
};
|
||||
}
|
||||
// fall through to the public reset-link path below
|
||||
} else {
|
||||
// Success — flip must_change_password so the user is forced
|
||||
// to pick a new password on next sign-in.
|
||||
try {
|
||||
await query(
|
||||
`UPDATE admin_users SET must_change_password = true WHERE user_id = $1`,
|
||||
[user.id],
|
||||
);
|
||||
} catch (flagErr) {
|
||||
console.warn(
|
||||
"[resetAdminPassword] failed to set must_change_password (non-fatal):",
|
||||
flagErr,
|
||||
);
|
||||
}
|
||||
return { success: true, tempPassword, method: "set" };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[resetAdminPassword] setUserPassword threw:", err);
|
||||
// network / unexpected — try the fallback below
|
||||
}
|
||||
|
||||
// 5. Fallback: send a password-reset email via the public endpoint.
|
||||
try {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
|
||||
const resetResult = await neonAuthRequestPasswordReset({
|
||||
email: normalizedEmail,
|
||||
redirectTo: `${siteUrl}/reset-password`,
|
||||
});
|
||||
|
||||
if (resetResult?.error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
resetResult.error.message ??
|
||||
resetResult.error.code ??
|
||||
"Failed to send reset email",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
method: "reset_link_sent",
|
||||
message: `Set-password is not available for this account. Sent a password-reset email to ${normalizedEmail} instead — share that link with the user.`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to update password",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { pool, query } from "@/lib/db";
|
||||
import { query, withTx } from "@/lib/db";
|
||||
import {
|
||||
createUser as neonAuthCreateUser,
|
||||
requestPasswordReset as neonAuthRequestPasswordReset,
|
||||
} from "@/lib/auth";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
export type AdminUserRow = {
|
||||
id: string;
|
||||
@@ -110,7 +114,7 @@ async function sendWelcomeEmailSafe(input: {
|
||||
role: "platform_admin" | "brand_admin" | "store_employee";
|
||||
password: string;
|
||||
brandId?: string;
|
||||
}): Promise<void> {
|
||||
}): Promise<{ sent: boolean; error?: string }> {
|
||||
try {
|
||||
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
||||
const { getBrandSettings } = await import("@/actions/brand-settings");
|
||||
@@ -119,14 +123,21 @@ async function sendWelcomeEmailSafe(input: {
|
||||
let logoUrl: string | null = null;
|
||||
let brandName = "Route Commerce";
|
||||
if (input.brandId) {
|
||||
const settings = await getBrandSettings(input.brandId);
|
||||
if (settings.success && settings.settings) {
|
||||
logoUrl = settings.settings.logo_url ?? null;
|
||||
brandName = settings.settings.brand_name ?? brandName;
|
||||
try {
|
||||
const settings = await getBrandSettings(input.brandId);
|
||||
if (settings.success && settings.settings) {
|
||||
logoUrl = settings.settings.logo_url ?? null;
|
||||
brandName = settings.settings.brand_name ?? brandName;
|
||||
}
|
||||
} catch (brandErr) {
|
||||
console.warn(
|
||||
"[createAdminUser] Failed to load brand settings for welcome email:",
|
||||
brandErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await sendWelcomeEmail({
|
||||
const result = await sendWelcomeEmail({
|
||||
to: input.to,
|
||||
name: input.name,
|
||||
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
||||
@@ -134,8 +145,11 @@ async function sendWelcomeEmailSafe(input: {
|
||||
tempPassword: input.password,
|
||||
logoUrl: logoUrl ?? undefined,
|
||||
});
|
||||
} catch {
|
||||
// welcome email is best-effort; never block user creation
|
||||
return result.ok ? { sent: true } : { sent: false, error: result.error };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error("[createAdminUser] Welcome email failed (non-fatal):", msg);
|
||||
return { sent: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,77 +184,326 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
export type CreateAdminUserResult = {
|
||||
user: AdminUserRow | null;
|
||||
error: string | null;
|
||||
/**
|
||||
* The temporary password that was set on the Neon Auth account. Only
|
||||
* populated on success. The UI should display this once to the caller
|
||||
* and offer a copy-to-clipboard affordance — the password is never
|
||||
* stored in plaintext and cannot be retrieved again.
|
||||
*/
|
||||
tempPassword?: string;
|
||||
/**
|
||||
* Whether the welcome email was dispatched. Best-effort — the user is
|
||||
* created even if email fails. False here means the caller should
|
||||
* share the password out-of-band.
|
||||
*/
|
||||
emailSent?: boolean;
|
||||
/**
|
||||
* Set when `emailSent` is false. Helps the UI show a useful warning.
|
||||
*/
|
||||
emailError?: string;
|
||||
/**
|
||||
* Which Neon Auth path was used to create the account. "admin" means
|
||||
* the caller's Neon Auth session has `role = 'admin'` and we used the
|
||||
* privileged `auth.admin.createUser` endpoint. "signup" means we had
|
||||
* to fall back to the public `/sign-up/email` endpoint (e.g. the
|
||||
* caller's Neon Auth user is not flagged as admin, or the admin
|
||||
* endpoint rejected the request). The choice is recorded for
|
||||
* auditability.
|
||||
*/
|
||||
authPath?: "admin" | "signup";
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new admin user. Steps:
|
||||
*
|
||||
* 1. Verify the caller is a `platform_admin`.
|
||||
* 2. Create the Neon Auth account (with the provided password) and
|
||||
* link it to the local `admin_users` row via `user_id`. Tries the
|
||||
* privileged `auth.admin.createUser` first; falls back to the
|
||||
* public `/sign-up/email` + `emailVerified = true` pattern when
|
||||
* the caller's Neon Auth session is not an admin (which is the
|
||||
* common case in dev / when a brand admin's user was not
|
||||
* provisioned as Neon Auth admin).
|
||||
* 3. Insert `admin_user_brands` link if a brand was selected.
|
||||
* 4. Send a welcome email with the temporary password (best-effort).
|
||||
*
|
||||
* The temp password is returned to the caller so the UI can show it
|
||||
* once — this is the only opportunity to share it with the new user,
|
||||
* since Neon Auth stores a hash, not the plaintext.
|
||||
*/
|
||||
export async function createAdminUser(
|
||||
input: CreateAdminUserInput,
|
||||
): Promise<CreateAdminUserResult> {
|
||||
// 1. Authorization: only platform admins can mint new admin users.
|
||||
const caller = await getAdminUser();
|
||||
if (!caller) {
|
||||
return { user: null, error: "Not authenticated. Please sign in again." };
|
||||
}
|
||||
if (caller.role !== "platform_admin") {
|
||||
return {
|
||||
user: null,
|
||||
error: "Only platform admins can create new admin users.",
|
||||
};
|
||||
}
|
||||
|
||||
const email = input.email.trim().toLowerCase();
|
||||
const password = input.password;
|
||||
const displayName = input.display_name ?? email.split("@")[0];
|
||||
const f = input.flags;
|
||||
|
||||
let neonAuthUserId: string;
|
||||
let authPath: "admin" | "signup" = "admin";
|
||||
try {
|
||||
// No Supabase Auth — `user_id` stays NULL until the user signs in
|
||||
// via Auth.js and `get_admin_user_for_session` matches them by
|
||||
// `auth_subject` / `email`. We just insert the row.
|
||||
const f = input.flags;
|
||||
const { rows } = await query<Record<string, unknown>>(
|
||||
`INSERT INTO admin_users
|
||||
(user_id, display_name, email, phone_number, role, brand_id,
|
||||
can_manage_products, can_manage_stops, can_manage_orders,
|
||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
active, must_change_password, auth_provider, auth_subject)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'pending',$17)
|
||||
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
|
||||
can_manage_products, can_manage_stops, can_manage_orders,
|
||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
active, must_change_password, created_at, last_login`,
|
||||
[
|
||||
null,
|
||||
input.display_name ?? input.email.split("@")[0],
|
||||
input.email.toLowerCase(),
|
||||
input.phone_number ?? null,
|
||||
input.role,
|
||||
input.brand_id,
|
||||
f.can_manage_products ?? false,
|
||||
f.can_manage_stops ?? false,
|
||||
f.can_manage_orders ?? false,
|
||||
f.can_manage_pickup ?? false,
|
||||
f.can_manage_messages ?? false,
|
||||
f.can_manage_refunds ?? false,
|
||||
f.can_manage_users ?? false,
|
||||
f.can_manage_water_log ?? false,
|
||||
f.can_manage_reports ?? false,
|
||||
input.mustChangePassword ?? true,
|
||||
input.email.toLowerCase(),
|
||||
],
|
||||
);
|
||||
if (!rows[0]) return { user: null, error: "Insert returned no row" };
|
||||
// 2. Create the Neon Auth account. Try the privileged admin
|
||||
// endpoint first.
|
||||
const adminResult = await neonAuthCreateUser({
|
||||
email,
|
||||
password,
|
||||
name: displayName,
|
||||
});
|
||||
|
||||
const newAdminId = String(rows[0].id);
|
||||
if (adminResult.data?.user?.id) {
|
||||
neonAuthUserId = String(adminResult.data.user.id);
|
||||
authPath = "admin";
|
||||
} else if (adminResult.error) {
|
||||
// 2b. Fallback: public sign-up + flip emailVerified to true. The
|
||||
// admin endpoint requires the caller to have role='admin' in
|
||||
// Neon Auth, which is not always true (provision-admin.ts does
|
||||
// not set it). Falling back to sign-up is a safe, well-tested
|
||||
// path — see scripts/seed-admin.ts.
|
||||
const code = adminResult.error.code ?? "";
|
||||
const isAuthRequired =
|
||||
code === "FAILED_TO_CREATE_USER" ||
|
||||
code === "FORBIDDEN" ||
|
||||
code === "UNAUTHORIZED" ||
|
||||
code === "INTERNAL_SERVER_ERROR" ||
|
||||
code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL";
|
||||
|
||||
// Ensure the admin_user_brands link exists for brand-scoped roles.
|
||||
// (Platform admins created without a chosen brand may have 0 links and still
|
||||
// get access via role; getAdminUser allows this.)
|
||||
if (input.brand_id) {
|
||||
try {
|
||||
await query(
|
||||
if (code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
||||
return {
|
||||
user: null,
|
||||
error: `A Neon Auth user with email ${email} already exists. Pick a different email or reset the existing user's password.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isAuthRequired) {
|
||||
return {
|
||||
user: null,
|
||||
error: `Neon Auth createUser failed: ${adminResult.error.message ?? code ?? "unknown error"}`,
|
||||
};
|
||||
}
|
||||
|
||||
authPath = "signup";
|
||||
const signupResult = await signupFallbackCreate(email, password, displayName);
|
||||
if (!signupResult.userId) {
|
||||
return {
|
||||
user: null,
|
||||
error: `Could not create Neon Auth user via fallback sign-up: ${signupResult.error}`,
|
||||
};
|
||||
}
|
||||
neonAuthUserId = signupResult.userId;
|
||||
} else {
|
||||
return {
|
||||
user: null,
|
||||
error: "Neon Auth createUser returned neither data nor error.",
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
user: null,
|
||||
error: `Neon Auth error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Insert the local admin_users row + brand link in a transaction.
|
||||
// We do this AFTER creating the Neon Auth user so a Neon Auth
|
||||
// failure doesn't leave a dangling local row.
|
||||
let insertedRow: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const result = await withTx(async (client) => {
|
||||
const ins = await client.query<Record<string, unknown>>(
|
||||
`INSERT INTO admin_users
|
||||
(user_id, display_name, email, phone_number, role, brand_id,
|
||||
can_manage_products, can_manage_stops, can_manage_orders,
|
||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
active, must_change_password, auth_provider, auth_subject)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'neon_auth',$17)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
email = EXCLUDED.email,
|
||||
phone_number = EXCLUDED.phone_number,
|
||||
role = EXCLUDED.role,
|
||||
brand_id = EXCLUDED.brand_id,
|
||||
can_manage_products = EXCLUDED.can_manage_products,
|
||||
can_manage_stops = EXCLUDED.can_manage_stops,
|
||||
can_manage_orders = EXCLUDED.can_manage_orders,
|
||||
can_manage_pickup = EXCLUDED.can_manage_pickup,
|
||||
can_manage_messages = EXCLUDED.can_manage_messages,
|
||||
can_manage_refunds = EXCLUDED.can_manage_refunds,
|
||||
can_manage_users = EXCLUDED.can_manage_users,
|
||||
can_manage_water_log= EXCLUDED.can_manage_water_log,
|
||||
can_manage_reports = EXCLUDED.can_manage_reports,
|
||||
active = EXCLUDED.active,
|
||||
must_change_password= EXCLUDED.must_change_password,
|
||||
auth_provider = EXCLUDED.auth_provider
|
||||
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
|
||||
can_manage_products, can_manage_stops, can_manage_orders,
|
||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
active, must_change_password, created_at, last_login`,
|
||||
[
|
||||
neonAuthUserId,
|
||||
displayName,
|
||||
email,
|
||||
input.phone_number ?? null,
|
||||
input.role,
|
||||
input.brand_id,
|
||||
f.can_manage_products ?? false,
|
||||
f.can_manage_stops ?? false,
|
||||
f.can_manage_orders ?? false,
|
||||
f.can_manage_pickup ?? false,
|
||||
f.can_manage_messages ?? false,
|
||||
f.can_manage_refunds ?? false,
|
||||
f.can_manage_users ?? false,
|
||||
f.can_manage_water_log ?? false,
|
||||
f.can_manage_reports ?? false,
|
||||
input.mustChangePassword ?? true,
|
||||
neonAuthUserId,
|
||||
],
|
||||
);
|
||||
if (!ins.rows[0]) throw new Error("admin_users insert returned no row");
|
||||
|
||||
if (input.brand_id) {
|
||||
await client.query(
|
||||
`INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (admin_user_id, brand_id) DO NOTHING`,
|
||||
[newAdminId, input.brand_id],
|
||||
[ins.rows[0].id, input.brand_id],
|
||||
);
|
||||
} catch (linkErr) {
|
||||
console.error("[createAdminUser] Failed to create admin_user_brands link:", linkErr);
|
||||
// Non-fatal — the user row exists; a platform admin can link manually.
|
||||
}
|
||||
return ins.rows[0];
|
||||
});
|
||||
insertedRow = result;
|
||||
} catch (err) {
|
||||
// We created the Neon Auth account but failed to insert the local
|
||||
// row. Don't roll back the auth user — the operator can re-link
|
||||
// it via the admin UI / DB. Surface the error.
|
||||
console.error(
|
||||
"[createAdminUser] Neon Auth user created (id=" + neonAuthUserId + ") but admin_users insert failed:",
|
||||
err,
|
||||
);
|
||||
return {
|
||||
user: null,
|
||||
error:
|
||||
`Neon Auth user was created but the local admin row failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}. The Neon Auth account is orphaned — link it manually via the admin_users table.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Welcome email (best-effort).
|
||||
const emailResult = await sendWelcomeEmailSafe({
|
||||
to: email,
|
||||
name: displayName,
|
||||
role: input.role,
|
||||
password,
|
||||
brandId: input.brand_id ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
user: mapUserRow(insertedRow),
|
||||
error: null,
|
||||
tempPassword: password,
|
||||
emailSent: emailResult.sent,
|
||||
emailError: emailResult.error,
|
||||
authPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Public sign-up + emailVerified = true fallback. Used when the
|
||||
* caller's Neon Auth session is not flagged as admin, or the admin
|
||||
* endpoint rejected the request for any reason.
|
||||
*/
|
||||
async function signupFallbackCreate(
|
||||
email: string,
|
||||
password: string,
|
||||
name: string,
|
||||
): Promise<{ userId: string | null; error: string | null }> {
|
||||
const baseUrl = process.env.NEON_AUTH_BASE_URL;
|
||||
if (!baseUrl) {
|
||||
return {
|
||||
userId: null,
|
||||
error: "NEON_AUTH_BASE_URL is not set; cannot fall back to public sign-up.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/sign-up/email`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Origin": process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000",
|
||||
"x-neon-auth-proxy": "node",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
callbackURL: "/admin",
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
user?: { id?: string };
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
||||
// Look up the existing user id so we can still link.
|
||||
const existing = await query<{ id: string }>(
|
||||
`SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1`,
|
||||
[email],
|
||||
);
|
||||
if (existing.rows[0]?.id) {
|
||||
return { userId: existing.rows[0].id, error: null };
|
||||
}
|
||||
return { userId: null, error: "User already exists but could not be looked up." };
|
||||
}
|
||||
return {
|
||||
userId: null,
|
||||
error: data.message ?? data.code ?? `Sign-up failed (HTTP ${res.status})`,
|
||||
};
|
||||
}
|
||||
|
||||
await sendWelcomeEmailSafe({
|
||||
to: input.email,
|
||||
name: input.display_name ?? input.email.split("@")[0],
|
||||
role: input.role,
|
||||
password: input.password,
|
||||
brandId: input.brand_id ?? undefined,
|
||||
});
|
||||
const userId = data.user?.id;
|
||||
if (!userId) {
|
||||
return { userId: null, error: "Sign-up response missing user.id" };
|
||||
}
|
||||
|
||||
return { user: mapUserRow(rows[0]), error: null };
|
||||
// Flip emailVerified so the user can sign in without a verification step.
|
||||
// The platform admin already vetted this email — they're handing the
|
||||
// password to the user directly.
|
||||
try {
|
||||
await query(`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`, [userId]);
|
||||
} catch (verifyErr) {
|
||||
console.warn(
|
||||
"[createAdminUser] Failed to set emailVerified=true (non-fatal):",
|
||||
verifyErr,
|
||||
);
|
||||
}
|
||||
|
||||
return { userId, error: null };
|
||||
} catch (err) {
|
||||
return { user: null, error: err instanceof Error ? err.message : String(err) };
|
||||
return {
|
||||
userId: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,17 +566,59 @@ export async function setMustChangePassword(userId: string): Promise<{ success:
|
||||
}
|
||||
|
||||
/**
|
||||
* No auth service anymore (no Supabase, no Auth.js password-reset
|
||||
* endpoint). A platform admin can reset access by deleting +
|
||||
* re-creating the user, or by toggling `must_change_password` via the
|
||||
* UI — the function is preserved as a no-op so call sites keep
|
||||
* compiling.
|
||||
* Sends a password-reset email to the user via Neon Auth.
|
||||
*
|
||||
* Authorization: platform_admin only. Brand-scoped admins cannot
|
||||
* trigger password resets for users outside their brand.
|
||||
*
|
||||
* The endpoint used (`requestPasswordReset`) is a public Neon Auth
|
||||
* endpoint — it does not require the caller to be the target user.
|
||||
* For the same reason the public /api/auth/forgot-password route
|
||||
* works, this will always succeed against the Neon Auth API; we
|
||||
* still return the result so the UI can show a clear success/failure
|
||||
* message.
|
||||
*/
|
||||
export async function sendPasswordResetEmail(_email: string): Promise<{ success: boolean; error: string | null }> {
|
||||
return {
|
||||
success: false,
|
||||
error: "Password reset is handled by a platform admin. Contact them to reset your access.",
|
||||
};
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
): Promise<{ success: boolean; error: string | null }> {
|
||||
try {
|
||||
// Authz: must be signed in as a platform_admin.
|
||||
const me = await getAdminUser();
|
||||
if (!me) {
|
||||
return { success: false, error: "Not authenticated." };
|
||||
}
|
||||
if (me.role !== "platform_admin") {
|
||||
return { success: false, error: "Only platform admins can send password resets." };
|
||||
}
|
||||
|
||||
const trimmedEmail = email.trim().toLowerCase();
|
||||
if (!trimmedEmail) {
|
||||
return { success: false, error: "Email is required." };
|
||||
}
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
|
||||
|
||||
const result = await neonAuthRequestPasswordReset({
|
||||
email: trimmedEmail,
|
||||
redirectTo: `${siteUrl}/reset-password`,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
console.error("[admin/sendPasswordResetEmail] Neon Auth error:", result.error);
|
||||
return {
|
||||
success: false,
|
||||
error: result.error.message ?? result.error.code ?? "Failed to send reset email",
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, error: null };
|
||||
} catch (err) {
|
||||
console.error("[admin/sendPasswordResetEmail] Unexpected error:", err);
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
|
||||
@@ -327,7 +632,3 @@ export async function getBrands(): Promise<{ brands: { id: string; name: string
|
||||
return { brands: [], error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// Keep `pool` reachable so bundlers don't tree-shake the import — the
|
||||
// import is for the `server-only` side effect.
|
||||
void pool;
|
||||
|
||||
@@ -284,7 +284,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
||||
}>(
|
||||
`SELECT
|
||||
o.id::text AS id,
|
||||
c.name AS customer_name,
|
||||
TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
|
||||
o.total_cents::float / 100.0 AS subtotal,
|
||||
o.status,
|
||||
o.placed_at::text AS created_at,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { signOut } from "@/lib/auth";
|
||||
import { signIn, signOut } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
@@ -12,3 +12,61 @@ export async function signOutAction(): Promise<void> {
|
||||
await signOut();
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick off a Google OAuth sign-in. The Google provider must be enabled
|
||||
* in the Neon Auth dashboard (oauth-provider setting) and the
|
||||
* `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` env vars must be set
|
||||
* in the runtime environment.
|
||||
*
|
||||
* Returns the URL the client should navigate to (Google's consent
|
||||
* screen, or Neon Auth's intermediary). The client should do
|
||||
* `window.location.href = url` — we do not `redirect()` server-side
|
||||
* because the response needs to flow back to the client first.
|
||||
*/
|
||||
export async function signInWithGoogleAction(input: {
|
||||
callbackURL?: string;
|
||||
errorCallbackURL?: string;
|
||||
}): Promise<{ url: string | null; error: string | null }> {
|
||||
const callbackURL = input.callbackURL ?? "/admin";
|
||||
const errorCallbackURL = input.errorCallbackURL ?? "/login?error=oauth";
|
||||
|
||||
try {
|
||||
// Neon Auth's better-auth client returns `{ data, error }`. For
|
||||
// social sign-in, `data` is `{ redirect, url, token?, user? }`.
|
||||
// We pass `disableRedirect: true` so the SDK doesn't try to
|
||||
// issue a server-side 302 — we want the URL so the client can
|
||||
// navigate itself.
|
||||
const result = await signIn.social({
|
||||
provider: "google",
|
||||
callbackURL,
|
||||
errorCallbackURL,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error("[auth/google] signIn.social error:", result.error);
|
||||
return {
|
||||
url: null,
|
||||
error:
|
||||
result.error.message ??
|
||||
result.error.code ??
|
||||
"Could not start Google sign-in.",
|
||||
};
|
||||
}
|
||||
|
||||
const url = result.data?.url ?? null;
|
||||
if (!url) {
|
||||
return {
|
||||
url: null,
|
||||
error: "Google sign-in returned no redirect URL.",
|
||||
};
|
||||
}
|
||||
return { url, error: null };
|
||||
} catch (err) {
|
||||
console.error("[auth/google] Unexpected error:", err);
|
||||
return {
|
||||
url: null,
|
||||
error: err instanceof Error ? err.message : "An unexpected error occurred.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export async function createOrder(
|
||||
}
|
||||
}
|
||||
|
||||
await sendOrderReceiptEmail({
|
||||
const result = await sendOrderReceiptEmail({
|
||||
customerName,
|
||||
customerEmail,
|
||||
orderId: data.id,
|
||||
@@ -149,6 +149,9 @@ export async function createOrder(
|
||||
brandName: "Tuxedo Corn",
|
||||
logoUrl,
|
||||
});
|
||||
if (!result.ok) {
|
||||
console.error("[checkout] Order receipt email failed:", result.error);
|
||||
}
|
||||
} catch {
|
||||
// Email failure should not fail the order
|
||||
}
|
||||
|
||||
@@ -28,6 +28,30 @@ export type DashboardSummary = {
|
||||
active_products: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mobile-dashboard summary shape. Returned by the `get_dashboard_summary`
|
||||
* RPC (migration 0091) and consumed by `/admin/v2` to power the four
|
||||
* stat cards + the 7-day order chart.
|
||||
*
|
||||
* - `revenue_today` is in CENTS (not dollars) — divide by 100 for display.
|
||||
* - `orders_last_7_days` is oldest → today (7 elements, includes today).
|
||||
*/
|
||||
export type MobileDashboardSummary = {
|
||||
orders_today: number;
|
||||
revenue_today: number;
|
||||
pending_fulfillment: number;
|
||||
stops_today: number;
|
||||
orders_last_7_days: Array<{ date: string; count: number }>;
|
||||
};
|
||||
|
||||
const EMPTY_MOBILE_DASHBOARD: MobileDashboardSummary = {
|
||||
orders_today: 0,
|
||||
revenue_today: 0,
|
||||
pending_fulfillment: 0,
|
||||
stops_today: 0,
|
||||
orders_last_7_days: [],
|
||||
};
|
||||
|
||||
// ── Dashboard Stats Actions ─────────────────────────────────────────────────
|
||||
|
||||
export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
@@ -42,24 +66,22 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch today's orders. `orders` and `stops` use the legacy schema
|
||||
// (column names like `subtotal`, `brand_id`, `date`); the new-schema
|
||||
// Drizzle `orders` table doesn't have these. Raw SQL via the shared
|
||||
// pg pool.
|
||||
// Fetch today's orders. New schema: `total_cents` (int, divide by 100
|
||||
// for dollars) and `placed_at` (not legacy `subtotal` / `created_at`).
|
||||
const todayOrdersRes = brandId
|
||||
? await pool.query<{ subtotal: number; status: string }>(
|
||||
`SELECT subtotal, status
|
||||
? await pool.query<{ total_cents: number; status: string }>(
|
||||
`SELECT total_cents, status
|
||||
FROM orders
|
||||
WHERE created_at >= $1
|
||||
AND created_at < $2
|
||||
WHERE placed_at >= $1
|
||||
AND placed_at < $2
|
||||
AND brand_id = $3`,
|
||||
[startOfDay.toISOString(), endOfDay.toISOString(), brandId],
|
||||
)
|
||||
: await pool.query<{ subtotal: number; status: string }>(
|
||||
`SELECT subtotal, status
|
||||
: await pool.query<{ total_cents: number; status: string }>(
|
||||
`SELECT total_cents, status
|
||||
FROM orders
|
||||
WHERE created_at >= $1
|
||||
AND created_at < $2`,
|
||||
WHERE placed_at >= $1
|
||||
AND placed_at < $2`,
|
||||
[startOfDay.toISOString(), endOfDay.toISOString()],
|
||||
);
|
||||
const todayOrders = todayOrdersRes.rows;
|
||||
@@ -67,7 +89,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
// Calculate today's revenue and orders
|
||||
const validOrders = todayOrders.filter((o) => o.status !== "cancelled");
|
||||
const todayRevenue = validOrders.reduce(
|
||||
(sum, o) => sum + (o.subtotal || 0),
|
||||
(sum, o) => sum + ((o.total_cents || 0) / 100),
|
||||
0,
|
||||
);
|
||||
const todayOrderCount = validOrders.length;
|
||||
@@ -106,7 +128,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
);
|
||||
const activeProducts = productsRes.rows.length;
|
||||
|
||||
// Fetch weekly orders for chart (last 7 days)
|
||||
// Fetch weekly orders for chart (last 7 days). New schema uses `placed_at`.
|
||||
const weeklyOrders: number[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const dayStart = new Date(startOfDay);
|
||||
@@ -116,48 +138,60 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
const dayRes = brandId
|
||||
? await pool.query<{ id: string }>(
|
||||
`SELECT id FROM orders
|
||||
WHERE created_at >= $1
|
||||
AND created_at < $2
|
||||
WHERE placed_at >= $1
|
||||
AND placed_at < $2
|
||||
AND brand_id = $3
|
||||
LIMIT 1`,
|
||||
[dayStart.toISOString(), dayEnd.toISOString(), brandId],
|
||||
)
|
||||
: await pool.query<{ id: string }>(
|
||||
`SELECT id FROM orders
|
||||
WHERE created_at >= $1
|
||||
AND created_at < $2
|
||||
WHERE placed_at >= $1
|
||||
AND placed_at < $2
|
||||
LIMIT 1`,
|
||||
[dayStart.toISOString(), dayEnd.toISOString()],
|
||||
);
|
||||
weeklyOrders.push(dayRes.rows.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
// Fetch recent orders (last 10)
|
||||
// Fetch recent orders (last 10). New schema: `total_cents`/`placed_at`
|
||||
// on `orders`; customer name comes from a `customers` join (no
|
||||
// `customer_name` column on `orders`).
|
||||
const recentRes = brandId
|
||||
? await pool.query<{
|
||||
id: string;
|
||||
customer_name: string;
|
||||
subtotal: number;
|
||||
total_cents: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
placed_at: string;
|
||||
}>(
|
||||
`SELECT id, customer_name, subtotal, status, created_at
|
||||
FROM orders
|
||||
WHERE brand_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`SELECT o.id::text AS id,
|
||||
TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
|
||||
o.total_cents,
|
||||
o.status,
|
||||
o.placed_at::text AS placed_at
|
||||
FROM orders o
|
||||
LEFT JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.brand_id = $1
|
||||
ORDER BY o.placed_at DESC
|
||||
LIMIT 10`,
|
||||
[brandId],
|
||||
)
|
||||
: await pool.query<{
|
||||
id: string;
|
||||
customer_name: string;
|
||||
subtotal: number;
|
||||
total_cents: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
placed_at: string;
|
||||
}>(
|
||||
`SELECT id, customer_name, subtotal, status, created_at
|
||||
FROM orders
|
||||
ORDER BY created_at DESC
|
||||
`SELECT o.id::text AS id,
|
||||
TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
|
||||
o.total_cents,
|
||||
o.status,
|
||||
o.placed_at::text AS placed_at
|
||||
FROM orders o
|
||||
LEFT JOIN customers c ON c.id = o.customer_id
|
||||
ORDER BY o.placed_at DESC
|
||||
LIMIT 10`,
|
||||
);
|
||||
|
||||
@@ -166,9 +200,9 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
.map((o) => ({
|
||||
id: o.id || "",
|
||||
customer_name: o.customer_name || "Guest",
|
||||
total: o.subtotal || 0,
|
||||
total: (o.total_cents || 0) / 100,
|
||||
status: o.status || "unknown",
|
||||
created_at: formatTimeAgo(o.created_at),
|
||||
created_at: formatTimeAgo(o.placed_at),
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -266,6 +300,48 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the mobile dashboard summary for a single brand.
|
||||
*
|
||||
* Different name from the existing `getDashboardSummary()` (which is the
|
||||
* legacy 4-field shape used by the v1 dashboard) so both can coexist.
|
||||
* Returns safe fallbacks on any error — the v2 page renders a 0s
|
||||
* dashboard rather than crashing the shell.
|
||||
*/
|
||||
export async function getMobileDashboardSummary(
|
||||
brandId: string,
|
||||
): Promise<MobileDashboardSummary> {
|
||||
try {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return EMPTY_MOBILE_DASHBOARD;
|
||||
}
|
||||
const { rows } = await pool.query<{ data: MobileDashboardSummary }>(
|
||||
"SELECT get_dashboard_summary($1::uuid) AS data",
|
||||
[brandId],
|
||||
);
|
||||
const data = rows[0]?.data;
|
||||
if (!data) {
|
||||
return EMPTY_MOBILE_DASHBOARD;
|
||||
}
|
||||
return {
|
||||
orders_today: Number(data.orders_today ?? 0),
|
||||
revenue_today: Number(data.revenue_today ?? 0),
|
||||
pending_fulfillment: Number(data.pending_fulfillment ?? 0),
|
||||
stops_today: Number(data.stops_today ?? 0),
|
||||
orders_last_7_days: Array.isArray(data.orders_last_7_days)
|
||||
? data.orders_last_7_days.map((d) => ({
|
||||
date: String(d?.date ?? ""),
|
||||
count: Number(d?.count ?? 0),
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch mobile dashboard summary:", error);
|
||||
return EMPTY_MOBILE_DASHBOARD;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTimeAgo(dateString: string): string {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
/**
|
||||
* Offline mutation dispatcher.
|
||||
*
|
||||
* The v2 mobile admin (PR 3) introduces an IndexedDB-backed mutation
|
||||
* queue (src/lib/offline/queue.ts) and a sync engine
|
||||
* (src/lib/offline/sync.ts) that replays queued actions when the
|
||||
* device comes back online. The client side enqueues an action with a
|
||||
* name + JSON payload + idempotency key (clientActionId), then calls
|
||||
* `syncPending` which invokes this dispatcher for each pending action.
|
||||
*
|
||||
* The dispatcher is the single server-side bridge between the offline
|
||||
* queue and our SECURITY DEFINER server actions. To add a new offline-
|
||||
* capable action, append an entry to `listClientActions()` with:
|
||||
* - a stable name (kebab- or camelCase, the client enqueues by name)
|
||||
* - a handler that accepts the JSON payload and returns
|
||||
* { success: true } | { success: false; error: string }
|
||||
*
|
||||
* The action names referenced by the plan (markOrderReady,
|
||||
* markOrderPickedUp, updateStopStatus, adjustProductStock) do not yet
|
||||
* exist as server actions in the v1 codebase. They're added in
|
||||
* follow-up PRs. For now the dispatcher returns `{ ok: false, error:
|
||||
* "Unknown action" }` for them, which surfaces in the OfflineBanner
|
||||
* conflict count. This keeps the structure in place without coupling
|
||||
* PR 3 to a server-side action surface it doesn't own.
|
||||
*
|
||||
* Idempotency: each enqueue carries a clientActionId (uuid v4). Real
|
||||
* idempotency requires a dedup table on the server — that's also a
|
||||
* follow-up. Until then, replays of the same action will re-execute,
|
||||
* but last-write-wins on the underlying tables is safe for the
|
||||
* mutations we ship (status flips, stock adjust).
|
||||
*/
|
||||
|
||||
interface ClientAction {
|
||||
name: string;
|
||||
// The handler signature is intentionally loose — payload shapes vary
|
||||
// per action. The dispatcher in `sync.ts` invokes this with the raw
|
||||
// payload from IndexedDB.
|
||||
handler: (payload: unknown, clientActionId: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function listClientActions(): Promise<ClientAction[]> {
|
||||
// Wire real server actions here as they land:
|
||||
// { name: "markOrderReady", handler: markOrderReady },
|
||||
// { name: "markOrderPickedUp", handler: markOrderPickedUp },
|
||||
// { name: "updateStopStatus", handler: updateStopStatus },
|
||||
// { name: "adjustProductStock", handler: adjustProductStock },
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function dispatchClientAction(
|
||||
actionName: string,
|
||||
payload: unknown,
|
||||
clientActionId: string,
|
||||
): Promise<
|
||||
| { ok: true }
|
||||
| { ok: false; conflict: string }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
const actions = await listClientActions();
|
||||
const action = actions.find((a) => a.name === actionName);
|
||||
if (!action) {
|
||||
return { ok: false, error: "Unknown action" };
|
||||
}
|
||||
try {
|
||||
const result = await action.handler(payload, clientActionId);
|
||||
if (result.success === false) {
|
||||
const message = result.error ?? "Action failed";
|
||||
if (/conflict|stale/i.test(message)) {
|
||||
return { ok: false, conflict: message };
|
||||
}
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
revalidatePath("/admin/v2/orders");
|
||||
revalidatePath("/admin/v2/orders/[id]", "page");
|
||||
revalidatePath("/admin/v2/stops");
|
||||
revalidatePath("/admin/v2/products");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type PlatformMetrics = {
|
||||
active_brands: number;
|
||||
orders_today: number;
|
||||
revenue_today: number;
|
||||
active_routes: number;
|
||||
failed_orders_today: number;
|
||||
pending_orders_today: number;
|
||||
};
|
||||
|
||||
export type ActivityEvent = {
|
||||
id: string;
|
||||
event_type: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
payload: Record<string, unknown>;
|
||||
actor_type: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
brand_id: string | null;
|
||||
brand_name: string | null;
|
||||
};
|
||||
|
||||
export type BrandHealth = {
|
||||
brand_id: string;
|
||||
brand_name: string;
|
||||
brand_slug: string;
|
||||
plan_tier: string;
|
||||
orders_today: number;
|
||||
revenue_today: number;
|
||||
active_stops: number;
|
||||
failed_orders: number;
|
||||
pending_orders: number;
|
||||
last_activity: string | null;
|
||||
open_pain_items: number;
|
||||
health_status: "healthy" | "warning" | "critical";
|
||||
};
|
||||
|
||||
export type PainLogItem = {
|
||||
id: string;
|
||||
brand_id: string | null;
|
||||
brand_name: string | null;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
category: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CreatePainLogData = {
|
||||
brand_id?: string | null;
|
||||
severity: "low" | "medium" | "high" | "critical";
|
||||
category: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
// ── Internal RPC helper ──────────────────────────────────────────────────────
|
||||
|
||||
async function platformRPC<T>(rpcName: string): Promise<T> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
if (adminUser.role !== "platform_admin") throw new Error("Access denied: platform admin only");
|
||||
|
||||
const { rows } = await pool.query<Record<string, T>>(
|
||||
`SELECT ${rpcName}() AS "${rpcName}"`,
|
||||
);
|
||||
|
||||
const data = rows[0]?.[rpcName];
|
||||
if (data == null) {
|
||||
return null as T;
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// ── Platform actions ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getPlatformMetrics(): Promise<PlatformMetrics> {
|
||||
return platformRPC<PlatformMetrics>("get_platform_command_center_metrics");
|
||||
}
|
||||
|
||||
export async function getPlatformActivityFeed(): Promise<ActivityEvent[]> {
|
||||
return platformRPC<ActivityEvent[]>("get_platform_activity_feed");
|
||||
}
|
||||
|
||||
export async function getBrandHealthSnapshot(): Promise<BrandHealth[]> {
|
||||
return platformRPC<BrandHealth[]>("get_brand_health_snapshot");
|
||||
}
|
||||
|
||||
export async function getPainLog(): Promise<PainLogItem[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const { rows } = await pool.query<PainLogItem>(
|
||||
`SELECT * FROM founder_pain_log_platform ORDER BY created_at DESC`,
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function createPainLogItem(data: CreatePainLogData): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO founder_pain_log (brand_id, severity, category, title, description)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[
|
||||
data.brand_id || null,
|
||||
data.severity,
|
||||
data.category,
|
||||
data.title,
|
||||
data.description || null,
|
||||
],
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvePainLogItem(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
|
||||
|
||||
await pool.query(
|
||||
`UPDATE founder_pain_log
|
||||
SET status = 'resolved', resolved_at = now(), resolved_by = $2
|
||||
WHERE id = $1`,
|
||||
[id, adminUser.id],
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
@@ -73,9 +73,9 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
|
||||
}>(
|
||||
`SELECT
|
||||
o.id::text AS id,
|
||||
c.name AS customer_name,
|
||||
c.email AS customer_email,
|
||||
c.phone AS customer_phone,
|
||||
TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
|
||||
c.primary_email AS customer_email,
|
||||
c.primary_phone AS customer_phone,
|
||||
o.status,
|
||||
o.total_cents::float / 100.0 AS subtotal,
|
||||
o.placed_at::text AS created_at,
|
||||
|
||||
@@ -1,88 +1,374 @@
|
||||
/**
|
||||
* Water Log — field (PIN) actions.
|
||||
*
|
||||
* These power `/water` and `/water/admin` — the mobile-first, PIN-only
|
||||
* portals that ditch-riders actually use in the field. The shape of
|
||||
* the data they consume is the same as admin actions, but the
|
||||
* authorization model is different:
|
||||
*
|
||||
* - Irrigators carry a `wl_session` cookie tied to a row in
|
||||
* `water_sessions`. Cookie expiry = row's `expires_at`.
|
||||
* - Admins (water_admin role) carry a `wl_admin_session` cookie
|
||||
* tied to `water_admin_sessions`.
|
||||
*
|
||||
* Both cookies are httpOnly + sameSite=lax. The session lookup is the
|
||||
* gate for every mutating action — there's no role/permission check
|
||||
* inside the action because the cookie presence + the row's role is
|
||||
* the permission.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { and, desc, eq, gte, sql } from "drizzle-orm";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import {
|
||||
waterHeadgates,
|
||||
waterIrrigators,
|
||||
waterSessions,
|
||||
waterLogEntries,
|
||||
waterAdminSessions,
|
||||
} from "@/db/schema/water-log";
|
||||
import { verifyPin, validatePin } from "@/lib/water-log-pin";
|
||||
import { logAlert } from "@/lib/water-log-audit";
|
||||
|
||||
// TODO(migration): the water-log field UI used a chain of Supabase RPCs
|
||||
// (`get_water_headgates`, `verify_water_pin`, `get_water_user_by_id`,
|
||||
// `submit_water_entry`, `trigger_water_alert`,
|
||||
// `get_water_admin_session`) and tables (`water_headgates`,
|
||||
// `water_users`, `water_sessions`, `water_admin_sessions`,
|
||||
// `water_entries`, `water_alert_log`) that are not in the SaaS
|
||||
// rebuild's `db/schema/`. The actions below preserve the original
|
||||
// signatures and return empty / no-op responses so the field UI
|
||||
// degrades gracefully. See `actions/route-trace/lots.ts` for the
|
||||
// same pattern.
|
||||
// Field sessions last 8h — a working day in the field. Long enough
|
||||
// that a single sign-in covers a morning shift + afternoon shift.
|
||||
const FIELD_SESSION_HOURS = 8;
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
type VerifyPinResult = {
|
||||
success: true;
|
||||
user_id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
session_id: string;
|
||||
lang: string;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type SubmitEntryResult = {
|
||||
success: true;
|
||||
entry_id: string;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type Headgate = {
|
||||
type FieldHeadgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
status: string;
|
||||
high_threshold: number | null;
|
||||
low_threshold: number | null;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
|
||||
type VerifyPinResult =
|
||||
| {
|
||||
success: true;
|
||||
user_id: string;
|
||||
name: string;
|
||||
role: "irrigator" | "water_admin";
|
||||
session_id: string;
|
||||
lang: string;
|
||||
}
|
||||
| { success: false; error: string };
|
||||
|
||||
type SubmitEntryResult =
|
||||
| { success: true; entry_id: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
// ── Headgates (read-only for field) ────────────────────────────────────────
|
||||
|
||||
export async function getWaterHeadgates(
|
||||
_brandId: string,
|
||||
_activeOnly = false
|
||||
): Promise<Headgate[]> {
|
||||
return [];
|
||||
activeOnly: boolean = false,
|
||||
): Promise<FieldHeadgate[]> {
|
||||
return withBrand(TUXEDO_BRAND_ID, async (db) => {
|
||||
const where = activeOnly
|
||||
? and(
|
||||
eq(waterHeadgates.brandId, TUXEDO_BRAND_ID),
|
||||
eq(waterHeadgates.active, true),
|
||||
)
|
||||
: eq(waterHeadgates.brandId, TUXEDO_BRAND_ID);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterHeadgates)
|
||||
.where(where)
|
||||
.orderBy(waterHeadgates.name);
|
||||
return rows.map((h) => ({
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
unit: h.unit,
|
||||
status: h.status,
|
||||
high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null,
|
||||
low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null,
|
||||
active: h.active,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// ── PIN verify + session ───────────────────────────────────────────────────
|
||||
|
||||
export async function verifyWaterPin(
|
||||
_brandId: string,
|
||||
_pin: string
|
||||
pin: string,
|
||||
): Promise<VerifyPinResult> {
|
||||
return { success: false, error: NOT_CONFIGURED };
|
||||
}
|
||||
// Validate format first (cheap, no DB).
|
||||
const formatError = validatePin(pin);
|
||||
if (formatError) return { success: false, error: formatError };
|
||||
|
||||
export async function submitWaterEntry(
|
||||
_headgateId: string,
|
||||
_measurement: number,
|
||||
_unit: string,
|
||||
_notes: string,
|
||||
_photoUrl?: string,
|
||||
_latitude?: number,
|
||||
_longitude?: number,
|
||||
_headgateLocked?: boolean
|
||||
): Promise<SubmitEntryResult> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_session")?.value;
|
||||
// Look the irrigator up across all brands (PIN is the only credential).
|
||||
const lookup = await withPlatformAdmin(async (db) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterIrrigators)
|
||||
.where(eq(waterIrrigators.active, true))
|
||||
.orderBy(waterIrrigators.name);
|
||||
return rows;
|
||||
});
|
||||
|
||||
if (!sessionId) {
|
||||
return { success: false, error: "Not logged in" };
|
||||
// Constant-time-ish: check every row, only succeed on the first match.
|
||||
// (We can't make this perfectly constant-time without pulling the
|
||||
// hashed values into memory in random order, but iterating by name
|
||||
// is close enough for a 4-digit PIN and avoids the worst case of an
|
||||
// obvious timing oracle on a sorted list.)
|
||||
const match = lookup.find((i) => verifyPin(pin, i.pinHash));
|
||||
if (!match) {
|
||||
// Add a small delay to discourage brute-force scanning.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return { success: false, error: "Invalid PIN" };
|
||||
}
|
||||
|
||||
return { success: false, error: NOT_CONFIGURED };
|
||||
// Create a session in the user's brand context.
|
||||
const sessionId = await withBrand(match.brandId, async (db) => {
|
||||
const expiresAt = new Date(
|
||||
Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000,
|
||||
);
|
||||
const [row] = await db
|
||||
.insert(waterSessions)
|
||||
.values({
|
||||
irrigatorId: match.id,
|
||||
expiresAt,
|
||||
})
|
||||
.returning({ id: waterSessions.id });
|
||||
if (!row) throw new Error("Failed to create session");
|
||||
// Bump last_used_at for "I haven't seen this person in a while" UX
|
||||
await db
|
||||
.update(waterIrrigators)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(waterIrrigators.id, match.id));
|
||||
return row.id;
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wl_session", sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: FIELD_SESSION_HOURS * 3600,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user_id: match.id,
|
||||
name: match.name,
|
||||
role: (match.role as "irrigator" | "water_admin") ?? "irrigator",
|
||||
session_id: sessionId,
|
||||
lang: match.languagePreference,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Submit entry ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function submitWaterEntry(
|
||||
headgateId: string,
|
||||
measurement: number,
|
||||
unit: string,
|
||||
notes: string,
|
||||
photoUrl?: string,
|
||||
latitude?: number,
|
||||
longitude?: number,
|
||||
_headgateLocked?: boolean,
|
||||
): Promise<SubmitEntryResult> {
|
||||
// ── Auth ──
|
||||
const session = await requireFieldSession();
|
||||
if (!session.ok) return { success: false, error: session.error };
|
||||
|
||||
// ── Validate input ──
|
||||
if (!Number.isFinite(measurement) || measurement < 0) {
|
||||
return { success: false, error: "Measurement must be a non-negative number" };
|
||||
}
|
||||
if (measurement > 1_000_000) {
|
||||
return { success: false, error: "Measurement is implausibly large" };
|
||||
}
|
||||
if (notes && notes.length > 500) {
|
||||
return { success: false, error: "Notes are too long (max 500 chars)" };
|
||||
}
|
||||
if (latitude != null && (latitude < -90 || latitude > 90)) {
|
||||
return { success: false, error: "Invalid latitude" };
|
||||
}
|
||||
if (longitude != null && (longitude < -180 || longitude > 180)) {
|
||||
return { success: false, error: "Invalid longitude" };
|
||||
}
|
||||
|
||||
// ── Write the entry inside the brand context ──
|
||||
return withBrand(session.brandId, async (db) => {
|
||||
// Confirm the headgate belongs to this brand
|
||||
const headgateRows = await db
|
||||
.select()
|
||||
.from(waterHeadgates)
|
||||
.where(eq(waterHeadgates.id, headgateId))
|
||||
.limit(1);
|
||||
const headgate = headgateRows[0];
|
||||
if (!headgate) return { success: false, error: "Headgate not found" };
|
||||
|
||||
// Auto-calc total_gallons when CFS × duration is provided.
|
||||
// Caller passes `measurement` as the total CFS volume for the set
|
||||
// (we don't track duration separately on the entry — duration goes
|
||||
// in notes if needed). We leave total_gallons null for CFS by
|
||||
// default to avoid spurious "X gallons" claims.
|
||||
const totalGallons = computeTotalGallons(measurement, unit);
|
||||
|
||||
const now = new Date();
|
||||
const loggedDate = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}`;
|
||||
|
||||
const [entry] = await db
|
||||
.insert(waterLogEntries)
|
||||
.values({
|
||||
brandId: session.brandId,
|
||||
headgateId,
|
||||
irrigatorId: session.userId,
|
||||
measurement: measurement.toString(),
|
||||
unit,
|
||||
totalGallons: totalGallons?.toString() ?? null,
|
||||
method: "manual",
|
||||
notes: notes || null,
|
||||
submittedVia: "field",
|
||||
photoUrl: photoUrl ?? null,
|
||||
latitude: latitude ?? null,
|
||||
longitude: longitude ?? null,
|
||||
loggedDate,
|
||||
loggedAt: now,
|
||||
})
|
||||
.returning({ id: waterLogEntries.id });
|
||||
if (!entry) return { success: false, error: "Insert failed" };
|
||||
|
||||
// Bump headgate last_used_at
|
||||
await db
|
||||
.update(waterHeadgates)
|
||||
.set({ lastUsedAt: now })
|
||||
.where(eq(waterHeadgates.id, headgateId));
|
||||
|
||||
// Threshold alert check (fire-and-forget)
|
||||
if (headgate.highThreshold != null && measurement > Number(headgate.highThreshold)) {
|
||||
void logAlert({
|
||||
brandId: session.brandId,
|
||||
headgateId,
|
||||
alertType: "high",
|
||||
reading: measurement,
|
||||
threshold: Number(headgate.highThreshold),
|
||||
headgateName: headgate.name,
|
||||
});
|
||||
}
|
||||
if (headgate.lowThreshold != null && measurement < Number(headgate.lowThreshold)) {
|
||||
void logAlert({
|
||||
brandId: session.brandId,
|
||||
headgateId,
|
||||
alertType: "low",
|
||||
reading: measurement,
|
||||
threshold: Number(headgate.lowThreshold),
|
||||
headgateName: headgate.name,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, entry_id: entry.id };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Session helpers (shared with admin.ts via requireFieldSession) ────────
|
||||
|
||||
type FieldSession =
|
||||
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function requireFieldSession(): Promise<FieldSession> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_session")?.value;
|
||||
if (!sessionId) return { ok: false, error: "Not logged in" };
|
||||
|
||||
return withPlatformAdmin(async (db) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
session: waterSessions,
|
||||
irrigator: waterIrrigators,
|
||||
})
|
||||
.from(waterSessions)
|
||||
.innerJoin(waterIrrigators, eq(waterIrrigators.id, waterSessions.irrigatorId))
|
||||
.where(eq(waterSessions.id, sessionId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) return { ok: false as const, error: "Session not found" };
|
||||
if (row.session.expiresAt.getTime() < Date.now()) {
|
||||
// Best-effort cleanup
|
||||
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
|
||||
return { ok: false as const, error: "Session expired" };
|
||||
}
|
||||
if (!row.irrigator.active) {
|
||||
return { ok: false as const, error: "User is inactive" };
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
userId: row.irrigator.id,
|
||||
brandId: row.irrigator.brandId,
|
||||
role: (row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFieldSessionUser(): Promise<{
|
||||
userId: string;
|
||||
name: string;
|
||||
brandId: string;
|
||||
role: "irrigator" | "water_admin";
|
||||
} | null> {
|
||||
const s = await requireFieldSession();
|
||||
if (!s.ok) return null;
|
||||
return withPlatformAdmin(async (db) => {
|
||||
const rows = await db
|
||||
.select({ name: waterIrrigators.name, role: waterIrrigators.role })
|
||||
.from(waterIrrigators)
|
||||
.where(eq(waterIrrigators.id, s.userId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
userId: s.userId,
|
||||
name: row.name,
|
||||
brandId: s.brandId,
|
||||
role: (row.role as "irrigator" | "water_admin") ?? "irrigator",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Cookie/language helpers ───────────────────────────────────────────────
|
||||
|
||||
export async function logoutWater(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_session")?.value;
|
||||
if (sessionId) {
|
||||
// Best-effort DB cleanup
|
||||
try {
|
||||
await withPlatformAdmin(async (db) => {
|
||||
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
|
||||
});
|
||||
} catch {
|
||||
// ignore — the cookie is being deleted anyway
|
||||
}
|
||||
}
|
||||
cookieStore.delete("wl_session");
|
||||
}
|
||||
|
||||
export async function logoutWaterAdmin(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||
if (sessionId) {
|
||||
try {
|
||||
await withPlatformAdmin(async (db) => {
|
||||
await db
|
||||
.delete(waterAdminSessions)
|
||||
.where(eq(waterAdminSessions.id, sessionId));
|
||||
});
|
||||
} catch {
|
||||
// ignore — cookie is being deleted anyway
|
||||
}
|
||||
}
|
||||
cookieStore.delete("wl_admin_session");
|
||||
}
|
||||
|
||||
@@ -91,7 +377,49 @@ export async function getWaterSession(): Promise<string | null> {
|
||||
return cookieStore.get("wl_session")?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current `/water/admin` session.
|
||||
*
|
||||
* Returns the admin role + brandId on success, or `null` if the cookie
|
||||
* is missing / expired / points at a deleted session row. Used by
|
||||
* admin pages (entries/[id], headgates/[id], users/[id]) as a *second*
|
||||
* gate on top of `getAdminUser()` — a site admin can edit entries
|
||||
* directly, but a PIN-authenticated water admin can too.
|
||||
*/
|
||||
export async function getWaterAdminSession(): Promise<{
|
||||
sessionId: string;
|
||||
brandId: string;
|
||||
adminUserId: string;
|
||||
role: "water_admin";
|
||||
} | null> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||
if (!sessionId) return null;
|
||||
return withPlatformAdmin(async (db) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: waterAdminSessions.id,
|
||||
brandId: waterAdminSessions.brandId,
|
||||
adminUserId: waterAdminSessions.adminUserId,
|
||||
expiresAt: waterAdminSessions.expiresAt,
|
||||
})
|
||||
.from(waterAdminSessions)
|
||||
.where(eq(waterAdminSessions.id, sessionId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
if (row.expiresAt.getTime() <= Date.now()) return null;
|
||||
return {
|
||||
sessionId: row.id,
|
||||
brandId: row.brandId,
|
||||
adminUserId: row.adminUserId,
|
||||
role: "water_admin" as const,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function setWaterLang(lang: string): Promise<void> {
|
||||
if (!["en", "es"].includes(lang)) return;
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wl_lang", lang, {
|
||||
httpOnly: false,
|
||||
@@ -102,15 +430,24 @@ export async function setWaterLang(lang: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWaterAdminSession(): Promise<{
|
||||
user_id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
} | null> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||
|
||||
if (!sessionId) return null;
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* If the measurement is in CFS (cubic feet per second) and we know it
|
||||
* represents a set duration, we can derive total gallons. Without
|
||||
* duration, we leave total_gallons null to avoid making up numbers.
|
||||
*
|
||||
* CFS × 60s × 7.48052 = gallons per minute.
|
||||
* 1 CFS for 1 hour = ~448.83 gallons.
|
||||
*/
|
||||
function computeTotalGallons(measurement: number, unit: string): number | null {
|
||||
if (unit !== "CFS") return null;
|
||||
// We don't have a duration field on the entry, so we return null. The
|
||||
// admin can edit an entry to set gallons manually if they have that
|
||||
// data. The hook is here so callers can extend easily.
|
||||
return null;
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
@@ -1,44 +1,244 @@
|
||||
/**
|
||||
* Water Log — admin settings (PIN-protected /water/admin portal).
|
||||
*
|
||||
* The `/water/admin` portal is gated by its own 4-digit PIN (separate
|
||||
* from the irrigators' PIN). That PIN is *hashed* and stored in
|
||||
* `water_admin_settings` (one row per brand). Sessions are tracked in
|
||||
* `water_admin_sessions`.
|
||||
*
|
||||
* There is currently no per-admin-user PIN — a single brand-wide admin
|
||||
* PIN. If you need per-user PINs (e.g. for an audit trail of which
|
||||
* admin entered the portal), swap `pin_hash` for `admin_user_pins` and
|
||||
* key sessions by `admin_user_id`.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withBrand } from "@/db/client";
|
||||
import {
|
||||
waterAdminSettings,
|
||||
waterAdminSessions,
|
||||
type WaterAdminSettings,
|
||||
} from "@/db/schema/water-log";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
|
||||
import { logAuditEvent } from "@/lib/water-log-audit";
|
||||
|
||||
// TODO(migration): the water-log settings RPCs (`get_water_admin_settings`,
|
||||
// `hash_water_admin_pin`, `save_water_admin_settings`,
|
||||
// `verify_water_admin_pin`) and the underlying
|
||||
// `water_admin_settings` table are not in the SaaS rebuild schema.
|
||||
// The functions below preserve the original signatures and return
|
||||
// empty / no-op responses. Same pattern as
|
||||
// `actions/route-trace/lots.ts`.
|
||||
|
||||
export type WaterAdminSettings = {
|
||||
export type AdminSettings = {
|
||||
enabled: boolean;
|
||||
session_duration_hours: number;
|
||||
can_edit_entries: boolean;
|
||||
can_delete_entries: boolean;
|
||||
can_export_csv: boolean;
|
||||
alert_phone?: string | null;
|
||||
alerts_enabled?: boolean;
|
||||
sessionDurationHours: number;
|
||||
canEditEntries: boolean;
|
||||
canDeleteEntries: boolean;
|
||||
canExportCsv: boolean;
|
||||
alertPhone: string | null;
|
||||
alertsEnabled: boolean;
|
||||
/** The currently configured PIN, in plain text. Only returned on
|
||||
* read right after the admin regenerates it; otherwise null. */
|
||||
pin: string | null;
|
||||
};
|
||||
|
||||
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
|
||||
function mapSettings(s: WaterAdminSettings): AdminSettings {
|
||||
return {
|
||||
enabled: s.enabled,
|
||||
sessionDurationHours: s.sessionDurationHours,
|
||||
canEditEntries: s.canEditEntries,
|
||||
canDeleteEntries: s.canDeleteEntries,
|
||||
canExportCsv: s.canExportCsv,
|
||||
alertPhone: s.alertPhone,
|
||||
alertsEnabled: s.alertsEnabled,
|
||||
pin: null, // never returned unless just regenerated
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWaterAdminSettings(_brandId: string): Promise<WaterAdminSettings | null> {
|
||||
return null;
|
||||
export async function getWaterAdminSettings(
|
||||
brandId: string,
|
||||
): Promise<AdminSettings | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
if (
|
||||
!adminUser.can_manage_water_log &&
|
||||
adminUser.role !== "platform_admin"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return withBrand(brandId, async (db) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterAdminSettings)
|
||||
.where(eq(waterAdminSettings.brandId, brandId))
|
||||
.limit(1);
|
||||
if (!rows[0]) {
|
||||
// Lazy-initialize default settings the first time the page is hit
|
||||
const [created] = await db
|
||||
.insert(waterAdminSettings)
|
||||
.values({ brandId })
|
||||
.returning();
|
||||
if (!created) return null;
|
||||
return { ...mapSettings(created), pin: null };
|
||||
}
|
||||
return mapSettings(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveWaterAdminSettings(
|
||||
_brandId: string,
|
||||
_settings: Partial<WaterAdminSettings & { pin?: string }>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
brandId: string,
|
||||
settings: Partial<AdminSettings>,
|
||||
): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
|
||||
return { success: false, error: NOT_CONFIGURED };
|
||||
if (
|
||||
!adminUser.can_manage_water_log &&
|
||||
adminUser.role !== "platform_admin"
|
||||
) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
if (
|
||||
settings.sessionDurationHours != null &&
|
||||
(settings.sessionDurationHours < 1 || settings.sessionDurationHours > 168)
|
||||
) {
|
||||
return { success: false, error: "Session duration must be 1–168 hours" };
|
||||
}
|
||||
|
||||
return withBrand(brandId, async (db) => {
|
||||
const update: Partial<WaterAdminSettings> = {};
|
||||
if (settings.enabled != null) update.enabled = settings.enabled;
|
||||
if (settings.sessionDurationHours != null)
|
||||
update.sessionDurationHours = settings.sessionDurationHours;
|
||||
if (settings.canEditEntries != null)
|
||||
update.canEditEntries = settings.canEditEntries;
|
||||
if (settings.canDeleteEntries != null)
|
||||
update.canDeleteEntries = settings.canDeleteEntries;
|
||||
if (settings.canExportCsv != null)
|
||||
update.canExportCsv = settings.canExportCsv;
|
||||
if (settings.alertPhone !== undefined)
|
||||
update.alertPhone = settings.alertPhone;
|
||||
if (settings.alertsEnabled != null)
|
||||
update.alertsEnabled = settings.alertsEnabled;
|
||||
update.updatedBy = adminUser.user_id ?? adminUser.id ?? null;
|
||||
|
||||
// Upsert: insert if missing, update if present.
|
||||
const updated = await db
|
||||
.insert(waterAdminSettings)
|
||||
.values({ brandId, ...update })
|
||||
.onConflictDoUpdate({
|
||||
target: waterAdminSettings.brandId,
|
||||
set: update,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await logAuditEvent({
|
||||
brandId,
|
||||
actorId: adminUser.user_id ?? null,
|
||||
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
|
||||
action: "update",
|
||||
entityType: "settings",
|
||||
details: { ...update },
|
||||
});
|
||||
return { success: true, settings: mapSettings(updated[0]) };
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyWaterAdminPin(
|
||||
_brandId: string,
|
||||
_pin: string
|
||||
): Promise<{ success: boolean; session_id?: string; error?: string }> {
|
||||
return { success: false, error: NOT_CONFIGURED };
|
||||
/**
|
||||
* Generate a fresh PIN, hash it, and save it on the brand. Returns the
|
||||
* plaintext PIN *once* — caller is responsible for showing it to the
|
||||
* admin and never persisting it.
|
||||
*/
|
||||
export async function regenerateAdminPin(
|
||||
brandId: string,
|
||||
): Promise<{ success: boolean; pin?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
!adminUser.can_manage_water_log &&
|
||||
adminUser.role !== "platform_admin"
|
||||
) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
const pin = generatePin();
|
||||
const pinHash = hashPin(pin);
|
||||
return withBrand(brandId, async (db) => {
|
||||
await db
|
||||
.insert(waterAdminSettings)
|
||||
.values({ brandId, pinHash })
|
||||
.onConflictDoUpdate({
|
||||
target: waterAdminSettings.brandId,
|
||||
set: { pinHash, updatedBy: adminUser.user_id ?? adminUser.id ?? null },
|
||||
});
|
||||
// Invalidate any existing admin sessions for safety
|
||||
await db
|
||||
.delete(waterAdminSessions)
|
||||
.where(eq(waterAdminSessions.brandId, brandId));
|
||||
await logAuditEvent({
|
||||
brandId,
|
||||
actorId: adminUser.user_id ?? null,
|
||||
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
|
||||
action: "regenerate_admin_pin",
|
||||
entityType: "settings",
|
||||
});
|
||||
return { success: true, pin };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PIN verify for the `/water/admin` portal. Creates a session row and
|
||||
* sets the `wl_admin_session` cookie.
|
||||
*/
|
||||
export async function verifyWaterAdminPin(
|
||||
brandId: string,
|
||||
pin: string,
|
||||
): Promise<{ success: boolean; session_id?: string; error?: string }> {
|
||||
const formatError = validatePin(pin);
|
||||
if (formatError) return { success: false, error: formatError };
|
||||
|
||||
return withBrand(brandId, async (db) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterAdminSettings)
|
||||
.where(eq(waterAdminSettings.brandId, brandId))
|
||||
.limit(1);
|
||||
const settings = rows[0];
|
||||
if (!settings) {
|
||||
return { success: false, error: "Admin portal not configured" };
|
||||
}
|
||||
if (!settings.enabled) {
|
||||
return { success: false, error: "Admin portal is disabled" };
|
||||
}
|
||||
if (!settings.pinHash) {
|
||||
return { success: false, error: "No PIN configured — generate one in /admin/water-log/settings" };
|
||||
}
|
||||
if (!verifyPin(pin, settings.pinHash)) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return { success: false, error: "Invalid PIN" };
|
||||
}
|
||||
|
||||
// Tie the session to the calling site admin (best-effort).
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
const expiresAt = new Date(
|
||||
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
|
||||
);
|
||||
const [session] = await db
|
||||
.insert(waterAdminSessions)
|
||||
.values({
|
||||
brandId,
|
||||
adminUserId: adminUser?.user_id ?? adminUser?.id ?? "00000000-0000-0000-0000-000000000000",
|
||||
pinHashUsed: settings.pinHash,
|
||||
expiresAt,
|
||||
})
|
||||
.returning({ id: waterAdminSessions.id });
|
||||
if (!session) return { success: false, error: "Failed to create session" };
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wl_admin_session", session.id, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: settings.sessionDurationHours * 3600,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return { success: true, session_id: session.id };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import CommandCenterDashboard from "@/components/admin/CommandCenterDashboard";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function CommandCenterPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (adminUser.role !== "platform_admin") return <AdminAccessDenied message="Platform admin access required." />;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black px-4 py-8">
|
||||
<div className="mx-auto max-w-[1600px]">
|
||||
<CommandCenterDashboard />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import CommunicationsLoading from "@/components/admin/CommunicationsLoading";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return <CommunicationsLoading activeTab="campaigns" />;
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -8,6 +8,11 @@ import { getSession } from "@/lib/auth";
|
||||
import "@/styles/admin-design-system.css";
|
||||
import { ToastProvider } from "@/components/admin/Toast";
|
||||
import { ToastContainer } from "@/components/admin/ToastContainer";
|
||||
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
|
||||
import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer";
|
||||
import CommandPalette from "@/components/admin/CommandPalette";
|
||||
import PWAInstallPrompt from "@/components/pwa/InstallPrompt";
|
||||
import { getEnabledAddons } from "@/actions/billing/stripe-portal";
|
||||
|
||||
// Admin layout calls getAdminUser() which reads cookies(). Without this,
|
||||
// Next.js tries to prerender the entire /admin/* tree statically and the
|
||||
@@ -97,6 +102,17 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
console.error("[admin/layout] listBrandsForAdmin failed:", err);
|
||||
}
|
||||
|
||||
// Fetch enabled add-ons for the active brand. Used to gate Water Log /
|
||||
// Route Trace visibility in the sidebar. Empty object = all show.
|
||||
let enabledAddons: Record<string, boolean> = {};
|
||||
if (activeBrandId) {
|
||||
try {
|
||||
enabledAddons = await getEnabledAddons(activeBrandId);
|
||||
} catch (err) {
|
||||
console.error("[admin/layout] getEnabledAddons failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastProviderWrapper>
|
||||
<AdminSidebar
|
||||
@@ -104,10 +120,23 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
brandIds={adminUser.brand_ids}
|
||||
activeBrandId={activeBrandId}
|
||||
brands={brands}
|
||||
enabledAddons={enabledAddons}
|
||||
/>
|
||||
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
{children}
|
||||
{/* Cmd+K command palette — mounted globally so any admin page can summon it */}
|
||||
<CommandPalette />
|
||||
{/* The main content area swaps on every navigation. Wrapping
|
||||
it in <SmoothViewTransition> opts the swap into a soft
|
||||
crossfade via the View Transitions API — sidebar and
|
||||
background stay mounted, only the body fades. */}
|
||||
<div
|
||||
id="page-content"
|
||||
className="min-h-screen lg:pl-60 admin-section outline-none"
|
||||
style={{ backgroundColor: "var(--admin-bg)" }}
|
||||
>
|
||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||
<RouteAnnouncer />
|
||||
</div>
|
||||
<PWAInstallPrompt />
|
||||
</ToastProviderWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,14 @@
|
||||
import LoadingSkeleton, { SkeletonCard, SkeletonTable } from "@/components/shared/LoadingSkeleton";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/**
|
||||
* Admin shell loading state.
|
||||
*
|
||||
* Replaces the previous skeleton (header + 4 stat cards + 2 panels +
|
||||
* 5-row table) with a 1px-thick animated bar at the top of the page.
|
||||
* The AdminSidebar and parchment background stay mounted, so the
|
||||
* transition is a subtle fade — the user never sees an "empty" version
|
||||
* of the admin while the next page streams in.
|
||||
*/
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<main className="min-h-screen px-4 sm:px-6 md:px-8 py-8" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<LoadingSkeleton variant="text" width="w-48" height="h-8" />
|
||||
<LoadingSkeleton variant="text" width="w-64" height="h-4" />
|
||||
</div>
|
||||
<LoadingSkeleton variant="button" />
|
||||
</div>
|
||||
|
||||
{/* Stats cards skeleton */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl border bg-white p-5"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-pulse rounded-xl h-10 w-10" style={{ backgroundColor: "var(--admin-bg)" }} />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="animate-pulse h-3 w-16 rounded" style={{ backgroundColor: "var(--admin-bg)" }} />
|
||||
<div className="animate-pulse h-5 w-12 rounded" style={{ backgroundColor: "var(--admin-bg)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content cards skeleton */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<SkeletonTable rows={8} cols={4} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import OrderEditForm from "@/components/admin/OrderEditForm";
|
||||
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
|
||||
import OrderPickupAction from "@/components/admin/OrderPickupAction";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import AdminBadge from "@/components/admin/design-system/AdminBadge";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
@@ -34,16 +36,31 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<main
|
||||
className="min-h-screen px-6 py-10"
|
||||
style={{ backgroundColor: "var(--admin-bg)" }}
|
||||
>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="inline-flex items-center gap-2 text-sm text-stone-500 hover:text-stone-700"
|
||||
className="inline-flex items-center gap-2 text-sm transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
← Back to Orders
|
||||
</Link>
|
||||
<div className="mt-8 rounded-2xl border border-red-200 bg-red-50 p-8 text-center">
|
||||
<p className="text-lg font-semibold text-red-700">Order not found</p>
|
||||
<div
|
||||
className="mt-8 rounded-2xl border p-8 text-center"
|
||||
style={{
|
||||
borderColor: "var(--admin-danger-soft)",
|
||||
backgroundColor: "var(--admin-danger-soft)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--admin-danger)" }}
|
||||
>
|
||||
Order not found
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -67,46 +84,66 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
const total = subtotal + taxAmount - discount_amount;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-8">
|
||||
<main
|
||||
className="min-h-screen px-6 py-8"
|
||||
style={{ backgroundColor: "var(--admin-bg)" }}
|
||||
>
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="inline-flex items-center gap-2 text-xs font-semibold transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Orders
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 bg-white text-stone-500 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Order Details</h1>
|
||||
<p className="text-sm text-stone-500">{formatDate(order.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-bold ${
|
||||
order.pickup_complete
|
||||
? "bg-green-50 text-green-700 border border-green-200"
|
||||
: "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
}`}>
|
||||
{order.pickup_complete ? "✓ Picked Up" : "⏳ Pending"}
|
||||
</span>
|
||||
{order.payment_processor && (
|
||||
<span className="rounded-full bg-violet-50 px-2.5 py-0.5 text-xs font-semibold text-violet-700 border border-violet-200">
|
||||
{order.payment_processor}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="ha-eyebrow mb-2">
|
||||
Order #{order.id.slice(0, 8).toUpperCase()} · {formatDate(order.created_at)}
|
||||
</p>
|
||||
<PageHeader
|
||||
title={order.customer_name}
|
||||
subtitle={`${formatCurrency(total)} · ${order.pickup_complete ? "Picked Up" : "Awaiting Pickup"}${order.stops ? ` · ${order.stops.city}, ${order.stops.state}` : ""}`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminBadge tone={order.pickup_complete ? "success" : "warning"} dot>
|
||||
{order.pickup_complete ? "Picked Up" : "Pending"}
|
||||
</AdminBadge>
|
||||
{order.payment_processor && (
|
||||
<AdminBadge tone="info">{order.payment_processor}</AdminBadge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Customer info */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400">Customer</p>
|
||||
<h2 className="mt-1 text-2xl font-bold text-stone-950">{order.customer_name}</h2>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Customer
|
||||
</p>
|
||||
<h2
|
||||
className="mt-1 text-2xl font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.customer_name}
|
||||
</h2>
|
||||
</div>
|
||||
<OrderPickupAction
|
||||
orderId={order.id}
|
||||
@@ -118,14 +155,34 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
<div className="mt-5 grid grid-cols-2 gap-4">
|
||||
{order.customer_phone && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-stone-400">Phone</p>
|
||||
<p className="mt-0.5 text-sm font-medium text-stone-700">{order.customer_phone}</p>
|
||||
<p
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Phone
|
||||
</p>
|
||||
<p
|
||||
className="mt-0.5 text-sm font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.customer_phone}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{order.customer_email && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-stone-400">Email</p>
|
||||
<p className="mt-0.5 text-sm font-medium text-stone-700">{order.customer_email}</p>
|
||||
<p
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Email
|
||||
</p>
|
||||
<p
|
||||
className="mt-0.5 text-sm font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.customer_email}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -133,64 +190,133 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
|
||||
{/* Stop info */}
|
||||
{order.stops && (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400">Pickup Location</p>
|
||||
<p className="mt-1 text-lg font-bold text-stone-950">
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Pickup Location
|
||||
</p>
|
||||
<p
|
||||
className="mt-1 text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.stops.city}, {order.stops.state}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-stone-500">{formatDate(order.stops.date)}</p>
|
||||
<p
|
||||
className="mt-0.5 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
{formatDate(order.stops.date)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order items */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-stone-950">Order Items</h3>
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
Order Items
|
||||
</h3>
|
||||
|
||||
{order.order_items && order.order_items.length > 0 ? (
|
||||
<div className="mt-4 divide-y divide-stone-100">
|
||||
<div
|
||||
className="mt-4 divide-y"
|
||||
style={{ borderColor: "var(--admin-border-light)" }}
|
||||
>
|
||||
{order.order_items.map((item: OrderItem) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-stone-900">
|
||||
<p
|
||||
className="font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{item.products?.name ?? "Unknown Product"}
|
||||
</p>
|
||||
<p className="text-xs text-stone-400">Qty: {item.quantity} × {formatCurrency(Number(item.price))}</p>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Qty: {item.quantity} × {formatCurrency(Number(item.price))}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-semibold text-stone-900">
|
||||
<p
|
||||
className="font-semibold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{formatCurrency(Number(item.price) * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-stone-400">No items found</p>
|
||||
<p
|
||||
className="mt-4 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
No items found
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="mt-6 border-t border-stone-100 pt-6 space-y-2">
|
||||
<div
|
||||
className="mt-6 border-t pt-6 space-y-2"
|
||||
style={{ borderColor: "var(--admin-border-light)" }}
|
||||
>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Subtotal</span>
|
||||
<span className="text-stone-900">{formatCurrency(subtotal)}</span>
|
||||
<span style={{ color: "var(--admin-text-muted)" }}>Subtotal</span>
|
||||
<span style={{ color: "var(--admin-text-primary)" }}>
|
||||
{formatCurrency(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
{taxAmount > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Tax</span>
|
||||
<span className="text-stone-900">{formatCurrency(taxAmount)}</span>
|
||||
<span style={{ color: "var(--admin-text-muted)" }}>Tax</span>
|
||||
<span style={{ color: "var(--admin-text-primary)" }}>
|
||||
{formatCurrency(taxAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{discount_amount > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Discount</span>
|
||||
<span className="text-red-600">-{formatCurrency(discount_amount)}</span>
|
||||
<span style={{ color: "var(--admin-text-muted)" }}>Discount</span>
|
||||
<span style={{ color: "var(--admin-danger)" }}>
|
||||
-{formatCurrency(discount_amount)}
|
||||
</span>
|
||||
{order.discount_reason && (
|
||||
<span className="text-xs text-stone-400 ml-2">({order.discount_reason})</span>
|
||||
<span
|
||||
className="text-xs ml-2"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
({order.discount_reason})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-lg font-bold text-stone-950 pt-2 border-t border-stone-200">
|
||||
<div
|
||||
className="flex justify-between text-lg font-bold pt-2 border-t"
|
||||
style={{
|
||||
color: "var(--admin-text-primary)",
|
||||
borderColor: "var(--admin-border)",
|
||||
}}
|
||||
>
|
||||
<span>Total</span>
|
||||
<span>{formatCurrency(total)}</span>
|
||||
</div>
|
||||
@@ -198,9 +324,25 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
</div>
|
||||
|
||||
{/* Payment & Refunds */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-stone-950">Payment & Refunds</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">Record payment details and manage refunds</p>
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
Payment & Refunds
|
||||
</h3>
|
||||
<p
|
||||
className="mt-1 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Record payment details and manage refunds
|
||||
</p>
|
||||
|
||||
<div className="mt-5">
|
||||
<OrderPaymentSection
|
||||
@@ -218,9 +360,25 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
</div>
|
||||
|
||||
{/* Edit form */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-stone-950">Edit Order</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">Update customer details, pricing, and status</p>
|
||||
<div
|
||||
className="rounded-2xl border p-6 shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
Edit Order
|
||||
</h3>
|
||||
<p
|
||||
className="mt-1 text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Update customer details, pricing, and status
|
||||
</p>
|
||||
|
||||
<div className="mt-5">
|
||||
<OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} />
|
||||
@@ -229,9 +387,25 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
|
||||
{/* Internal notes */}
|
||||
{order.internal_notes && (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-amber-600">Internal Notes</p>
|
||||
<p className="mt-1 text-sm text-stone-700">{order.internal_notes}</p>
|
||||
<div
|
||||
className="rounded-2xl border p-6"
|
||||
style={{
|
||||
borderColor: "var(--admin-warning-soft)",
|
||||
backgroundColor: "var(--admin-warning-soft)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--admin-warning)" }}
|
||||
>
|
||||
Internal Notes
|
||||
</p>
|
||||
<p
|
||||
className="mt-1 text-sm"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.internal_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -65,11 +65,12 @@ export default async function AdminOrdersPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<p className="ha-eyebrow mb-2">Operations</p>
|
||||
<PageHeader
|
||||
title="Orders"
|
||||
subtitle="Manage customer orders and pickup status"
|
||||
subtitle="View, filter, and manage customer orders."
|
||||
icon={
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
|
||||
@@ -82,7 +83,13 @@ export default async function AdminOrdersPage() {
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div
|
||||
className="rounded-2xl border overflow-hidden"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<AdminOrdersPanel
|
||||
initialOrders={brandOrders}
|
||||
initialStops={brandStops}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { getDashboardStats } from "@/actions/dashboard";
|
||||
import DashboardClient from "@/components/admin/DashboardClient";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
@@ -122,6 +123,10 @@ export default async function AdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch dashboard stats server-side to eliminate client-side waterfall.
|
||||
// Stats are now available immediately — no "—" placeholder flash.
|
||||
const stats = await getDashboardStats();
|
||||
|
||||
return (
|
||||
<DashboardClient
|
||||
brandId={adminUser?.brand_id ?? null}
|
||||
@@ -131,6 +136,7 @@ export default async function AdminPage() {
|
||||
enabledAddons={enabledAddons}
|
||||
usage={usage}
|
||||
limits={limits}
|
||||
stats={stats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ProductEditForm from "@/components/admin/ProductEditForm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { PageHeader, AdminBadge } from "@/components/admin/design-system";
|
||||
import { Package as PackageIcon } from "lucide-react";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
@@ -51,15 +53,15 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-600">Product not found</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white p-4 text-sm text-stone-600">
|
||||
<h1 className="text-3xl font-bold text-[var(--admin-danger)]">Product not found</h1>
|
||||
<pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
|
||||
{error?.message ?? "Product not found"}
|
||||
</pre>
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
||||
className="mt-4 inline-block text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]"
|
||||
>
|
||||
← Back to Products
|
||||
</Link>
|
||||
@@ -69,58 +71,66 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</Link>
|
||||
<div className="ha-eyebrow mb-2">Operations</div>
|
||||
<PageHeader
|
||||
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
|
||||
title="Edit product"
|
||||
subtitle="Update product details, pricing, and availability."
|
||||
actions={
|
||||
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</AdminBadge>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
>
|
||||
← Back to Products
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
|
||||
{product.brands?.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-stone-950">
|
||||
<h1 className="mt-2 text-3xl font-bold text-[var(--admin-text-primary)]">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-stone-600">
|
||||
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">
|
||||
{product.description}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
|
||||
product.active
|
||||
? "bg-emerald-100 text-emerald-600"
|
||||
: "bg-stone-200 text-stone-500"
|
||||
}`}
|
||||
>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Price</p>
|
||||
<p className="mt-1 text-2xl font-bold text-stone-950">
|
||||
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Price</p>
|
||||
<p
|
||||
className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
${Number(product.price).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Type</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Type</p>
|
||||
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
||||
{product.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Edit Product</h2>
|
||||
<p className="mt-1 text-stone-500">
|
||||
<div className="mt-6 rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
|
||||
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Product</h2>
|
||||
<p className="mt-1 text-[var(--admin-text-muted)]">
|
||||
Update product details, pricing, and availability.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import NewProductForm from "@/components/admin/NewProductForm";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
import { Package as PackageIcon } from "lucide-react";
|
||||
import { getBrands } from "@/actions/admin/users";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
@@ -19,28 +21,25 @@ export default async function NewProductPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<div className="ha-eyebrow mb-2">Operations</div>
|
||||
<PageHeader
|
||||
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
|
||||
title="New product"
|
||||
subtitle="Add a product to your catalog with pricing, type, and availability."
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
>
|
||||
← Back to Products
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<h1 className="text-3xl font-bold text-stone-950">
|
||||
Create Product
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-stone-500">
|
||||
{isPlatformAdmin
|
||||
? "Add a new product to any brand you administer."
|
||||
: "Add a new product to your brand's catalog."}
|
||||
</p>
|
||||
|
||||
<div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
|
||||
<NewProductForm
|
||||
defaultBrandId={adminUser.brand_id ?? ""}
|
||||
brands={brands}
|
||||
|
||||
@@ -7,16 +7,6 @@ import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import ProductsClient from "@/components/admin/ProductsClient";
|
||||
import { getBrands } from "@/actions/admin/users";
|
||||
|
||||
// Icon for page header
|
||||
const PackageIcon = () => (
|
||||
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m7.5 4.27 9 5.15"/>
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
|
||||
<path d="m3.3 7 8.7 5 8.7-5"/>
|
||||
<path d="M12 22V12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Shape ProductsClient expects (legacy columns mapped from the new schema).
|
||||
type ProductRow = {
|
||||
id: string;
|
||||
@@ -41,8 +31,8 @@ export default async function AdminProductsPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Access Denied</h1>
|
||||
<p className="mt-2 text-sm text-stone-500">You do not have permission to manage products.</p>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-danger)]">Access Denied</h1>
|
||||
<p className="mt-2 text-sm text-[var(--admin-text-muted)]">You do not have permission to manage products.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
@@ -133,8 +123,8 @@ export default async function AdminProductsPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Error loading products</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-stone-600">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-danger)]">Error loading products</h1>
|
||||
<pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
|
||||
{queryError}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,25 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getShippingOrders } from "@/actions/shipping";
|
||||
import ShippingFulfillmentPanel from "@/components/admin/ShippingFulfillmentPanel";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TruckIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 sm:h-6 sm:w-6 text-current"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 17h14M5 17a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM19 17a2 2 0 1 0 4 0 2 2 0 0 0-4 0Z" />
|
||||
<path d="M14 17V5H3v12M14 9h4l3 4v4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default async function ShippingFulfillmentPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
@@ -12,9 +28,22 @@ export default async function ShippingFulfillmentPage() {
|
||||
const { orders } = await getShippingOrders();
|
||||
|
||||
return (
|
||||
<ShippingFulfillmentPanel
|
||||
initialOrders={orders ?? []}
|
||||
canManageOrders={adminUser.can_manage_orders ?? false}
|
||||
/>
|
||||
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<p className="ha-eyebrow mb-2">Operations</p>
|
||||
<PageHeader
|
||||
title="Shipping Fulfillment"
|
||||
subtitle="Create FedEx labels, print packing slips, and track outbound shipments."
|
||||
icon={<TruckIcon />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-6 md:px-8 pb-6 sm:pb-8">
|
||||
<ShippingFulfillmentPanel
|
||||
initialOrders={orders ?? []}
|
||||
canManageOrders={adminUser.can_manage_orders ?? false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { pool } from "@/lib/db";
|
||||
import StopEditForm from "@/components/admin/StopEditForm";
|
||||
import StopProductAssignment from "@/components/admin/StopProductAssignment";
|
||||
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
const StopIcon = () => (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" 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>
|
||||
);
|
||||
|
||||
type StopDetailPageProps = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
@@ -26,7 +34,6 @@ interface Stop {
|
||||
cutoff_time: string | null;
|
||||
status: string;
|
||||
location: string;
|
||||
slug: string;
|
||||
active: boolean;
|
||||
brands?: { name: string; slug: string };
|
||||
}
|
||||
@@ -48,15 +55,6 @@ interface ProductStop {
|
||||
export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const [{ data: stop, error }, { data: brands }] = await Promise.all([
|
||||
supabase
|
||||
.from("stops")
|
||||
.select("*, brands(name, slug)")
|
||||
.eq("id", id)
|
||||
.single() as unknown as { data: Stop | null; error: { message: string } | null },
|
||||
supabase.from("brands").select("id, name, slug") as unknown as { data: { id: string; name: string; slug: string }[] | null },
|
||||
]);
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) {
|
||||
@@ -65,183 +63,258 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
|
||||
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
||||
|
||||
if (adminUser.brand_id && stop?.brand_id !== adminUser.brand_id) {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
// Fetch stop
|
||||
const { rows: stopsRows } = await pool.query<Stop & { brand_name: string; brand_slug: string }>(
|
||||
`SELECT s.*, b.name as brand_name, b.slug as brand_slug
|
||||
FROM stops s
|
||||
LEFT JOIN brands b ON b.id = s.brand_id
|
||||
WHERE s.id = $1
|
||||
LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
const stopRow = stopsRows[0];
|
||||
|
||||
if (error || !stop) {
|
||||
if (!stopRow) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-600">Stop not found</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
|
||||
{error?.message ?? "Stop not found"}
|
||||
</pre>
|
||||
<Link
|
||||
href="/admin/stops"
|
||||
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Stops
|
||||
</Link>
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<p className="ha-eyebrow mb-2">Operations</p>
|
||||
<PageHeader
|
||||
title="Stop not found"
|
||||
subtitle="This stop may have been removed or you don't have access."
|
||||
icon={<StopIcon />}
|
||||
/>
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-5">
|
||||
<pre className="rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border-light)] p-4 text-sm text-[var(--admin-text-secondary)] overflow-auto">
|
||||
{id}
|
||||
</pre>
|
||||
<Link
|
||||
href="/admin/stops"
|
||||
className="ha-btn-ghost mt-4 inline-flex"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Stops
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const [{ data: allProducts }, { data: productStops }] = await Promise.all([
|
||||
supabase
|
||||
.from("products")
|
||||
.select("id, name, type, price")
|
||||
.eq("brand_id", stop.brand_id)
|
||||
.eq("active", true) as unknown as { data: Product[] | null },
|
||||
supabase
|
||||
.from("product_stops")
|
||||
.select("id, product_id, products(id, name, type, price)")
|
||||
.eq("stop_id", id) as unknown as { data: ProductStop[] | null },
|
||||
]);
|
||||
if (adminUser.brand_id && stopRow.brand_id !== adminUser.brand_id) {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
|
||||
const assignedProducts = (productStops ?? [])
|
||||
.map((ps: ProductStop) => ({
|
||||
id: ps.id,
|
||||
product_id: ps.product_id,
|
||||
products: ps.products ? {
|
||||
name: ps.products.name,
|
||||
type: ps.products.type,
|
||||
price: ps.products.price,
|
||||
image_url: ps.products.image_url,
|
||||
} : null,
|
||||
}))
|
||||
.filter(Boolean);
|
||||
const stop: Stop = {
|
||||
id: stopRow.id,
|
||||
brand_id: stopRow.brand_id,
|
||||
city: stopRow.city ?? "",
|
||||
state: stopRow.state ?? "",
|
||||
address: stopRow.address ?? null,
|
||||
zip: stopRow.zip ?? null,
|
||||
date: stopRow.date ? String(stopRow.date) : "",
|
||||
time: stopRow.time ?? "",
|
||||
cutoff_date: stopRow.cutoff_date ? String(stopRow.cutoff_date) : null,
|
||||
cutoff_time: stopRow.cutoff_date ? String(stopRow.cutoff_date) : null,
|
||||
status: stopRow.status ?? "active",
|
||||
location: stopRow.location ?? "",
|
||||
active: stopRow.status === "active",
|
||||
brands: { name: stopRow.brand_name ?? "", slug: stopRow.brand_slug ?? "" },
|
||||
};
|
||||
|
||||
// Fetch all products for this brand
|
||||
const { rows: productRows } = await pool.query<{ id: string; name: string; type: string; price: number; image_url: string | null }>(
|
||||
`SELECT id, name, type, price, image_url
|
||||
FROM products
|
||||
WHERE brand_id = $1 AND active = true
|
||||
ORDER BY name`,
|
||||
[stop.brand_id]
|
||||
);
|
||||
|
||||
// Fetch product-stop assignments
|
||||
const { rows: productStopRows } = await pool.query<{ id: string; product_id: string; name: string; type: string; price: number; image_url: string | null }>(
|
||||
`SELECT ps.id, ps.product_id, p.name, p.type, p.price, p.image_url
|
||||
FROM product_stops ps
|
||||
JOIN products p ON p.id = ps.product_id
|
||||
WHERE ps.stop_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// Fetch brands list
|
||||
const { rows: brandRows } = await pool.query<{ id: string; name: string; slug: string }>(
|
||||
`SELECT id, name, slug FROM brands ORDER BY name`
|
||||
);
|
||||
|
||||
const allProducts = productRows.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
price: Number(p.price),
|
||||
image_url: p.image_url,
|
||||
}));
|
||||
|
||||
const assignedProducts = productStopRows.map((ps) => ({
|
||||
id: ps.id,
|
||||
product_id: ps.product_id,
|
||||
products: {
|
||||
name: ps.name,
|
||||
type: ps.type,
|
||||
price: Number(ps.price),
|
||||
image_url: ps.image_url,
|
||||
},
|
||||
}));
|
||||
|
||||
const detailSubtitle = stop.brands?.name
|
||||
? `${stop.brands.name} · ${stop.location}`
|
||||
: stop.location;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Link
|
||||
href="/admin/stops"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Stops
|
||||
</Link>
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<p className="ha-eyebrow mb-2">Operations</p>
|
||||
<PageHeader
|
||||
title={`${stop.city}, ${stop.state}`}
|
||||
subtitle={detailSubtitle}
|
||||
icon={<StopIcon />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
|
||||
{stop.brands?.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-4xl font-bold text-stone-950">
|
||||
{stop.city}, {stop.state}
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-stone-600">{stop.location}</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-4 py-2 text-sm font-medium ${
|
||||
stop.active
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-stone-200 text-stone-500"
|
||||
}`}
|
||||
>
|
||||
{stop.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Date</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
{stop.date}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Time</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
{stop.time}
|
||||
</p>
|
||||
</div>
|
||||
{stop.address && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Address</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
{stop.address}
|
||||
{stop.zip ? `, ${stop.zip}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{stop.cutoff_time && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Cutoff</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
{new Date(stop.cutoff_time).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<a
|
||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||
className="rounded-xl border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50"
|
||||
<div className="px-4 sm:px-6 md:px-8 pb-8 sm:pb-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Link
|
||||
href="/admin/stops"
|
||||
className="ha-btn-ghost mb-4 inline-flex"
|
||||
>
|
||||
Duplicate Stop
|
||||
</a>
|
||||
</div>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Stops
|
||||
</Link>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
<h2 className="text-2xl font-bold text-stone-950">
|
||||
Assigned Products
|
||||
</h2>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Manage which products are available at this stop.
|
||||
</p>
|
||||
<div className="rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
|
||||
{stop.brands?.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-4xl font-bold text-[var(--admin-text-primary)]">
|
||||
{stop.city}, {stop.state}
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">{stop.location}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<StopProductAssignment
|
||||
stopId={stop.id}
|
||||
allProducts={allProducts ?? []}
|
||||
assignedProducts={assignedProducts}
|
||||
callerUid={adminUser.user_id}
|
||||
/>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-4 py-2 text-sm font-medium ${
|
||||
stop.active
|
||||
? "bg-[var(--admin-success-soft)] text-[var(--admin-success)] border border-[var(--admin-success)]/30"
|
||||
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
|
||||
}`}
|
||||
>
|
||||
{stop.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Date</p>
|
||||
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
||||
{stop.date}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Time</p>
|
||||
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
||||
{stop.time}
|
||||
</p>
|
||||
</div>
|
||||
{stop.address && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Address</p>
|
||||
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
||||
{stop.address}
|
||||
{stop.zip ? `, ${stop.zip}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{stop.cutoff_date && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Cutoff</p>
|
||||
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
||||
{new Date(stop.cutoff_date + "T00:00:00").toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Edit Stop</h2>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Update stop details, location, and availability.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<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 ?? []}
|
||||
/>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<a
|
||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||
className="ha-btn-ghost"
|
||||
>
|
||||
Duplicate Stop
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Customers */}
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Message Customers</h2>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Send updates to customers with pending pickups at this stop.
|
||||
</p>
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
|
||||
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">
|
||||
Assigned Products
|
||||
</h2>
|
||||
<p className="mt-1 text-[var(--admin-text-secondary)]">
|
||||
Manage which products are available at this stop.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
|
||||
<div className="mt-6">
|
||||
<StopProductAssignment
|
||||
stopId={stop.id}
|
||||
allProducts={allProducts}
|
||||
assignedProducts={assignedProducts}
|
||||
callerUid={adminUser.user_id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
|
||||
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Stop</h2>
|
||||
<p className="mt-1 text-[var(--admin-text-secondary)]">
|
||||
Update stop details, location, and availability.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<StopEditForm
|
||||
stop={{
|
||||
id: stop.id,
|
||||
city: stop.city,
|
||||
state: stop.state,
|
||||
date: stop.date,
|
||||
time: stop.time,
|
||||
location: stop.location,
|
||||
slug: stop.brands?.slug ?? "",
|
||||
active: stop.active,
|
||||
brand_id: stop.brand_id,
|
||||
address: stop.address,
|
||||
zip: stop.zip,
|
||||
cutoff_time: stop.cutoff_time,
|
||||
}}
|
||||
brands={brandRows ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Customers */}
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
|
||||
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Message Customers</h2>
|
||||
<p className="mt-1 text-[var(--admin-text-secondary)]">
|
||||
Send updates to customers with pending pickups at this stop.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { pool } from "@/lib/db";
|
||||
import NewStopForm from "@/components/admin/NewStopForm";
|
||||
import StopProductAssignment from "@/components/admin/StopProductAssignment";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
const StopIcon = () => (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" 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>
|
||||
);
|
||||
|
||||
type Stop = {
|
||||
city: string;
|
||||
state: string;
|
||||
@@ -32,49 +40,67 @@ export default async function NewStopPage({
|
||||
|
||||
let duplicateFrom: Stop | null = null;
|
||||
if (duplicate) {
|
||||
const { data } = await supabase
|
||||
.from("stops")
|
||||
.select("city, state, location, date, time, brand_id, active, address, zip, cutoff_time")
|
||||
.eq("id", duplicate)
|
||||
.single() as unknown as { data: Stop | null };
|
||||
duplicateFrom = data;
|
||||
const { rows } = await pool.query<Stop>(
|
||||
`SELECT city, state, location, date, time, brand_id, active, address, zip, cutoff_date
|
||||
FROM stops WHERE id = $1 LIMIT 1`,
|
||||
[duplicate]
|
||||
);
|
||||
const row = rows[0];
|
||||
if (row) {
|
||||
duplicateFrom = {
|
||||
city: row.city ?? "",
|
||||
state: row.state ?? "",
|
||||
location: row.location ?? "",
|
||||
date: row.date ? String(row.date) : "",
|
||||
time: row.time ?? "",
|
||||
brand_id: row.brand_id,
|
||||
active: row.active ?? true,
|
||||
address: row.address ?? null,
|
||||
zip: row.zip ?? null,
|
||||
cutoff_time: row.cutoff_time ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const brandId =
|
||||
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [{ data: allProducts }] = await Promise.all([
|
||||
supabase
|
||||
.from("products")
|
||||
.select("id, name, type, price")
|
||||
.eq("brand_id", brandId)
|
||||
.eq("active", true) as unknown as { data: { id: string; name: string; type: string; price: number }[] | null },
|
||||
]);
|
||||
const { rows: productRows } = await pool.query<{ id: string; name: string; type: string; price: number }>(
|
||||
`SELECT id, name, type, price FROM products WHERE brand_id = $1 AND active = true ORDER BY name`,
|
||||
[brandId]
|
||||
);
|
||||
|
||||
const pageTitle = duplicateFrom ? "Duplicate Stop" : "Create Stop";
|
||||
const pageSubtitle = duplicateFrom
|
||||
? `Pre-filled from ${duplicateFrom.city}, ${duplicateFrom.state}. Edit and save to create.`
|
||||
: "Add a new tour stop for Tuxedo Corn or Indian River Direct.";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<p className="ha-eyebrow mb-2">Operations</p>
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
subtitle={pageSubtitle}
|
||||
icon={<StopIcon />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-6 md:px-8 pb-8 sm:pb-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Link
|
||||
href="/admin/stops"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
className="ha-btn-ghost mb-4 inline-flex"
|
||||
>
|
||||
← Back to Stops
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Stops
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
<h1 className="text-3xl font-bold text-stone-950">
|
||||
{duplicateFrom ? "Duplicate Stop" : "Create Stop"}
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-stone-600">
|
||||
{duplicateFrom
|
||||
? `Pre-filled from ${duplicateFrom.city}, ${duplicateFrom.state}. Edit and save to create.`
|
||||
: "Add a new tour stop for Tuxedo Corn or Indian River Direct."}
|
||||
</p>
|
||||
|
||||
<NewStopForm duplicateFrom={duplicateFrom} />
|
||||
<div className="rounded-2xl bg-white p-8 border border-[var(--admin-border)] shadow-sm">
|
||||
<NewStopForm duplicateFrom={duplicateFrom} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
import StopsDashboardClient from "@/components/admin/stops/StopsDashboardClient";
|
||||
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import StopTableClient from "@/components/admin/StopTableClient";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface Stop {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
active: boolean;
|
||||
deleted_at: string | null;
|
||||
brand_id: string;
|
||||
address: string | null;
|
||||
zip: string | null;
|
||||
cutoff_time: string | null;
|
||||
status: string;
|
||||
brands: { name: string } | { name: string }[];
|
||||
}
|
||||
|
||||
const StopIcon = () => (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" 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"/>
|
||||
@@ -30,85 +13,123 @@ const StopIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default async function AdminStopsPage() {
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminStopsPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
||||
|
||||
if (!adminUser.can_manage_stops) {
|
||||
redirect("/admin/pickup");
|
||||
interface DbStopRow {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: Date | string;
|
||||
time: string | null;
|
||||
location: string;
|
||||
status: string;
|
||||
brand_id: string;
|
||||
address: string | null;
|
||||
zip: string | null;
|
||||
cutoff_date: string | null;
|
||||
brand_name?: string;
|
||||
}
|
||||
let stops: DbStopRow[] = [];
|
||||
let error: string | null = null;
|
||||
|
||||
let query = supabase
|
||||
.from("stops")
|
||||
.select(`
|
||||
id,
|
||||
city,
|
||||
state,
|
||||
date,
|
||||
time,
|
||||
location,
|
||||
active,
|
||||
deleted_at,
|
||||
brand_id,
|
||||
address,
|
||||
zip,
|
||||
cutoff_time,
|
||||
status,
|
||||
brands (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.is("deleted_at", null)
|
||||
.order("date", { ascending: true });
|
||||
// Resolve active brand from cookie/URL (respects platform_admin "All brands" = null)
|
||||
let activeBrandId: string | null = null;
|
||||
try {
|
||||
activeBrandId = await getActiveBrandId(adminUser);
|
||||
|
||||
if (adminUser?.brand_id) {
|
||||
query = query.eq("brand_id", adminUser.brand_id);
|
||||
// If brand-scoped (not platform_admin) and no active brand, restrict query
|
||||
const brandCondition =
|
||||
activeBrandId
|
||||
? `brand_id = '${activeBrandId}'`
|
||||
: adminUser.role === "platform_admin"
|
||||
? "1=1"
|
||||
: `brand_id IN ('${(adminUser.brand_ids ?? []).join("','")}')`;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.id, s.city, s.state, s.date, s."time", s.location, s.status,
|
||||
s.brand_id, s.address, s.zip, s.cutoff_date,
|
||||
b.name as brand_name
|
||||
FROM stops s
|
||||
LEFT JOIN brands b ON b.id = s.brand_id
|
||||
WHERE ${brandCondition}
|
||||
ORDER BY s.date ASC
|
||||
LIMIT 200`
|
||||
);
|
||||
stops = rows;
|
||||
console.log("[admin/stops] Fetched", rows.length, "stops");
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
console.error("[admin/stops] Query error:", error);
|
||||
}
|
||||
|
||||
const { data: stops, error } = await query as unknown as { data: Stop[] | null; error: { message: string } | null };
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<nav className="flex items-center gap-2 text-xs sm:text-sm mb-6">
|
||||
<a href="/admin" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Admin</a>
|
||||
<span className="text-[var(--admin-text-muted)]">/</span>
|
||||
<span className="text-[var(--admin-text-primary)] font-medium">Stops & Routes</span>
|
||||
</nav>
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-red-600 tracking-tight">
|
||||
Error loading stops
|
||||
</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)]">
|
||||
{error.message}
|
||||
</pre>
|
||||
<p className="ha-eyebrow mb-2">Operations</p>
|
||||
<PageHeader
|
||||
title="Stops & Routes"
|
||||
subtitle="Schedule and manage pickup locations and dates."
|
||||
icon={<StopIcon />}
|
||||
/>
|
||||
<div className="rounded-2xl border border-[var(--admin-danger)]/30 bg-[var(--admin-danger-soft)] p-5">
|
||||
<h2 className="text-lg font-semibold text-[var(--admin-danger)]">Error loading stops</h2>
|
||||
<pre className="mt-3 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)] overflow-auto">
|
||||
{error}
|
||||
</pre>
|
||||
<div className="mt-4 p-4 rounded-xl bg-[var(--admin-bg-subtle)] text-sm text-[var(--admin-text-secondary)]">
|
||||
<p className="font-semibold text-[var(--admin-text-primary)]">Debug info:</p>
|
||||
<p>adminUser.brand_id: {adminUser.brand_id ?? "null"}</p>
|
||||
<p>adminUser.role: {adminUser.role}</p>
|
||||
<p>adminUser.email: {adminUser.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Transform stops for the client component
|
||||
const stopsForClient = stops.map((s) => ({
|
||||
id: s.id,
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
date: s.date instanceof Date ? s.date.toISOString().split("T")[0] : String(s.date).split("T")[0],
|
||||
time: s.time || "",
|
||||
location: s.location,
|
||||
active: s.status === "active",
|
||||
brand_id: s.brand_id,
|
||||
status: s.status,
|
||||
address: s.address,
|
||||
zip: s.zip,
|
||||
cutoff_time: s.cutoff_date,
|
||||
brands: s.brand_name ? { name: s.brand_name } : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<p className="ha-eyebrow mb-2">Operations</p>
|
||||
<PageHeader
|
||||
breadcrumb={[
|
||||
{ label: "Admin", href: "/admin" },
|
||||
{ label: "Stops & Routes" }
|
||||
]}
|
||||
icon={<StopIcon />}
|
||||
title="Stops & Routes"
|
||||
subtitle={adminUser?.brand_id ? "Managing stops for your brand." : "Manage routes, pickup locations, dates, and cutoff times."}
|
||||
actions={<StopsHeaderActions brandId={adminUser?.brand_id ?? ""} />}
|
||||
subtitle="Schedule and manage pickup locations and dates."
|
||||
icon={<StopIcon />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
<StopsDashboardClient stops={stops ?? []} />
|
||||
<div className="px-4 sm:px-6 md:px-8 pb-6 sm:pb-8">
|
||||
<StopTableClient stops={stopsForClient} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import AdminShell from "@/components/admin/AdminShell";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { listBrandsForAdmin } from "@/actions/brands";
|
||||
import { getEnabledAddons } from "@/actions/billing/stripe-portal";
|
||||
import { ToastProvider } from "@/components/admin/Toast";
|
||||
import { ToastContainer } from "@/components/admin/ToastContainer";
|
||||
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
|
||||
import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AdminV2Layout({ children }: { children: React.ReactNode }) {
|
||||
let adminUser = null;
|
||||
try {
|
||||
adminUser = await getAdminUser();
|
||||
} catch {
|
||||
return (
|
||||
<ToastProviderWrapper>
|
||||
<AdminAccessDenied message="Failed to verify authentication." />
|
||||
</ToastProviderWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!adminUser) {
|
||||
return (
|
||||
<ToastProviderWrapper>
|
||||
<AdminAccessDenied message="You don't have admin access." />
|
||||
</ToastProviderWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (adminUser.must_change_password) {
|
||||
redirect("/change-password");
|
||||
}
|
||||
|
||||
// Resolve brand + brands + addons with graceful fallbacks. A failure in
|
||||
// any of these must not crash the whole shell — the AdminShell renders
|
||||
// empty state (no brands) rather than a 500.
|
||||
let activeBrandId: string | null = null;
|
||||
try {
|
||||
activeBrandId = await getActiveBrandId(adminUser);
|
||||
} catch (err) {
|
||||
console.error("[admin/v2/layout] getActiveBrandId failed:", err);
|
||||
}
|
||||
|
||||
let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
|
||||
try {
|
||||
brands = await listBrandsForAdmin();
|
||||
} catch (err) {
|
||||
console.error("[admin/v2/layout] listBrandsForAdmin failed:", err);
|
||||
}
|
||||
|
||||
let enabledAddons: Record<string, boolean> = {};
|
||||
if (activeBrandId) {
|
||||
try {
|
||||
enabledAddons = await getEnabledAddons(activeBrandId);
|
||||
} catch (err) {
|
||||
console.error("[admin/v2/layout] getEnabledAddons failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastProviderWrapper>
|
||||
<AdminShell
|
||||
userRole={adminUser.role}
|
||||
brandIds={adminUser.brand_ids}
|
||||
activeBrandId={activeBrandId}
|
||||
brands={brands}
|
||||
enabledAddons={enabledAddons}
|
||||
>
|
||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||
<RouteAnnouncer />
|
||||
</AdminShell>
|
||||
</ToastProviderWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminOrderDetail, type AdminOrder } from "@/actions/orders";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import PageHeader from "@/components/admin/PageHeader";
|
||||
import StatusPill, { type StatusKind } from "@/components/admin/StatusPill";
|
||||
import StickyActionBar from "@/components/admin/StickyActionBar";
|
||||
import OrderActionButtons from "@/components/admin/orders/OrderActionButtons";
|
||||
import FulfillmentTimeline from "@/components/admin/orders/FulfillmentTimeline";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Map the legacy `orders.status` + `pickup_complete` pair onto the
|
||||
* StatusPill's `StatusKind` union. Must match the helper in
|
||||
* `orders/page.tsx` so the list and detail pages agree on the
|
||||
* pill vocabulary. Kept inlined here to avoid a shared-types file
|
||||
* until we know the mapping is stable.
|
||||
*/
|
||||
type OrderStatusKind = "placed" | "ready" | "picked-up" | "cancelled";
|
||||
|
||||
function statusKind(o: { status: string; pickup_complete: boolean }): OrderStatusKind {
|
||||
if (o.status === "canceled") return "cancelled";
|
||||
if (o.pickup_complete || o.status === "fulfilled") return "picked-up";
|
||||
if (o.status === "confirmed") return "ready";
|
||||
return "placed";
|
||||
}
|
||||
|
||||
export default async function OrderDetailV2Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const order = await getAdminOrderDetail(id);
|
||||
if (!order) notFound();
|
||||
|
||||
const kind = statusKind(order);
|
||||
const subtotal = Number(order.subtotal ?? 0);
|
||||
const taxAmount = Number(order.tax_amount ?? 0);
|
||||
const discountAmount = Number(order.discount_amount ?? 0);
|
||||
const total = subtotal + taxAmount - discountAmount;
|
||||
const itemCount = order.order_items?.length ?? 0;
|
||||
|
||||
return (
|
||||
<main className="pb-40">
|
||||
<PageHeader
|
||||
title={`Order #${order.id.slice(0, 8).toUpperCase()}`}
|
||||
subtitle={formatDate(order.created_at)}
|
||||
actions={<StatusPill status={kind as StatusKind} />}
|
||||
/>
|
||||
<div className="px-4 space-y-4">
|
||||
<section
|
||||
className="rounded-2xl p-4"
|
||||
style={{ backgroundColor: "var(--color-surface-2)" }}
|
||||
>
|
||||
<h2
|
||||
className="text-h1 font-display"
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
{order.customer_name}
|
||||
</h2>
|
||||
<div className="mt-2 space-y-1">
|
||||
{order.customer_phone && (
|
||||
<a
|
||||
href={`tel:${order.customer_phone}`}
|
||||
className="block text-body"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
{order.customer_phone}
|
||||
</a>
|
||||
)}
|
||||
{order.customer_email && (
|
||||
<a
|
||||
href={`mailto:${order.customer_email}`}
|
||||
className="block text-body"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
{order.customer_email}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{order.stops && (
|
||||
<section
|
||||
className="rounded-2xl p-4"
|
||||
style={{ backgroundColor: "var(--color-surface-2)" }}
|
||||
>
|
||||
<h3
|
||||
className="text-label font-semibold mb-1"
|
||||
style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}
|
||||
>
|
||||
PICKUP
|
||||
</h3>
|
||||
<div className="text-h2" style={{ fontWeight: 700, lineHeight: 1.3 }}>
|
||||
{order.stops.city}, {order.stops.state}
|
||||
</div>
|
||||
{order.stops.date && (
|
||||
<div
|
||||
className="text-meta mt-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{formatDate(order.stops.date)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<FulfillmentTimeline
|
||||
status={kind}
|
||||
placedAt={order.created_at}
|
||||
pickedUpAt={order.pickup_completed_at}
|
||||
/>
|
||||
|
||||
<section
|
||||
className="rounded-2xl p-4"
|
||||
style={{ backgroundColor: "var(--color-surface-2)" }}
|
||||
>
|
||||
<h3
|
||||
className="text-label font-semibold mb-3"
|
||||
style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}
|
||||
>
|
||||
ITEMS ({itemCount})
|
||||
</h3>
|
||||
{itemCount > 0 ? (
|
||||
<ul className="space-y-3">
|
||||
{order.order_items?.map((item: NonNullable<AdminOrder["order_items"]>[number]) => {
|
||||
const unitPrice = Number(item.price ?? 0);
|
||||
const lineTotal = unitPrice * item.quantity;
|
||||
return (
|
||||
<li key={item.id} className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-h2" style={{ fontWeight: 700 }}>
|
||||
{item.products?.name ?? "Unknown product"}
|
||||
</div>
|
||||
<div
|
||||
className="text-meta"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{item.quantity} × ${unitPrice.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-body font-semibold">
|
||||
${lineTotal.toFixed(2)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div
|
||||
className="text-body"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
No items
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="mt-4 pt-3 border-t flex items-center justify-between"
|
||||
style={{ borderColor: "var(--color-surface-3)" }}
|
||||
>
|
||||
<span
|
||||
className="text-label font-semibold"
|
||||
style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}
|
||||
>
|
||||
TOTAL
|
||||
</span>
|
||||
<span
|
||||
className="text-h1 font-display"
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
${total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{order.internal_notes && (
|
||||
<section
|
||||
className="rounded-2xl p-4 italic"
|
||||
style={{ backgroundColor: "var(--color-surface-2)" }}
|
||||
>
|
||||
<p className="text-body">“{order.internal_notes}”</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StickyActionBar>
|
||||
<OrderActionButtons orderId={order.id} status={kind} />
|
||||
</StickyActionBar>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminOrders } from "@/actions/orders";
|
||||
import PageHeader from "@/components/admin/PageHeader";
|
||||
import { CardList, CardListItem } from "@/components/admin/CardList";
|
||||
import StatusPill, { type StatusKind } from "@/components/admin/StatusPill";
|
||||
import EmptyState from "@/components/admin/EmptyState";
|
||||
import OrdersFilterButton from "@/components/admin/orders/OrdersFilterButton";
|
||||
import PullToRefresh from "@/components/admin/PullToRefresh";
|
||||
import { formatRelativeTime } from "@/lib/format-date";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Map the legacy `orders.status` + `pickup_complete` pair onto the
|
||||
* StatusPill's `StatusKind` union. The legacy schema uses
|
||||
* ('pending' | 'confirmed' | 'fulfilled' | 'canceled') and a boolean
|
||||
* `pickup_complete`; the v2 design uses the simpler
|
||||
* (placed | ready | picked-up | cancelled) vocabulary.
|
||||
*/
|
||||
function statusKind(o: { status: string; pickup_complete: boolean }): StatusKind {
|
||||
if (o.status === "canceled") return "cancelled";
|
||||
if (o.pickup_complete || o.status === "fulfilled") return "picked-up";
|
||||
if (o.status === "confirmed") return "ready";
|
||||
return "placed";
|
||||
}
|
||||
|
||||
export default async function OrdersV2Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ status?: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const result = await getAdminOrders();
|
||||
const allOrders = result.success ? result.orders : [];
|
||||
const params = await searchParams;
|
||||
|
||||
// The legacy RPC doesn't accept a status filter — apply it client-side.
|
||||
// The shape we filter on matches the StatusPill vocabulary (placed/ready/
|
||||
// picked-up/cancelled), not the legacy DB status string, so the user
|
||||
// intent is the same.
|
||||
const filtered = params.status
|
||||
? allOrders.filter((o) => statusKind(o) === params.status)
|
||||
: allOrders;
|
||||
|
||||
// Show at most 50 — the plan's example limit. Anything more is paginated
|
||||
// (PR follow-up).
|
||||
const orders = filtered.slice(0, 50);
|
||||
|
||||
if (orders.length === 0) {
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Orders" actions={<OrdersFilterButton />} />
|
||||
<EmptyState
|
||||
title="No orders yet"
|
||||
description="When customers place orders, they'll show up here."
|
||||
icon={<span aria-hidden="true">📦</span>}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<PageHeader
|
||||
title="Orders"
|
||||
subtitle={`${orders.length} order${orders.length === 1 ? "" : "s"}`}
|
||||
actions={<OrdersFilterButton />}
|
||||
/>
|
||||
<PullToRefresh>
|
||||
<CardList ariaLabel="Orders">
|
||||
{orders.map((order) => {
|
||||
const total = Number(order.subtotal ?? 0);
|
||||
return (
|
||||
<CardListItem key={order.id} href={`/admin/v2/orders/${order.id}`}>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<StatusPill status={statusKind(order)} />
|
||||
<span
|
||||
className="text-mono"
|
||||
style={{ color: "var(--color-text-faint)" }}
|
||||
>
|
||||
#{order.id.slice(0, 8)}
|
||||
</span>
|
||||
<span
|
||||
className="text-meta ml-auto"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{formatRelativeTime(order.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-h2"
|
||||
style={{ fontWeight: 700, lineHeight: 1.3 }}
|
||||
>
|
||||
{order.customer_name ?? "Customer"}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span
|
||||
className="text-h1 font-display"
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
${total.toFixed(2)}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ color: "var(--color-text-faint)" }}
|
||||
>
|
||||
›
|
||||
</span>
|
||||
</div>
|
||||
</CardListItem>
|
||||
);
|
||||
})}
|
||||
</CardList>
|
||||
</PullToRefresh>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import Link from "next/link";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { pool } from "@/lib/db";
|
||||
import { getMobileDashboardSummary } from "@/actions/dashboard";
|
||||
import { getAdminOrders } from "@/actions/orders";
|
||||
import PageHeader from "@/components/admin/PageHeader";
|
||||
import EmptyState from "@/components/admin/EmptyState";
|
||||
import StatusPill, { type StatusKind } from "@/components/admin/StatusPill";
|
||||
import PullToRefresh from "@/components/admin/PullToRefresh";
|
||||
import { formatDate, formatRelativeTime } from "@/lib/format-date";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Row shape for the "Today's stops" inline query. Slimmer than the
|
||||
* full v2 stops page shape — the dashboard only needs the time + a
|
||||
* displayable address. The query mirrors `src/app/admin/v2/stops/page.tsx`'s
|
||||
* `s.date = $1` pattern (stops uses a `date` DATE column, not
|
||||
* `scheduled_at`).
|
||||
*/
|
||||
interface DashboardStopRow {
|
||||
id: string;
|
||||
time: string | null;
|
||||
location: string;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
brand_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the legacy order status onto the StatusPill vocabulary. Mirrors
|
||||
* the v2 orders page's `statusKind()` — `'pending' | 'confirmed'` is
|
||||
* the v1 schema's "needs attention" set (everything before fulfillment).
|
||||
*/
|
||||
function orderStatusKind(status: string): StatusKind {
|
||||
if (status === "canceled") return "cancelled";
|
||||
if (status === "fulfilled") return "picked-up";
|
||||
if (status === "confirmed") return "ready";
|
||||
return "placed";
|
||||
}
|
||||
|
||||
/** Format a stop's free-form time string (24h or 12h) for display. */
|
||||
function formatStopTime(time: string | null): string {
|
||||
if (!time) return "—";
|
||||
const t = time.trim();
|
||||
if (/[ap]m$/i.test(t)) return t.toUpperCase();
|
||||
const m = t.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (m) {
|
||||
let h = parseInt(m[1], 10);
|
||||
const min = m[2];
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
if (h === 0) h = 12;
|
||||
else if (h > 12) h -= 12;
|
||||
return `${h}:${min} ${ampm}`;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Pick the best address string we have on a stop row. */
|
||||
function stopAddress(stop: DashboardStopRow): string {
|
||||
if (stop.address) return stop.address;
|
||||
if (stop.location) return stop.location;
|
||||
const cityState = [stop.city, stop.state].filter(Boolean).join(", ");
|
||||
return cityState || "Stop";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the "today" YYYY-MM-DD string in the server's local timezone.
|
||||
* Kept in sync with `src/app/admin/v2/stops/page.tsx` so a dashboard
|
||||
* click-through lands on the matching stops list.
|
||||
*/
|
||||
function todayDateStr(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default async function DashboardV2Page() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
|
||||
if (!activeBrandId) {
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Today" subtitle={formatDate(new Date())} />
|
||||
<PullToRefresh>
|
||||
<EmptyState
|
||||
title="No brand selected"
|
||||
description="Select a brand to view its dashboard."
|
||||
icon={<span aria-hidden="true">🌾</span>}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const brandId = activeBrandId;
|
||||
const dateStr = todayDateStr();
|
||||
|
||||
// Parallel fetch: stat summary (RPC), today's stops, and pending
|
||||
// orders. The stops query mirrors the v2 stops page's pool pattern;
|
||||
// `getAdminOrders` is the shared legacy RPC.
|
||||
const [summary, stopsResult, ordersResult] = await Promise.all([
|
||||
getMobileDashboardSummary(brandId),
|
||||
pool.query<DashboardStopRow>(
|
||||
`SELECT s.id, s."time", s.location, s.address, s.city, s.state, s.brand_id
|
||||
FROM stops s
|
||||
WHERE s.date = $1 AND s.brand_id = $2
|
||||
ORDER BY s."time" ASC NULLS LAST, s.location ASC
|
||||
LIMIT 5`,
|
||||
[dateStr, brandId],
|
||||
),
|
||||
getAdminOrders(),
|
||||
]);
|
||||
|
||||
const todayStops = stopsResult.rows;
|
||||
const allOrders = ordersResult.success ? ordersResult.orders : [];
|
||||
const pendingOrders = allOrders
|
||||
.filter((o) => o.status === "pending" || o.status === "confirmed")
|
||||
.slice(0, 5);
|
||||
|
||||
const hasAnyData =
|
||||
summary.orders_today > 0 ||
|
||||
summary.revenue_today > 0 ||
|
||||
summary.pending_fulfillment > 0 ||
|
||||
summary.stops_today > 0;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Today" subtitle={formatDate(new Date())} />
|
||||
<PullToRefresh>
|
||||
<div className="px-4 pb-24 space-y-4">
|
||||
{/* "Needs you" callout — surfaces the most actionable count
|
||||
* at the top of the page. Tapping jumps to the filtered
|
||||
* orders list. */}
|
||||
{summary.pending_fulfillment > 0 && (
|
||||
<Link
|
||||
href="/admin/v2/orders?status=placed"
|
||||
className="block rounded-2xl p-4 active:opacity-80 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: "var(--color-warning-soft)",
|
||||
borderLeft: "4px solid var(--color-warning)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-label font-semibold"
|
||||
style={{
|
||||
color: "var(--color-warning)",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
NEEDS YOU
|
||||
</div>
|
||||
<div
|
||||
className="text-h2 mt-1"
|
||||
style={{ fontWeight: 700, lineHeight: 1.3 }}
|
||||
>
|
||||
{summary.pending_fulfillment} order
|
||||
{summary.pending_fulfillment === 1 ? "" : "s"} waiting
|
||||
</div>
|
||||
<div
|
||||
className="text-meta mt-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
View all →
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 2x2 stat grid. The "Pending" card picks up the warning
|
||||
* accent when there's anything to act on. */}
|
||||
<section
|
||||
className="grid grid-cols-2 gap-3"
|
||||
aria-label="Today's stats"
|
||||
>
|
||||
<StatCard
|
||||
label="Orders"
|
||||
value={summary.orders_today.toLocaleString("en-US")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Revenue"
|
||||
value={`$${(summary.revenue_today / 100).toFixed(2)}`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Pending"
|
||||
value={summary.pending_fulfillment.toLocaleString("en-US")}
|
||||
accent={summary.pending_fulfillment > 0}
|
||||
/>
|
||||
<StatCard
|
||||
label="Stops"
|
||||
value={summary.stops_today.toLocaleString("en-US")}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Today's stops — at-a-glance list of the next 5 runs so
|
||||
* the operator can see where they need to be today. */}
|
||||
{todayStops.length > 0 && (
|
||||
<section aria-label="Today's stops">
|
||||
<h2
|
||||
className="text-label font-semibold mb-3"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
TODAY'S STOPS
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{todayStops.map((stop) => {
|
||||
const href = `/admin/v2/stops?date=${dateStr}`;
|
||||
return (
|
||||
<li key={stop.id}>
|
||||
<Link
|
||||
href={href}
|
||||
className="block rounded-2xl p-4 active:scale-[0.99] transition-transform"
|
||||
style={{
|
||||
backgroundColor: "var(--color-surface-2)",
|
||||
minHeight: "72px",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-meta"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{formatStopTime(stop.time)}
|
||||
</div>
|
||||
<div
|
||||
className="text-h2 mt-1"
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.3,
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
{stopAddress(stop)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Top 5 pending orders — drilled into from the "Needs you"
|
||||
* callout above. Each row tappable to the order detail. */}
|
||||
{pendingOrders.length > 0 && (
|
||||
<section aria-label="Pending orders">
|
||||
<h2
|
||||
className="text-label font-semibold mb-3"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
PENDING ORDERS
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{pendingOrders.map((order) => {
|
||||
const total = Number(order.subtotal ?? 0);
|
||||
return (
|
||||
<li key={order.id}>
|
||||
<Link
|
||||
href={`/admin/v2/orders/${order.id}`}
|
||||
className="block rounded-2xl p-4 active:scale-[0.99] transition-transform"
|
||||
style={{
|
||||
backgroundColor: "var(--color-surface-2)",
|
||||
minHeight: "72px",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<StatusPill status={orderStatusKind(order.status)} />
|
||||
<span
|
||||
className="text-mono"
|
||||
style={{ color: "var(--color-text-faint)" }}
|
||||
>
|
||||
#{order.id.slice(0, 8)}
|
||||
</span>
|
||||
<span
|
||||
className="text-meta ml-auto"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{formatRelativeTime(order.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-h2"
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.3,
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
{order.customer_name ?? "Customer"}
|
||||
</div>
|
||||
<div
|
||||
className="text-h1 mt-1"
|
||||
style={{ fontWeight: 600, color: "var(--color-text)" }}
|
||||
>
|
||||
${total.toFixed(2)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Empty dashboard — no orders, no revenue, no stops. Keep
|
||||
* the page useful instead of showing four zeros. */}
|
||||
{!hasAnyData && (
|
||||
<EmptyState
|
||||
title="Nothing to show yet"
|
||||
description="When orders, stops, or revenue roll in, they'll show up here."
|
||||
icon={<span aria-hidden="true">🌱</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single stat tile. Mirrors the v2 product/stop cards' rounded-2xl +
|
||||
* surface-2 background. `accent` swaps the fill + value color to the
|
||||
* warning pair so the "Pending" card stands out when there's work to do.
|
||||
*/
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
accent = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl p-4"
|
||||
style={{
|
||||
backgroundColor: accent
|
||||
? "var(--color-warning-soft)"
|
||||
: "var(--color-surface-2)",
|
||||
minHeight: "88px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-label font-semibold"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{label.toUpperCase()}
|
||||
</div>
|
||||
<div
|
||||
className="text-h1 font-display mt-1"
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
color: accent ? "var(--color-warning)" : "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { pool } from "@/lib/db";
|
||||
import PageHeader from "@/components/admin/PageHeader";
|
||||
import { CardList, CardListItem } from "@/components/admin/CardList";
|
||||
import StatusPill, { type StatusKind } from "@/components/admin/StatusPill";
|
||||
import EmptyState from "@/components/admin/EmptyState";
|
||||
import PullToRefresh from "@/components/admin/PullToRefresh";
|
||||
import ProductsFilterChips from "@/components/admin/products/ProductsFilterChips";
|
||||
import StockAdjustButton from "@/components/admin/products/StockAdjustButton";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Row shape for the v2 products list. Mirrors the columns we actually
|
||||
* select from the `products` table — the plan's speculative shape used
|
||||
* `stock` / `image_url` / `price_cents` which is close, but the real
|
||||
* stock column is `inventory` (not `stock`), `image_url` and
|
||||
* `price_cents` are correct as-is, and there is no `hidden` field
|
||||
* (visibility is the boolean `active`).
|
||||
*/
|
||||
interface DbProductRow {
|
||||
id: string;
|
||||
name: string;
|
||||
price_cents: number;
|
||||
inventory: number;
|
||||
image_url: string | null;
|
||||
active: boolean;
|
||||
brand_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an inventory count onto the StatusPill vocabulary. Matches the
|
||||
* thresholds the v1 admin Products page uses (out = 0, low < 10, else in
|
||||
* stock). The `kind` doubles as a CSS-friendly token; the `label` is the
|
||||
* user-visible string (includes the count so the user can read it at
|
||||
* a glance in the field).
|
||||
*/
|
||||
function stockStatus(inventory: number): { kind: StatusKind; label: string } {
|
||||
if (inventory === 0) return { kind: "out", label: "Out of stock" };
|
||||
if (inventory < 10) return { kind: "low", label: `⚠ ${inventory} left` };
|
||||
return { kind: "in-stock", label: `${inventory} in stock` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the inventory/active filter clause for a given `?filter=` value.
|
||||
*
|
||||
* - "all" → no filter
|
||||
* - "in-stock" → inventory >= 10
|
||||
* - "low" → 0 < inventory < 10
|
||||
* - "out" → inventory = 0
|
||||
* - "hidden" → active = false (no separate `hidden` column in the
|
||||
* schema — "hidden" maps to the legacy "inactive" state)
|
||||
* - unknown → same as "all" (defensive: don't 500 on a bad URL)
|
||||
*/
|
||||
function filterClause(filter: string | undefined): { sql: string; params: unknown[] } {
|
||||
switch (filter) {
|
||||
case "in-stock":
|
||||
return { sql: " AND inventory >= 10", params: [] };
|
||||
case "low":
|
||||
return { sql: " AND inventory > 0 AND inventory < 10", params: [] };
|
||||
case "out":
|
||||
return { sql: " AND inventory = 0", params: [] };
|
||||
case "hidden":
|
||||
return { sql: " AND active = false", params: [] };
|
||||
case "all":
|
||||
default:
|
||||
return { sql: "", params: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProductsV2Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ filter?: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
const params = await searchParams;
|
||||
const filter = params.filter;
|
||||
const { sql: filterSql, params: filterParams } = filterClause(filter);
|
||||
|
||||
// Mirror the v2 stops page's brand-scoping pattern: explicit active
|
||||
// brand > platform_admin "all brands" > multi-brand membership list.
|
||||
let products: DbProductRow[] = [];
|
||||
let queryError: string | null = null;
|
||||
try {
|
||||
const selectFields = `
|
||||
id, name, price_cents, inventory, image_url, active, brand_id
|
||||
`;
|
||||
const orderBy = `name ASC`;
|
||||
|
||||
if (activeBrandId) {
|
||||
const result = await pool.query<DbProductRow>(
|
||||
`SELECT ${selectFields}
|
||||
FROM products
|
||||
WHERE brand_id = $1 ${filterSql}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT 200`,
|
||||
[activeBrandId, ...filterParams],
|
||||
);
|
||||
products = result.rows;
|
||||
} else if (adminUser.role === "platform_admin") {
|
||||
const result = await pool.query<DbProductRow>(
|
||||
`SELECT ${selectFields}
|
||||
FROM products
|
||||
WHERE 1=1 ${filterSql}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT 200`,
|
||||
[...filterParams],
|
||||
);
|
||||
products = result.rows;
|
||||
} else {
|
||||
const brandIds = adminUser.brand_ids ?? [];
|
||||
if (brandIds.length === 0) {
|
||||
products = [];
|
||||
} else {
|
||||
const result = await pool.query<DbProductRow>(
|
||||
`SELECT ${selectFields}
|
||||
FROM products
|
||||
WHERE brand_id = ANY($1::uuid[]) ${filterSql}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT 200`,
|
||||
[brandIds, ...filterParams],
|
||||
);
|
||||
products = result.rows;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
queryError = e instanceof Error ? e.message : String(e);
|
||||
console.error("[admin/v2/products] query error:", queryError);
|
||||
}
|
||||
|
||||
if (queryError) {
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Products" />
|
||||
<ProductsFilterChips active={filter ?? "all"} />
|
||||
<div className="px-4 pb-24">
|
||||
<EmptyState
|
||||
title="Couldn't load products"
|
||||
description={queryError}
|
||||
icon={<span aria-hidden="true">⚠️</span>}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Products" />
|
||||
<ProductsFilterChips active={filter ?? "all"} />
|
||||
<PullToRefresh>
|
||||
<EmptyState
|
||||
title="No products"
|
||||
description="Add your first product to start selling."
|
||||
icon={<span aria-hidden="true">🥬</span>}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<PageHeader
|
||||
title="Products"
|
||||
subtitle={`${products.length} product${products.length === 1 ? "" : "s"}`}
|
||||
/>
|
||||
<ProductsFilterChips active={filter ?? "all"} />
|
||||
<PullToRefresh>
|
||||
<CardList ariaLabel="Products">
|
||||
{products.map((product) => {
|
||||
const stock = stockStatus(product.inventory);
|
||||
// price_cents → display dollars, two decimals. The schema
|
||||
// stores integer cents, so the legacy `price` field (in
|
||||
// dollars) would be `price_cents / 100`.
|
||||
const priceDollars = (product.price_cents / 100).toFixed(2);
|
||||
return (
|
||||
<CardListItem key={product.id} href={`/admin/v2/products/${product.id}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{product.image_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- product
|
||||
// images may live on a third-party CDN; Next/Image
|
||||
// would require us to whitelist the host. A plain
|
||||
// <img> is fine here since this is a mobile-first
|
||||
// admin page, not a public storefront.
|
||||
<img
|
||||
src={product.image_url}
|
||||
alt=""
|
||||
className="w-16 h-16 rounded-2xl object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-16 h-16 rounded-2xl shrink-0 flex items-center justify-center"
|
||||
style={{ backgroundColor: "var(--color-surface-3)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
🥬
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-h2" style={{ fontWeight: 700, lineHeight: 1.3 }}>{product.name}</div>
|
||||
<StockAdjustButton productId={product.id} currentStock={product.inventory} />
|
||||
</div>
|
||||
<div className="text-h1 font-display mt-1" style={{ fontWeight: 600 }}>${priceDollars}</div>
|
||||
<div className="mt-2">
|
||||
<StatusPill status={stock.kind} label={stock.label} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardListItem>
|
||||
);
|
||||
})}
|
||||
</CardList>
|
||||
</PullToRefresh>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { pool } from "@/lib/db";
|
||||
import PageHeader from "@/components/admin/PageHeader";
|
||||
import { CardList, CardListItem } from "@/components/admin/CardList";
|
||||
import StatusPill, { type StatusKind } from "@/components/admin/StatusPill";
|
||||
import EmptyState from "@/components/admin/EmptyState";
|
||||
import PullToRefresh from "@/components/admin/PullToRefresh";
|
||||
import StopsDatePicker from "@/components/admin/stops/StopsDatePicker";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Row shape returned from the stops query. The legacy `stops` table uses
|
||||
* separate `date` (DATE) and `time` (TEXT) columns, not a combined
|
||||
* `scheduled_at` ISO string — the plan's example was speculative about
|
||||
* the schema, so we mirror the v1 stops page shape and adapt.
|
||||
*/
|
||||
interface DbStopRow {
|
||||
id: string;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
date: string;
|
||||
time: string | null;
|
||||
location: string;
|
||||
status: string;
|
||||
brand_id: string;
|
||||
address: string | null;
|
||||
zip: string | null;
|
||||
cutoff_date: string | null;
|
||||
customer_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the legacy `stops.status` ('active' | 'paused' | 'closed') onto the
|
||||
* StatusPill's `StatusKind` vocabulary. Mirrors the v1 page's
|
||||
* `s.status === "active"` check but extends it to cover the full enum.
|
||||
*
|
||||
* - 'active' → 'scheduled' (default — accepting orders)
|
||||
* - 'paused' → 'in-transit' (temporarily closed, like a truck en route)
|
||||
* - 'closed' → 'completed' (final state)
|
||||
*/
|
||||
function stopStatusKind(s: string): StatusKind {
|
||||
if (s === "paused") return "in-transit";
|
||||
if (s === "closed") return "completed";
|
||||
return "scheduled";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the hour (0-23) from a free-form time string. The `stops.time`
|
||||
* column is TEXT and accepts both 24h ("14:30") and 12h ("9:00 AM",
|
||||
* "9 AM", "2:30pm") formats. Returns null for unparseable input so
|
||||
* those stops fall into the "Anytime" bucket.
|
||||
*/
|
||||
function parseHour(time: string | null): number | null {
|
||||
if (!time) return null;
|
||||
const t = time.trim();
|
||||
// 12h format with optional minutes: "9:00 AM" / "9 AM" / "9:00am" / "2:30 PM"
|
||||
const m12 = t.match(/^(\d{1,2})(?::\d{2})?\s*(am|pm)$/i);
|
||||
if (m12) {
|
||||
let h = parseInt(m12[1], 10);
|
||||
const isPm = m12[2].toLowerCase() === "pm";
|
||||
if (h < 1 || h > 12) return null;
|
||||
if (h === 12) h = isPm ? 12 : 0;
|
||||
else if (isPm) h += 12;
|
||||
return h;
|
||||
}
|
||||
// 24h format: "14:30" or "14"
|
||||
const m24 = t.match(/^(\d{1,2})(?::\d{2})?$/);
|
||||
if (m24) {
|
||||
const h = parseInt(m24[1], 10);
|
||||
return h >= 0 && h < 24 ? h : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type TimeBucket = "Morning" | "Afternoon" | "Evening" | "Anytime";
|
||||
|
||||
function bucketFor(time: string | null): TimeBucket {
|
||||
const h = parseHour(time);
|
||||
if (h === null) return "Anytime";
|
||||
if (h < 12) return "Morning";
|
||||
if (h < 17) return "Afternoon";
|
||||
return "Evening";
|
||||
}
|
||||
|
||||
const BUCKET_ORDER: TimeBucket[] = ["Morning", "Afternoon", "Evening", "Anytime"];
|
||||
|
||||
/** Build a Google Maps directions URL for a stop's address. */
|
||||
function mapsUrlFor(stop: DbStopRow): string | null {
|
||||
const parts = [stop.address, stop.city, stop.state, stop.zip].filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(
|
||||
parts.join(", "),
|
||||
)}`;
|
||||
}
|
||||
|
||||
function formatDateHeading(d: Date): string {
|
||||
return d.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatTimeLabel(time: string | null): string {
|
||||
if (!time) return "—";
|
||||
const t = time.trim();
|
||||
// If it's already a "9:00 AM" or "9:00AM" style, return as-is
|
||||
if (/[ap]m$/i.test(t)) return t.toUpperCase();
|
||||
// 24h "14:30" → "2:30 PM"
|
||||
const m = t.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (m) {
|
||||
let h = parseInt(m[1], 10);
|
||||
const min = m[2];
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
if (h === 0) h = 12;
|
||||
else if (h > 12) h -= 12;
|
||||
return `${h}:${min} ${ampm}`;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
export default async function StopsV2Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ date?: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
const params = await searchParams;
|
||||
|
||||
// Resolve the target date. `searchParams.date` is a YYYY-MM-DD string.
|
||||
// Fall back to "today" in the user's local timezone (not UTC, so the
|
||||
// displayed heading matches what they expect).
|
||||
let dateObj: Date;
|
||||
if (params.date) {
|
||||
const parsed = new Date(params.date);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
dateObj = parsed;
|
||||
} else {
|
||||
const now = new Date();
|
||||
dateObj = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
} else {
|
||||
const now = new Date();
|
||||
dateObj = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, "0")}-${String(dateObj.getDate()).padStart(2, "0")}`;
|
||||
|
||||
// Build the brand filter. Mirrors the v1 stops page's brand-scoping
|
||||
// pattern: explicit active brand > platform_admin "all brands" >
|
||||
// multi-brand membership list.
|
||||
let stops: DbStopRow[] = [];
|
||||
let error: string | null = null;
|
||||
try {
|
||||
const selectFields = `
|
||||
s.id, s.city, s.state, s.date::text AS date, s."time", s.location,
|
||||
s.status, s.brand_id, s.address, s.zip, s.cutoff_date,
|
||||
(SELECT COUNT(*)::int FROM orders o WHERE o.stop_id = s.id) AS customer_count
|
||||
`;
|
||||
const orderBy = `s."time" ASC NULLS LAST, s.location ASC`;
|
||||
const baseWhere = `s.date = $1`;
|
||||
|
||||
if (activeBrandId) {
|
||||
const result = await pool.query<DbStopRow>(
|
||||
`SELECT ${selectFields}
|
||||
FROM stops s
|
||||
WHERE ${baseWhere} AND s.brand_id = $2
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT 200`,
|
||||
[dateStr, activeBrandId],
|
||||
);
|
||||
stops = result.rows;
|
||||
} else if (adminUser.role === "platform_admin") {
|
||||
const result = await pool.query<DbStopRow>(
|
||||
`SELECT ${selectFields}
|
||||
FROM stops s
|
||||
WHERE ${baseWhere}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT 200`,
|
||||
[dateStr],
|
||||
);
|
||||
stops = result.rows;
|
||||
} else {
|
||||
const brandIds = adminUser.brand_ids ?? [];
|
||||
if (brandIds.length === 0) {
|
||||
// No accessible brands → empty list, no need to query.
|
||||
stops = [];
|
||||
} else {
|
||||
const result = await pool.query<DbStopRow>(
|
||||
`SELECT ${selectFields}
|
||||
FROM stops s
|
||||
WHERE ${baseWhere} AND s.brand_id = ANY($2::uuid[])
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT 200`,
|
||||
[dateStr, brandIds],
|
||||
);
|
||||
stops = result.rows;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
console.error("[admin/v2/stops] query error:", error);
|
||||
}
|
||||
|
||||
const headerDate = dateObj;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Stops" actions={<StopsDatePicker value={headerDate} />} />
|
||||
<div className="px-4 pb-24">
|
||||
<EmptyState
|
||||
title="Couldn't load stops"
|
||||
description={error}
|
||||
icon={<span aria-hidden="true">⚠️</span>}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (stops.length === 0) {
|
||||
return (
|
||||
<main>
|
||||
<PageHeader
|
||||
title="Stops"
|
||||
subtitle={formatDateHeading(headerDate)}
|
||||
actions={<StopsDatePicker value={headerDate} />}
|
||||
/>
|
||||
<PullToRefresh>
|
||||
<EmptyState
|
||||
title="No stops scheduled"
|
||||
description="Add a stop to start routing."
|
||||
icon={<span aria-hidden="true">🚚</span>}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Group by time-of-day bucket. Use Map to preserve insertion order.
|
||||
const grouped: Record<TimeBucket, DbStopRow[]> = {
|
||||
Morning: [],
|
||||
Afternoon: [],
|
||||
Evening: [],
|
||||
Anytime: [],
|
||||
};
|
||||
for (const s of stops) {
|
||||
grouped[bucketFor(s.time)].push(s);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<PageHeader
|
||||
title="Stops"
|
||||
subtitle={formatDateHeading(headerDate)}
|
||||
actions={<StopsDatePicker value={headerDate} />}
|
||||
/>
|
||||
<PullToRefresh>
|
||||
{BUCKET_ORDER.filter((b) => grouped[b].length > 0).map((bucket) => (
|
||||
<section key={bucket}>
|
||||
<h2
|
||||
className="sticky top-0 px-4 py-2 text-label font-semibold"
|
||||
style={{
|
||||
backgroundColor: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{bucket.toUpperCase()}
|
||||
</h2>
|
||||
<CardList ariaLabel={`${bucket} stops`}>
|
||||
{grouped[bucket].map((stop) => {
|
||||
const href = mapsUrlFor(stop);
|
||||
return (
|
||||
<CardListItem key={stop.id} href={href ?? "#"}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="text-h1 font-display shrink-0"
|
||||
style={{ fontWeight: 600, minWidth: "72px" }}
|
||||
>
|
||||
{formatTimeLabel(stop.time)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-h2"
|
||||
style={{ fontWeight: 700, lineHeight: 1.3 }}
|
||||
>
|
||||
{stop.address ??
|
||||
stop.location ??
|
||||
([stop.city, stop.state].filter(Boolean).join(", ") ||
|
||||
"Stop")}
|
||||
</div>
|
||||
<div
|
||||
className="text-meta mt-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{stop.customer_count} customer
|
||||
{stop.customer_count === 1 ? "" : "s"}
|
||||
{stop.cutoff_date != null
|
||||
? ` · Cutoff ${stop.cutoff_date}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill status={stopStatusKind(stop.status)} />
|
||||
</div>
|
||||
</CardListItem>
|
||||
);
|
||||
})}
|
||||
</CardList>
|
||||
</section>
|
||||
))}
|
||||
</PullToRefresh>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,43 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* /admin/water-log/settings
|
||||
*
|
||||
* Site-admin-facing settings page for the Water Log module. Controls:
|
||||
* - Whether `/water/admin` is enabled
|
||||
* - 4-digit admin PIN (regenerate on demand)
|
||||
* - Session duration (hours)
|
||||
* - Per-coarse-permission flags (edit / delete / export)
|
||||
* - High/low alert phone + enable flag
|
||||
*
|
||||
* Visual language: Field Almanac. Same cream + forest palette as
|
||||
* the main WaterLogAdminPanel, but trimmed to a single column for
|
||||
* a focused "form" feel.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getWaterAdminSettings, saveWaterAdminSettings, type WaterAdminSettings } from "@/actions/water-log/settings";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
getWaterAdminSettings,
|
||||
saveWaterAdminSettings,
|
||||
regenerateAdminPin,
|
||||
type AdminSettings,
|
||||
} from "@/actions/water-log/settings";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export default function WaterLogSettingsPage() {
|
||||
const router = useRouter();
|
||||
const [settings, setSettings] = useState<WaterAdminSettings | null>(null);
|
||||
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [revealedPin, setRevealedPin] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
// Form state
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [pin, setPin] = useState("");
|
||||
const [confirmPin, setConfirmPin] = useState("");
|
||||
const [sessionDuration, setSessionDuration] = useState(12);
|
||||
const [canEdit, setCanEdit] = useState(true);
|
||||
const [canDelete, setCanDelete] = useState(false);
|
||||
const [canDelete, setCanDelete] = useState(true);
|
||||
const [canExport, setCanExport] = useState(true);
|
||||
const [alertPhone, setAlertPhone] = useState("");
|
||||
const [alertsEnabled, setAlertsEnabled] = useState(false);
|
||||
@@ -30,239 +48,298 @@ export default function WaterLogSettingsPage() {
|
||||
if (data) {
|
||||
setSettings(data);
|
||||
setEnabled(data.enabled);
|
||||
setSessionDuration(data.session_duration_hours);
|
||||
setCanEdit(data.can_edit_entries);
|
||||
setCanDelete(data.can_delete_entries);
|
||||
setCanExport(data.can_export_csv);
|
||||
setAlertPhone(data.alert_phone ?? "");
|
||||
setAlertsEnabled(data.alerts_enabled ?? false);
|
||||
setSessionDuration(data.sessionDurationHours);
|
||||
setCanEdit(data.canEditEntries);
|
||||
setCanDelete(data.canDeleteEntries);
|
||||
setCanExport(data.canExportCsv);
|
||||
setAlertPhone(data.alertPhone ?? "");
|
||||
setAlertsEnabled(data.alertsEnabled);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await loadSettings();
|
||||
};
|
||||
init();
|
||||
// Load on mount — setState-in-effect is intentional here
|
||||
// because we're hydrating from server data.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (enabled && pin && pin !== confirmPin) {
|
||||
setMessage({ type: "error", text: "PINs do not match" });
|
||||
return;
|
||||
}
|
||||
if (enabled && pin && (pin.length !== 4 || !/^\d{4}$/.test(pin))) {
|
||||
setMessage({ type: "error", text: "PIN must be exactly 4 digits" });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
|
||||
enabled,
|
||||
pin: pin || undefined,
|
||||
session_duration_hours: sessionDuration,
|
||||
can_edit_entries: canEdit,
|
||||
can_delete_entries: canDelete,
|
||||
can_export_csv: canExport,
|
||||
alert_phone: alertPhone || null,
|
||||
alerts_enabled: alertsEnabled,
|
||||
sessionDurationHours: sessionDuration,
|
||||
canEditEntries: canEdit,
|
||||
canDeleteEntries: canDelete,
|
||||
canExportCsv: canExport,
|
||||
alertPhone: alertPhone || null,
|
||||
alertsEnabled,
|
||||
});
|
||||
if (result.success) {
|
||||
setMessage({ type: "success", text: "Settings saved" });
|
||||
setPin("");
|
||||
setConfirmPin("");
|
||||
if (result.settings) setSettings(result.settings);
|
||||
} else {
|
||||
setMessage({ type: "error", text: result.error ?? "Save failed" });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleRegenerate() {
|
||||
if (!confirm("Regenerate the admin PIN? All current admin sessions will be signed out.")) {
|
||||
return;
|
||||
}
|
||||
setRegenerating(true);
|
||||
setMessage(null);
|
||||
const result = await regenerateAdminPin(TUXEDO_BRAND_ID);
|
||||
if (result.success && result.pin) {
|
||||
setRevealedPin(result.pin);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "New PIN generated. Save it now — it will not be shown again.",
|
||||
});
|
||||
} else {
|
||||
setMessage({ type: "error", text: result.error ?? "Failed to regenerate PIN" });
|
||||
}
|
||||
setRegenerating(false);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
||||
<span className="text-zinc-500">Loading...</span>
|
||||
<div className="min-h-screen bg-[#fdfaf2] flex items-center justify-center font-sans text-[#1d1d1f]">
|
||||
<span className="text-[#5a5d5a]">Loading…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
<div className="min-h-screen bg-[#fdfaf2] font-sans text-[#1d1d1f]">
|
||||
{/* Header */}
|
||||
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => router.back()} className="text-zinc-500 hover:text-zinc-400 text-sm">← Back</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100">Water Log — Admin Portal</h1>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Configure PIN access for the field admin portal at /water/admin</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-[#d4d9d3] bg-white">
|
||||
<div className="mx-auto max-w-2xl px-6 py-6">
|
||||
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">§ 04 — Settings</p>
|
||||
<h1
|
||||
className="mt-2 text-3xl font-medium text-[#1a4d2e]"
|
||||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||
>
|
||||
Water Log · Admin Portal
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[#5a5d5a]">
|
||||
Configure PIN access for the field admin portal at <code className="rounded bg-[#f4f1e8] px-1 py-0.5 font-mono text-xs">/water/admin</code>.
|
||||
This is separate from the platform admin login.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-lg px-6 py-6">
|
||||
<div className="mx-auto max-w-2xl px-6 py-8">
|
||||
<form onSubmit={handleSave} className="space-y-6">
|
||||
|
||||
{message && (
|
||||
<div className={`rounded-xl px-4 py-3 text-sm font-semibold ${message.type === "success" ? "bg-green-900/40 text-green-800 border border-green-200" : "bg-red-900/40 text-red-800 border border-red-200"}`}>
|
||||
<div
|
||||
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
|
||||
message.type === "success"
|
||||
? "border-[#1a4d2e] bg-[#1a4d2e]/5 text-[#1a4d2e]"
|
||||
: "border-[#a4452b] bg-[#a4452b]/5 text-[#a4452b]"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enable toggle */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-zinc-100">Enable Admin Portal</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Allow PIN-based access to /water/admin (separate from platform login)</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEnabled((v) => !v)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? "bg-green-500" : "bg-stone-300"}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Card title="Admin Portal" subtitle="PIN-based access to /water/admin">
|
||||
<ToggleRow
|
||||
label="Enable"
|
||||
description="Allow water admins to sign in with a 4-digit PIN."
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
{/* PIN */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
|
||||
<p className="font-semibold text-zinc-100">4-Digit PIN</p>
|
||||
<p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="water-new-pin" className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label>
|
||||
<input
|
||||
id="water-new-pin"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={4}
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||
placeholder="••••"
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
|
||||
style={{ letterSpacing: "0.4em" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="water-confirm-pin" className="block text-xs font-medium text-zinc-400 mb-1">Confirm PIN</label>
|
||||
<input
|
||||
id="water-confirm-pin"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={4}
|
||||
value={confirmPin}
|
||||
onChange={(e) => setConfirmPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||
placeholder="••••"
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
|
||||
style={{ letterSpacing: "0.4em" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Duration */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3">
|
||||
<p className="font-semibold text-zinc-100">Session Duration</p>
|
||||
<p className="text-xs text-zinc-500">How long the admin session lasts after login (1–72 hours).</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={72}
|
||||
value={sessionDuration}
|
||||
onChange={(e) => setSessionDuration(parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-16 text-center text-sm font-bold text-zinc-100">{sessionDuration}h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3">
|
||||
<p className="font-semibold text-zinc-100">Permissions</p>
|
||||
{[
|
||||
{ key: "canEdit", label: "Edit entries", desc: "Allow modifying existing log entries", value: canEdit, setter: setCanEdit },
|
||||
{ key: "canDelete", label: "Delete entries", desc: "Allow removing log entries", value: canDelete, setter: setCanDelete },
|
||||
{ key: "canExport", label: "Export CSV", desc: "Allow exporting water log data", value: canExport, setter: setCanExport },
|
||||
].map(({ key, label, desc, value, setter }) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">{label}</p>
|
||||
<p className="text-xs text-zinc-500">{desc}</p>
|
||||
<Card title="Admin PIN" subtitle="4-digit PIN required for /water/admin sign-in">
|
||||
{revealedPin ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-mono uppercase tracking-wider text-[#8a6b3b]">
|
||||
New PIN — write it down
|
||||
</p>
|
||||
<div className="rounded-lg border border-[#1a4d2e] bg-[#1a4d2e]/5 px-6 py-4 text-center">
|
||||
<span
|
||||
className="font-mono text-4xl font-bold tracking-[0.4em] text-[#1a4d2e]"
|
||||
aria-label={`PIN: ${revealedPin.split("").join(" ")}`}
|
||||
>
|
||||
{revealedPin}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setter((v: boolean) => !v)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${value ? "bg-green-500" : "bg-stone-300"}`}
|
||||
onClick={() => setRevealedPin(null)}
|
||||
className="text-xs font-medium text-[#5a5d5a] underline-offset-2 hover:underline"
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${value ? "translate-x-6" : "translate-x-1"}`} />
|
||||
I've saved it — dismiss
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-[#5a5d5a]">
|
||||
{settings?.pin === null
|
||||
? "Generate a new PIN to give to your water admin."
|
||||
: "A PIN is already configured. Regenerate to issue a new one (this signs out existing admin sessions)."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating}
|
||||
className="shrink-0 rounded-lg border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#143d24] disabled:opacity-50"
|
||||
>
|
||||
{regenerating ? "Generating…" : "Regenerate PIN"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Session Duration */}
|
||||
<Card title="Session Duration" subtitle="How long the admin session lasts after sign-in (1–168 hours)">
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={168}
|
||||
value={sessionDuration}
|
||||
onChange={(e) => setSessionDuration(parseInt(e.target.value, 10))}
|
||||
className="flex-1 accent-[#1a4d2e]"
|
||||
aria-label="Session duration in hours"
|
||||
/>
|
||||
<span className="w-20 rounded border border-[#d4d9d3] bg-white px-3 py-1.5 text-center font-mono text-sm font-semibold">
|
||||
{sessionDuration}h
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Permissions */}
|
||||
<Card title="Permissions" subtitle="Coarse-grained flags. The PIN sign-in gates the portal; these flags gate specific actions.">
|
||||
<div className="divide-y divide-[#d4d9d3]">
|
||||
<ToggleRow
|
||||
label="Edit entries"
|
||||
description="Allow modifying existing log entries."
|
||||
value={canEdit}
|
||||
onChange={setCanEdit}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Delete entries"
|
||||
description="Allow removing log entries."
|
||||
value={canDelete}
|
||||
onChange={setCanDelete}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Export CSV"
|
||||
description="Allow exporting water log data."
|
||||
value={canExport}
|
||||
onChange={setCanExport}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* High/Low Alerts */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
|
||||
<p className="font-semibold text-zinc-100">High/Low Alerts</p>
|
||||
<p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate's thresholds.</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">Enable Alerts</p>
|
||||
<p className="text-xs text-zinc-500">SMS on threshold breach</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAlertsEnabled((v) => !v)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${alertsEnabled ? "bg-green-500" : "bg-stone-300"}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${alertsEnabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card title="High / Low Alerts" subtitle="SMS a phone when a reading exceeds a headgate's thresholds.">
|
||||
<ToggleRow
|
||||
label="Enable alerts"
|
||||
description="Threshold breach notifications."
|
||||
value={alertsEnabled}
|
||||
onChange={setAlertsEnabled}
|
||||
/>
|
||||
{alertsEnabled && (
|
||||
<div>
|
||||
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-zinc-400 mb-1">Alert Phone Number</label>
|
||||
<div className="mt-4">
|
||||
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-[#5a5d5a]">
|
||||
Alert phone number
|
||||
</label>
|
||||
<input
|
||||
id="water-alert-phone"
|
||||
type="tel"
|
||||
value={alertPhone}
|
||||
onChange={(e) => setAlertPhone(e.target.value)}
|
||||
placeholder="+1234567890"
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900"
|
||||
placeholder="+13035551234"
|
||||
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 text-base outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500 mt-1">U.S. format recommended. Must include country code (e.g. +1 for USA).</p>
|
||||
<p className="mt-1 text-xs text-[#8a8b88]">
|
||||
U.S. format recommended. Include country code (e.g. <span className="font-mono">+1</span> for USA).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR hint */}
|
||||
<div className="rounded-xl bg-amber-50 border border-amber-100 p-4">
|
||||
<p className="text-xs text-amber-700">
|
||||
<strong>QR Lock:</strong> Irrigators can lock a headgate by visiting <code className="bg-amber-900/40 px-1 rounded">/water?h={'{headgate_token}'}</code>.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full rounded-xl bg-stone-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50"
|
||||
className="w-full rounded-lg bg-[#1a4d2e] px-6 py-3.5 text-sm font-semibold uppercase tracking-wider text-white transition hover:bg-[#143d24] disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
{saving ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#d4d9d3] bg-white p-5 shadow-sm">
|
||||
<header className="mb-4">
|
||||
<h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-[#1d1d1f]">{label}</p>
|
||||
{description && <p className="mt-0.5 text-xs text-[#5a5d5a]">{description}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!value)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
|
||||
value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
value ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,13 +68,19 @@ export async function GET(request: Request) {
|
||||
|
||||
for (const contact of contacts) {
|
||||
if (!contact.email) continue;
|
||||
const ok = await sendCampaignEmail({
|
||||
const result = await sendCampaignEmail({
|
||||
to: contact.email,
|
||||
subject: campaign.subject ?? campaign.name,
|
||||
html: campaign.body_html,
|
||||
});
|
||||
if (ok) sent++;
|
||||
else errors++;
|
||||
if (result.ok) sent++;
|
||||
else {
|
||||
errors++;
|
||||
console.error(
|
||||
`[cron/send-scheduled] campaign ${campaign.id} contact ${contact.email}:`,
|
||||
result.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Mark campaign as sent
|
||||
|
||||
@@ -19,11 +19,12 @@ export async function GET() {
|
||||
{ status: "ok", message: "admin_users table present" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Database schema check failed";
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
message: err?.message ?? "Database schema check failed",
|
||||
message,
|
||||
hint: "Run migrations against the DATABASE_URL (see docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md)",
|
||||
},
|
||||
{ status: 503 }
|
||||
|
||||
@@ -9,19 +9,25 @@ type RequestBody = {
|
||||
/** Raw file content (CSV text). AI parsing is attempted if useAI=true. */
|
||||
text: string;
|
||||
brandId: string;
|
||||
/** If true and OPENAI_API_KEY is set, parse unstructured text with AI. */
|
||||
/** If true and an AI API key is set, parse unstructured text with AI. */
|
||||
useAI?: boolean;
|
||||
};
|
||||
|
||||
const AI_MODEL = "gpt-4o-mini";
|
||||
|
||||
async function parseWithAI(text: string, brandId: string): Promise<{
|
||||
async function parseWithAI(text: string, _brandId: string): Promise<{
|
||||
stops: ParsedStop[];
|
||||
warnings: string[];
|
||||
}> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
// Prefer MiniMax (env-level) — fall back to OpenAI.
|
||||
const provider: "minimax" | "openai" =
|
||||
process.env.MINIMAX_API_KEY ? "minimax" : process.env.OPENAI_API_KEY ? "openai" : "openai";
|
||||
const apiKey = provider === "minimax" ? process.env.MINIMAX_API_KEY! : process.env.OPENAI_API_KEY!;
|
||||
const baseURL = provider === "minimax"
|
||||
? (process.env.MINIMAX_BASE_URL || "https://api.minimax.io/v1")
|
||||
: "https://api.openai.com/v1";
|
||||
const model = provider === "minimax" ? "MiniMax-M3" : "gpt-4o-mini";
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENAI_API_KEY is not configured. Use CSV format for reliable parsing.");
|
||||
throw new Error("MINIMAX_API_KEY or OPENAI_API_KEY is not configured. Use CSV format for reliable parsing.");
|
||||
}
|
||||
|
||||
const systemPrompt = `You are a schedule extraction assistant. Given raw schedule text, extract all stop entries.
|
||||
@@ -37,26 +43,27 @@ Return a JSON array where each entry has:
|
||||
|
||||
If a row lacks required fields (city, state, location), omit it and add a warning.`;
|
||||
|
||||
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
const res = await fetch(`${baseURL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: AI_MODEL,
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: `Extract stops from this schedule:\n\n${text.slice(0, 8000)}` },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
// response_format is OpenAI-specific. MiniMax /v1/chat/completions may not honor it.
|
||||
...(provider === "openai" ? { response_format: { type: "json_object" } } : {}),
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`OpenAI API error: ${res.status} — ${err}`);
|
||||
throw new Error(`${provider} API error: ${res.status} — ${err}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
@@ -1,52 +1,46 @@
|
||||
/**
|
||||
* POST /api/water-admin-auth
|
||||
*
|
||||
* Body: { brandId: string, pin: string }
|
||||
* Side effect: sets the `wl_admin_session` cookie on success.
|
||||
*
|
||||
* Used by the mobile/PIN-only `/water/admin/login` portal. The session
|
||||
* itself is created by `verifyWaterAdminPin()` in
|
||||
* `src/actions/water-log/settings.ts`, which is the single source of
|
||||
* truth for admin PIN verification.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { pool } from "@/lib/db";
|
||||
import { verifyWaterAdminPin } from "@/actions/water-log/settings";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { brandId, pin } = await request.json();
|
||||
const { brandId, pin } = (await request.json()) as {
|
||||
brandId?: string;
|
||||
pin?: string;
|
||||
};
|
||||
if (!brandId || !pin) {
|
||||
return NextResponse.json({ success: false, error: "Missing params" }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Missing brandId or pin" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get admin settings
|
||||
const settingsRes = await pool.query<{
|
||||
get_water_admin_settings: { enabled: boolean; session_duration_hours?: number } | null;
|
||||
}>(
|
||||
`SELECT get_water_admin_settings($1) AS "get_water_admin_settings"`,
|
||||
[brandId],
|
||||
);
|
||||
const settings = settingsRes.rows[0]?.get_water_admin_settings;
|
||||
if (!settings?.enabled) {
|
||||
return NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 });
|
||||
const result = await verifyWaterAdminPin(brandId, pin);
|
||||
if (!result.success) {
|
||||
// Don't leak whether the PIN was wrong vs. not configured — both
|
||||
// are the same to a field attacker probing the endpoint.
|
||||
const status = result.error === "Invalid PIN" ? 401 : 403;
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid PIN" },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify PIN
|
||||
const verifyRes = await pool.query<{
|
||||
verify_water_admin_pin: { success: boolean; session_id?: string } | null;
|
||||
}>(
|
||||
`SELECT verify_water_admin_pin($1, $2) AS "verify_water_admin_pin"`,
|
||||
[brandId, pin],
|
||||
);
|
||||
const verifyData = verifyRes.rows[0]?.verify_water_admin_pin;
|
||||
if (!verifyData?.success || !verifyData.session_id) {
|
||||
return NextResponse.json({ success: false, error: "Invalid PIN" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Create session cookie
|
||||
const sessionId = verifyData.session_id;
|
||||
const cookieStore = await cookies();
|
||||
const durationHours = settings.session_duration_hours ?? 4;
|
||||
cookieStore.set("wl_admin_session", sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: durationHours * 3600,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: "Server error" }, { status: 500 });
|
||||
} catch (err) {
|
||||
captureError(err as Error, { path: "/api/water-admin-auth" });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,86 @@
|
||||
/**
|
||||
* GET /api/water-logs/export
|
||||
*
|
||||
* Streams the water log as JSON or CSV. Admin-only — checked via
|
||||
* `getAdminUser()` + `can_manage_water_log`. Brand comes from the admin
|
||||
* session, with Tuxedo fallback for backward compat.
|
||||
*
|
||||
* GET /api/water-logs/export?format=csv
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { pool } from "@/lib/db";
|
||||
import { getWaterEntries } from "@/actions/water-log/admin";
|
||||
import type { WaterLogReportRow } from "@/lib/water-log-reporting";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!adminUser.can_manage_water_log) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const format = searchParams.get("format") ?? "json";
|
||||
const brandId =
|
||||
adminUser.brand_id ??
|
||||
process.env.TUXEDO_BRAND_ID ??
|
||||
TUXEDO_BRAND_ID;
|
||||
|
||||
// Use brand_id from session (always Tuxedo for water log) or fallback to env
|
||||
const brandId = adminUser.brand_id ?? process.env.TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
type WaterEntry = {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
headgate_id: string | null;
|
||||
measurement: number | null;
|
||||
unit: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const { rows: data } = await pool.query<{ get_water_entries: WaterEntry[] | null }>(
|
||||
`SELECT get_water_entries($1, $2) AS "get_water_entries"`,
|
||||
[brandId, 10000],
|
||||
);
|
||||
const entries = data[0]?.get_water_entries ?? [];
|
||||
const raw = await getWaterEntries(brandId, 10000);
|
||||
const rows: WaterLogReportRow[] = raw.map((r) => ({
|
||||
logged_at: r.logged_at,
|
||||
headgate_name: r.headgate_name,
|
||||
user_name: r.user_name,
|
||||
user_role: "irrigator",
|
||||
measurement: r.measurement,
|
||||
unit: r.unit,
|
||||
notes: r.notes,
|
||||
submitted_via: r.submitted_via,
|
||||
}));
|
||||
|
||||
if (format === "csv") {
|
||||
const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"];
|
||||
const csvRows = [headers.join(",")];
|
||||
for (const row of entries) {
|
||||
csvRows.push([
|
||||
row.id,
|
||||
row.user_id ?? "",
|
||||
row.headgate_id ?? "",
|
||||
row.measurement ?? "",
|
||||
row.unit ?? "",
|
||||
`"${(row.notes ?? "").replace(/"/g, '""')}"`,
|
||||
row.created_at ?? "",
|
||||
].join(","));
|
||||
const headers = [
|
||||
"When",
|
||||
"Headgate",
|
||||
"User",
|
||||
"Measurement",
|
||||
"Unit",
|
||||
"Total Gallons",
|
||||
"Method",
|
||||
"Notes",
|
||||
"Via",
|
||||
"Photo URL",
|
||||
];
|
||||
const esc = (s: string | null | undefined) =>
|
||||
`"${(s ?? "").replace(/"/g, '""')}"`;
|
||||
const lines: string[] = [headers.join(",")];
|
||||
for (const e of raw) {
|
||||
lines.push(
|
||||
[
|
||||
esc(e.logged_at),
|
||||
esc(e.headgate_name),
|
||||
esc(e.user_name),
|
||||
String(e.measurement),
|
||||
esc(e.unit),
|
||||
e.total_gallons != null ? String(e.total_gallons) : "",
|
||||
esc(e.method),
|
||||
esc(e.notes),
|
||||
esc(e.submitted_via),
|
||||
esc(e.photo_url),
|
||||
].join(","),
|
||||
);
|
||||
}
|
||||
return new NextResponse(csvRows.join("\n"), {
|
||||
return new NextResponse(lines.join("\n"), {
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="water-log-${new Date().toISOString().slice(0, 10)}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(entries);
|
||||
return NextResponse.json({ rows });
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function BlogPage() {
|
||||
{/* Hero */}
|
||||
<section className="py-12 sm:py-16 md:py-20">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Blog & Resources
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-[#6b8f71]">
|
||||
@@ -151,7 +151,7 @@ export default function BlogPage() {
|
||||
<span className="inline-block w-fit px-3 py-1 bg-[#c97a3e]/10 text-[#c97a3e] text-sm font-medium rounded-full mb-4">
|
||||
Featured
|
||||
</span>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Getting Started with Route Commerce
|
||||
</h2>
|
||||
<p className="text-[#666] mb-6">
|
||||
@@ -234,7 +234,7 @@ export default function BlogPage() {
|
||||
{/* Newsletter */}
|
||||
<section className="py-20 bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f]">
|
||||
<div className="max-w-2xl mx-auto px-6 text-center">
|
||||
<h2 className="text-3xl font-bold text-white mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-white mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Stay in the Loop
|
||||
</h2>
|
||||
<p className="text-[#faf8f5]/80 mb-8">
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function BrandsPage() {
|
||||
|
||||
<style jsx>{`
|
||||
.brands-page {
|
||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||
font-family: var(--font-manrope);
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -1,76 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Your Cart — Route Commerce",
|
||||
description: "Review and manage your shopping cart. Select pickup stops, adjust quantities, and proceed to checkout.",
|
||||
keywords: ["cart", "shopping cart", "produce order", "checkout", "pickup"],
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
{/* Dark background matching cart page */}
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" />
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{/* Minimal header skeleton */}
|
||||
<div className="h-20 border-b border-white/5" />
|
||||
|
||||
{/* Content skeleton */}
|
||||
<main className="px-6 py-12 relative">
|
||||
<div className="mx-auto grid max-w-6xl gap-10 lg:grid-cols-[1fr_380px]">
|
||||
<div>
|
||||
{/* Title skeleton */}
|
||||
<div className="h-10 w-56 rounded-lg bg-white/5 animate-pulse" />
|
||||
<div className="h-5 w-80 rounded mt-3 bg-white/5 animate-pulse" />
|
||||
|
||||
{/* Cart items skeleton */}
|
||||
<div className="mt-10 space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="glass-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="h-5 w-40 rounded bg-white/5 animate-pulse" />
|
||||
<div className="h-4 w-24 rounded bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-11 w-11 rounded-xl bg-white/5 animate-pulse" />
|
||||
<div className="h-11 w-10 rounded bg-white/5 animate-pulse" />
|
||||
<div className="h-11 w-11 rounded-xl bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
|
||||
<div className="h-5 w-20 rounded bg-white/5 animate-pulse" />
|
||||
<div className="h-4 w-16 rounded bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar skeleton */}
|
||||
<aside>
|
||||
<div className="glass-card p-6 sticky top-6">
|
||||
<div className="h-6 w-32 rounded bg-white/5 animate-pulse" />
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="h-4 w-full rounded bg-white/5 animate-pulse" />
|
||||
<div className="h-4 w-3/4 rounded bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
<div className="mt-5 h-14 w-full rounded-xl bg-white/5 animate-pulse" />
|
||||
<div className="mt-4 h-4 w-32 mx-auto rounded bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Status for accessibility */}
|
||||
<span role="status" className="sr-only">Loading your cart...</span>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export default function ChangelogPage() {
|
||||
<main className="max-w-4xl mx-auto px-6 py-16">
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Changelog
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||
|
||||
@@ -1,65 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Processing...",
|
||||
description: "Loading checkout...",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
|
||||
<div className="h-20 border-b border-slate-100/50" />
|
||||
|
||||
<main className="px-6 py-12">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
|
||||
<div>
|
||||
{/* Title skeleton */}
|
||||
<div className="h-10 w-32 rounded-lg bg-slate-200 animate-pulse" />
|
||||
<div className="h-5 w-64 rounded mt-3 bg-slate-100 animate-pulse" />
|
||||
|
||||
{/* Form skeleton */}
|
||||
<div className="mt-8 space-y-6">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm border border-slate-100">
|
||||
<div className="h-6 w-40 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="h-12 w-full rounded-xl bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="h-12 w-full rounded-xl bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button skeleton */}
|
||||
<div className="h-14 w-full rounded-xl bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar skeleton */}
|
||||
<aside>
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm border border-slate-100 sticky top-6">
|
||||
<div className="h-6 w-32 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="mt-4 space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<div className="h-4 w-32 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<span role="status" className="sr-only">Loading checkout...</span>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,78 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Loading contact...",
|
||||
description: "Loading...",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header skeleton */}
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-xl border-b border-[#e5e5e5]">
|
||||
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-[#e5e5e5] to-[#f5f5f5] animate-pulse" />
|
||||
<div className="h-6 w-36 rounded bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
<div className="h-5 w-24 rounded bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-32">
|
||||
{/* Page header skeleton */}
|
||||
<div className="text-center mb-16">
|
||||
<div className="h-4 w-20 rounded-full bg-[#e5e5e5] mx-auto mb-4 animate-pulse" />
|
||||
<div className="h-12 w-64 rounded-lg bg-[#e5e5e5] mx-auto mb-4 animate-pulse" />
|
||||
<div className="h-5 w-96 rounded bg-[#e5e5e5] mx-auto animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Contact cards skeleton */}
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-16">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-3xl bg-white border border-[#e5e5e5] p-8">
|
||||
<div className="h-14 w-14 rounded-2xl bg-[#e5e5e5] mx-auto mb-5 animate-pulse" />
|
||||
<div className="h-5 w-24 rounded bg-[#e5e5e5] mx-auto mb-3 animate-pulse" />
|
||||
<div className="h-4 w-40 rounded bg-[#e5e5e5] mx-auto animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form skeleton */}
|
||||
<div className="rounded-3xl bg-white border border-[#e5e5e5] p-10">
|
||||
<div className="h-5 w-20 rounded-full bg-[#e5e5e5] mb-3 animate-pulse" />
|
||||
<div className="h-8 w-40 rounded bg-[#e5e5e5] mb-4 animate-pulse" />
|
||||
<div className="h-1 w-12 bg-[#e5e5e5] mb-8 animate-pulse" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-[#e5e5e5] animate-pulse" />
|
||||
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-[#e5e5e5] animate-pulse" />
|
||||
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-[#e5e5e5] animate-pulse" />
|
||||
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-[#e5e5e5] animate-pulse" />
|
||||
<div className="h-32 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
<div className="h-12 w-32 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<span role="status" className="sr-only">Loading contact page...</span>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Root error boundary — the safety net for any uncaught error in the
|
||||
* app. Renders inside the root layout, so it inherits the same font
|
||||
* variables and design tokens as everything else.
|
||||
*/
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
reset,
|
||||
@@ -9,44 +15,78 @@ export default function ErrorPage({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Surface to the console for development; the Sentry-ready logger
|
||||
// in src/lib/sentry.ts can be wired in here when keys are configured.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[app/error]", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-6 relative">
|
||||
{/* Background */}
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" />
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-red-500/5 rounded-full blur-3xl" />
|
||||
<main className="atelier-canvas relative min-h-screen flex items-center justify-center px-6 py-16 overflow-hidden">
|
||||
<div className="atelier-grain" aria-hidden="true" />
|
||||
|
||||
{/* Ambient orbs */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div
|
||||
className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-30"
|
||||
style={{ background: "radial-gradient(circle, rgba(185, 28, 28, 0.12) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-1/4 right-1/4 w-96 h-96 rounded-full opacity-20"
|
||||
style={{ background: "radial-gradient(circle, rgba(202, 138, 4, 0.16) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center max-w-md mx-auto relative">
|
||||
<div className="glass-card p-10">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-red-500/10 border border-red-500/20 mb-6">
|
||||
<svg className="w-8 h-8 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<div className="relative z-10 max-w-md w-full text-center atelier-enter">
|
||||
<div className="atelier-section-num justify-center mb-4">No. 500</div>
|
||||
|
||||
<h1 className="atelier-title text-5xl sm:text-6xl">
|
||||
Something <span className="atelier-italic text-[#786B53]">broke</span>
|
||||
</h1>
|
||||
<p className="mt-4 text-stone-600 text-sm leading-relaxed">
|
||||
We hit an unexpected snag loading this page. Our harvest crew has been notified.
|
||||
</p>
|
||||
|
||||
{error.message && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary className="atelier-fineprint cursor-pointer hover:text-stone-900 transition-colors">
|
||||
Error details
|
||||
</summary>
|
||||
<div className="mt-3 rounded-lg border border-stone-200 bg-white/70 backdrop-blur-sm p-3.5 text-xs font-mono text-stone-700 break-words">
|
||||
{error.message}
|
||||
{error.digest && (
|
||||
<div className="mt-2 pt-2 border-t border-stone-200 text-stone-500">
|
||||
digest: <span className="text-stone-700">{error.digest}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="atelier-rule my-8" />
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="atelier-cta"
|
||||
aria-label="Retry loading this page"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold text-white tracking-tight">Something went wrong</h1>
|
||||
<p className="mt-3 text-zinc-400 text-sm">
|
||||
{error.message || "An unexpected error occurred."}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="mt-2 text-xs text-zinc-600 font-mono">Digest: {error.digest}</p>
|
||||
)}
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 px-5 py-2.5 text-sm font-medium text-white transition-all backdrop-blur-sm"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
</div>
|
||||
<span className="relative z-10">Try again</span>
|
||||
<span className="atelier-cta-shimmer" aria-hidden="true" />
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="atelier-discard text-center"
|
||||
>
|
||||
← Return home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,87 @@
|
||||
--color-citrus-900: #7c2d12;
|
||||
}
|
||||
|
||||
/* Field Almanac palette — values are intentionally equal to the existing
|
||||
* --color-surface-50 (== --color-surface-2) and --color-surface-100
|
||||
* (== --color-surface-3). The new tokens live in :root (not @theme) so
|
||||
* they don't collide with the existing @theme token names. The duplication
|
||||
* is intentional until the legacy @theme palette is consolidated. */
|
||||
/* ─── Field Almanac palette (mobile-first / HIG Accessibility) ─── */
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #faf8f5;
|
||||
--color-surface-2: #f5f5f7;
|
||||
--color-surface-3: #e8e8ed;
|
||||
|
||||
/* Text — all AAA on every surface (see design-tokens.test.ts) */
|
||||
--color-text: #1d1d1f;
|
||||
--color-text-muted: #424245;
|
||||
--color-text-faint: #5e5e63; /* AA on every surface (>= 5.28:1) */
|
||||
|
||||
/* Accent / status — darkened to pass AAA on the surfaces they actually
|
||||
* appear on (bg, surface, and their -soft pill backgrounds). The darker
|
||||
* hues (green-900 / red-900 / amber-900) intentionally trade a touch of
|
||||
* vibrancy for legibility in outdoor / sun-glare conditions. */
|
||||
--color-accent: #14532d; /* AAA on bg (9.11), surface (8.59), s2 (8.37), s3 (7.46) */
|
||||
--color-accent-2: #166534; /* hover state — used as button background, NOT text */
|
||||
--color-accent-soft: #dcfce7; /* pill background; accent text on it = 8.36:1 */
|
||||
--color-danger: #7f1d1d; /* AAA on bg (10.02), surface (9.45), s2 (9.20), s3 (8.20) */
|
||||
--color-danger-soft: #fee2e2;
|
||||
--color-warning: #5e2a04; /* AAA on bg (11.61), surface (10.95), s2 (10.66), s3 (9.51) */
|
||||
--color-warning-soft: #fef9c3;
|
||||
--color-success: #14532d; /* mirrors accent — both are "positive" semantics */
|
||||
--color-success-soft: #dcfce7;
|
||||
--color-info: #1e3a8a; /* AAA on every surface (8.48–10.36) */
|
||||
--color-info-soft: #dbeafe;
|
||||
}
|
||||
|
||||
/* ─── Type scale (HIG Accessibility-tier) ─── */
|
||||
:root {
|
||||
--text-display: 32px;
|
||||
--text-display--line-height: 1.15;
|
||||
--text-display--letter-spacing: -0.01em;
|
||||
|
||||
--text-h1: 24px;
|
||||
--text-h1--line-height: 1.2;
|
||||
|
||||
--text-h2: 19px;
|
||||
--text-h2--line-height: 1.3;
|
||||
|
||||
--text-body: 18px;
|
||||
--text-body--line-height: 1.45;
|
||||
|
||||
--text-label: 15px;
|
||||
--text-label--line-height: 1.3;
|
||||
--text-label--letter-spacing: 0.02em;
|
||||
|
||||
--text-mono: 14px;
|
||||
--text-mono--line-height: 1.4;
|
||||
|
||||
--text-meta: 13px;
|
||||
--text-meta--line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ─── Spacing (8pt grid) ─── */
|
||||
:root {
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
}
|
||||
|
||||
/* ─── Motion ─── */
|
||||
:root {
|
||||
--ease-apple: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--duration-fast: 150ms;
|
||||
--duration-base: 200ms;
|
||||
--duration-slow: 320ms;
|
||||
}
|
||||
|
||||
/* ─── Base ─────────────────────────────────────────────────────── */
|
||||
|
||||
*, *::before, *::after {
|
||||
@@ -177,6 +258,97 @@ select:-webkit-autofill:focus {
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
/* ─── View Transitions (route fades) ────────────────────────────────────── */
|
||||
/*
|
||||
* Native browser View Transitions API, opt-in via the
|
||||
* <ViewTransition name="page-content"> component and Next.js's
|
||||
* experimental.viewTransition config. These rules define the actual
|
||||
* crossfade / slide. Without them, transitions still work but use
|
||||
* the browser default (instant cut).
|
||||
*
|
||||
* 220ms is the sweet spot for route changes: long enough to soften
|
||||
* the cut, short enough to feel like a single app. The cubic-bezier
|
||||
* keeps the start crisp and the finish gentle.
|
||||
*/
|
||||
/* Route fade — pure opacity, no slide. The old "translateY(6px) on enter,
|
||||
* translateY(-4px) on exit" was a classic multi-axis vestibular trigger.
|
||||
* 140ms total is the sweet spot: barely there, but softens the cut. */
|
||||
::view-transition-old(page-content) {
|
||||
animation: route-fade-out 90ms ease-out both;
|
||||
}
|
||||
::view-transition-new(page-content) {
|
||||
animation: route-fade-in 140ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes route-fade-out {
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes route-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Top-of-page shimmer for the LoadingFade placeholder bar. */
|
||||
@keyframes transition-shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
|
||||
/* Spinner for the PullToRefresh indicator. The indicator uses
|
||||
* `border-top-color` to give the impression of one bright edge rotating.
|
||||
* Reduced-motion users get `animation: none` from the component, so this
|
||||
* keyframe doesn't need a motion-guard. */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Reduce motion: respect the user's OS-level preference. */
|
||||
/* The motion pass deliberately keeps the visual design intact (colors, type, layout)
|
||||
* but flattens everything that fights vestibular comfort. This block wipes:
|
||||
* - all CSS animations and transitions across the app
|
||||
* - all view transitions and route fades
|
||||
* - GSAP, framer-motion, and any scroll-driven transforms (those need JS-side guards
|
||||
* in their components, but the CSS-side transforms inherit this via inheritance).
|
||||
* If you want even less, just set prefers-reduced-motion: reduce in your OS and
|
||||
* every animation in the app turns off. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
::view-transition-old(page-content),
|
||||
::view-transition-new(page-content) {
|
||||
animation: none !important;
|
||||
}
|
||||
@keyframes transition-shimmer {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
}
|
||||
.atelier-enter,
|
||||
.atelier-backdrop,
|
||||
.atelier-stagger > *,
|
||||
.atelier-cta .atelier-cta-shimmer,
|
||||
.parallax-float,
|
||||
.animate-bounce,
|
||||
.hero-reveal {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
.atelier-stagger > *,
|
||||
.parallax-float,
|
||||
.hero-reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
/* Scroll progress bar (Tuxedo hero) — no animated width when motion is off */
|
||||
.scroll-progress-bar {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Light mode placeholder (used by storefront) */
|
||||
.light ::placeholder,
|
||||
.light::-webkit-input-placeholder,
|
||||
@@ -226,7 +398,7 @@ select:-webkit-autofill:focus {
|
||||
box-shadow:
|
||||
0 16px 48px rgba(0, 0, 0, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
transform: translateY(-2px);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ─── Premium card - Light Theme ─────────────────────────────────────── */
|
||||
@@ -257,10 +429,10 @@ select:-webkit-autofill:focus {
|
||||
0 8px 32px rgba(0, 0, 0, 0.06),
|
||||
0 0 0 1px rgba(26, 77, 46, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
transform: translateY(-2px);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.card:active {
|
||||
transform: scale(0.98) translateY(0);
|
||||
transform: scale(0.995) translateY(0);
|
||||
}
|
||||
|
||||
.card-dark {
|
||||
@@ -313,10 +485,11 @@ select:-webkit-autofill:focus {
|
||||
box-shadow:
|
||||
0 8px 20px rgba(26, 77, 46, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-1px);
|
||||
/* No vertical lift on hover — was a 1px translate that compounded with the gradient
|
||||
* shift and the inset highlight to read as jittery on the eye. */
|
||||
}
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98) translateY(0);
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
.btn-primary-green {
|
||||
@@ -342,7 +515,6 @@ select:-webkit-autofill:focus {
|
||||
box-shadow:
|
||||
0 8px 20px rgba(22, 163, 74, 0.4),
|
||||
inset 0 1px 0 rgba(255,255,255,0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ─── Gradient text ───────────────────────────────────────────── */
|
||||
@@ -665,13 +837,12 @@ select:-webkit-autofill:focus {
|
||||
.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);
|
||||
transform: scale(0.995);
|
||||
}
|
||||
.atelier-type-card.is-selected {
|
||||
background: linear-gradient(135deg, #224E2F 0%, #14532D 100%);
|
||||
@@ -683,11 +854,13 @@ select:-webkit-autofill:focus {
|
||||
}
|
||||
.atelier-type-card .atelier-type-icon {
|
||||
color: #786B53;
|
||||
transition: color 220ms ease, transform 220ms ease;
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
.atelier-type-card.is-selected .atelier-type-icon {
|
||||
color: #FCD34D;
|
||||
transform: scale(1.06);
|
||||
/* Removed the 1.06 scale on selected — the color change alone communicates the
|
||||
* selection. Scale + color change in the same beat read as "double confirmation",
|
||||
* which adds up when many cards animate at once. */
|
||||
}
|
||||
.atelier-type-card .atelier-type-name {
|
||||
font-family: var(--font-fraunces);
|
||||
@@ -704,12 +877,11 @@ select:-webkit-autofill:focus {
|
||||
.atelier-type-card .atelier-type-check {
|
||||
opacity: 0;
|
||||
color: #FCD34D;
|
||||
transform: scale(0.6);
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
.atelier-type-card.is-selected .atelier-type-check {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
/* Removed the scale(0.6 → 1) on the checkmark — pure opacity fade is calmer. */
|
||||
}
|
||||
|
||||
/* Pill toggle — Active / Taxable */
|
||||
@@ -746,7 +918,11 @@ select:-webkit-autofill:focus {
|
||||
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);
|
||||
/* Was cubic-bezier(0.34, 1.56, 0.64, 1) — the bouncy overshoot ease that
|
||||
* overshoots ~13% past the target then settles. Looks playful on a static
|
||||
* design, but in a real product it reads as twitchy and contributes to the
|
||||
* sense of "too much movement". */
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
.atelier-toggle.is-on .atelier-toggle-thumb {
|
||||
transform: translateX(16px);
|
||||
@@ -876,35 +1052,38 @@ select:-webkit-autofill:focus {
|
||||
color: #A8A29E;
|
||||
}
|
||||
|
||||
/* Modal enter animation */
|
||||
/* Modal enter animation — calmer version. The previous 420ms scale + 20px slide
|
||||
* with 80-440ms cumulative stagger delays meant the modal kept moving for nearly
|
||||
* a full second. Now: 180ms opacity-only fade-in, 4px max, no scale, no stagger. */
|
||||
@keyframes atelier-enter {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes atelier-backdrop {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes atelier-stagger {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.atelier-enter {
|
||||
animation: atelier-enter 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation: atelier-enter 180ms ease-out both;
|
||||
}
|
||||
.atelier-backdrop {
|
||||
animation: atelier-backdrop 300ms ease both;
|
||||
animation: atelier-backdrop 140ms ease-out both;
|
||||
}
|
||||
.atelier-stagger > * {
|
||||
animation: atelier-stagger 480ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation: atelier-stagger 180ms ease-out both;
|
||||
/* No more 80-440ms cumulative delays — items appear together, not as a parade. */
|
||||
}
|
||||
.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; }
|
||||
.atelier-stagger > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.atelier-stagger > *:nth-child(2) { animation-delay: 30ms; }
|
||||
.atelier-stagger > *:nth-child(3) { animation-delay: 60ms; }
|
||||
.atelier-stagger > *:nth-child(4) { animation-delay: 90ms; }
|
||||
.atelier-stagger > *:nth-child(5) { animation-delay: 120ms; }
|
||||
.atelier-stagger > *:nth-child(6) { animation-delay: 150ms; }
|
||||
.atelier-stagger > *:nth-child(7) { animation-delay: 180ms; }
|
||||
|
||||
/* Primary CTA — gradient with subtle inner highlight */
|
||||
.atelier-cta {
|
||||
@@ -929,7 +1108,7 @@ select:-webkit-autofill:focus {
|
||||
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);
|
||||
transition: background-position 220ms ease-out, box-shadow 180ms ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
.atelier-cta:hover {
|
||||
@@ -938,10 +1117,9 @@ select:-webkit-autofill:focus {
|
||||
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);
|
||||
transform: scale(0.995);
|
||||
}
|
||||
.atelier-cta:disabled {
|
||||
opacity: 0.5;
|
||||
@@ -958,7 +1136,9 @@ select:-webkit-autofill:focus {
|
||||
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);
|
||||
/* Was 700ms — the shimmer is decorative but a long linear slide on every
|
||||
* hover draws the eye. 400ms keeps the highlight but doesn't linger. */
|
||||
transition: left 400ms ease-out;
|
||||
}
|
||||
.atelier-cta:hover .atelier-cta-shimmer {
|
||||
left: 130%;
|
||||
@@ -1030,3 +1210,75 @@ select:-webkit-autofill:focus {
|
||||
color: #991B1B;
|
||||
}
|
||||
.atelier-error svg { flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
/* ─── Polish utilities — "Atelier des Récoltes" additions ─────── */
|
||||
|
||||
/* Tabular numerals for prices, quantities, dates */
|
||||
.atelier-numerals {
|
||||
font-family: var(--font-fraunces);
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
/* Inline editorial pill — for tags, badges, status chips */
|
||||
.atelier-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px 4px 8px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
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;
|
||||
white-space: nowrap;
|
||||
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.atelier-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-color: rgba(34, 78, 47, 0.25);
|
||||
}
|
||||
.atelier-pill .atelier-pill-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #16A34A;
|
||||
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
||||
}
|
||||
.atelier-pill.is-gold .atelier-pill-dot {
|
||||
background: #CA8A04;
|
||||
box-shadow: 0 0 0 2px rgba(202, 138, 4, 0.18);
|
||||
}
|
||||
.atelier-pill.is-muted .atelier-pill-dot {
|
||||
background: #A8A29E;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Refined focus ring for atelier inputs (replaces default browser ring) */
|
||||
.atelier-input:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 1px 0 0 #224E2F;
|
||||
}
|
||||
|
||||
/* Small caps editorial small-text — for footnote, fine print */
|
||||
.atelier-fineprint {
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #786B53;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Soft cream canvas (lighter than atelier-canvas) — for sub-pages */
|
||||
.atelier-canvas-soft {
|
||||
background-color: #FDFBF6;
|
||||
background-image:
|
||||
radial-gradient(ellipse 60% 40% at 50% 0%, rgba(180, 155, 100, 0.06) 0%, transparent 60%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||