Compare commits
51 Commits
v2.0.0
...
7e079e0186
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/
|
||||
|
||||
@@ -47,3 +47,4 @@ playwright-report/
|
||||
.mcp.json
|
||||
.env*
|
||||
public/videos/tuxedo-hero.mp4
|
||||
.neon
|
||||
|
||||
@@ -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());
|
||||
@@ -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"),
|
||||
|
||||
+144
-2
@@ -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;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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,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
|
||||
+8
-2
@@ -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,
|
||||
@@ -111,6 +112,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",
|
||||
|
||||
@@ -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,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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+347
-46
@@ -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) {
|
||||
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,29 +184,181 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
||||
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.
|
||||
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;
|
||||
const { rows } = await query<Record<string, unknown>>(
|
||||
|
||||
let neonAuthUserId: string;
|
||||
let authPath: "admin" | "signup" = "admin";
|
||||
try {
|
||||
// 2. Create the Neon Auth account. Try the privileged admin
|
||||
// endpoint first.
|
||||
const adminResult = await neonAuthCreateUser({
|
||||
email,
|
||||
password,
|
||||
name: displayName,
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
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,'pending',$17)
|
||||
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`,
|
||||
[
|
||||
null,
|
||||
input.display_name ?? input.email.split("@")[0],
|
||||
input.email.toLowerCase(),
|
||||
neonAuthUserId,
|
||||
displayName,
|
||||
email,
|
||||
input.phone_number ?? null,
|
||||
input.role,
|
||||
input.brand_id,
|
||||
@@ -206,41 +372,138 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
|
||||
f.can_manage_water_log ?? false,
|
||||
f.can_manage_reports ?? false,
|
||||
input.mustChangePassword ?? true,
|
||||
input.email.toLowerCase(),
|
||||
neonAuthUserId,
|
||||
],
|
||||
);
|
||||
if (!rows[0]) return { user: null, error: "Insert returned no row" };
|
||||
if (!ins.rows[0]) throw new Error("admin_users insert returned no row");
|
||||
|
||||
const newAdminId = String(rows[0].id);
|
||||
|
||||
// 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(
|
||||
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.`,
|
||||
};
|
||||
}
|
||||
|
||||
await sendWelcomeEmailSafe({
|
||||
to: input.email,
|
||||
name: input.display_name ?? input.email.split("@")[0],
|
||||
// 4. Welcome email (best-effort).
|
||||
const emailResult = await sendWelcomeEmailSafe({
|
||||
to: email,
|
||||
name: displayName,
|
||||
role: input.role,
|
||||
password: input.password,
|
||||
password,
|
||||
brandId: input.brand_id ?? undefined,
|
||||
});
|
||||
|
||||
return { user: mapUserRow(rows[0]), error: null };
|
||||
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})`,
|
||||
};
|
||||
}
|
||||
|
||||
const userId = data.user?.id;
|
||||
if (!userId) {
|
||||
return { userId: null, error: "Sign-up response missing user.id" };
|
||||
}
|
||||
|
||||
// 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 }> {
|
||||
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: "Password reset is handled by a platform admin. Contact them to reset your access.",
|
||||
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
|
||||
}
|
||||
|
||||
+42
-32
@@ -42,24 +42,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 +65,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 +104,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 +114,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 +176,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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+957
-191
File diff suppressed because it is too large
Load Diff
+398
-61
@@ -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 = {
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type FieldHeadgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
status: string;
|
||||
high_threshold: number | null;
|
||||
low_threshold: number | null;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type VerifyPinResult =
|
||||
| {
|
||||
success: true;
|
||||
user_id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
role: "irrigator" | "water_admin";
|
||||
session_id: string;
|
||||
lang: string;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
}
|
||||
| { success: false; error: string };
|
||||
|
||||
type SubmitEntryResult = {
|
||||
success: true;
|
||||
entry_id: string;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
type SubmitEntryResult =
|
||||
| { success: true; entry_id: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
type Headgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
|
||||
// ── 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> {
|
||||
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,10 @@ 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 { 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 +101,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,9 +119,21 @@ 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>
|
||||
</ToastProviderWrapper>
|
||||
);
|
||||
|
||||
+11
-44
@@ -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">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Back link */}
|
||||
<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"
|
||||
className="inline-flex items-center gap-2 text-xs font-semibold transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<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>
|
||||
<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>
|
||||
<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 && (
|
||||
<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>
|
||||
<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">
|
||||
<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="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="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<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 (
|
||||
<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,81 +63,155 @@ 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)" }}>
|
||||
<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">
|
||||
<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"}
|
||||
<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="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
||||
className="ha-btn-ghost mt-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>
|
||||
</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) => ({
|
||||
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: ps.products ? {
|
||||
name: ps.products.name,
|
||||
type: ps.products.type,
|
||||
price: ps.products.price,
|
||||
image_url: ps.products.image_url,
|
||||
} : null,
|
||||
}))
|
||||
.filter(Boolean);
|
||||
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)" }}>
|
||||
<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="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 className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
<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-stone-500">
|
||||
<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-stone-950">
|
||||
<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-stone-600">{stop.location}</p>
|
||||
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">{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"
|
||||
? "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"}
|
||||
@@ -148,31 +220,31 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
|
||||
<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">
|
||||
<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-stone-500">Time</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
<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-stone-500">Address</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
<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_time && (
|
||||
{stop.cutoff_date && (
|
||||
<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 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>
|
||||
)}
|
||||
@@ -182,33 +254,33 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
<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"
|
||||
className="ha-btn-ghost"
|
||||
>
|
||||
Duplicate Stop
|
||||
</a>
|
||||
</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">
|
||||
<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-stone-600">
|
||||
<p className="mt-1 text-[var(--admin-text-secondary)]">
|
||||
Manage which products are available at this stop.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<StopProductAssignment
|
||||
stopId={stop.id}
|
||||
allProducts={allProducts ?? []}
|
||||
allProducts={allProducts}
|
||||
assignedProducts={assignedProducts}
|
||||
callerUid={adminUser.user_id}
|
||||
/>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -221,22 +293,22 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
date: stop.date,
|
||||
time: stop.time,
|
||||
location: stop.location,
|
||||
slug: stop.slug,
|
||||
slug: stop.brands?.slug ?? "",
|
||||
active: stop.active,
|
||||
brand_id: stop.brand_id,
|
||||
address: stop.address,
|
||||
zip: stop.zip,
|
||||
cutoff_time: stop.cutoff_time,
|
||||
}}
|
||||
brands={brands ?? []}
|
||||
brands={brandRows ?? []}
|
||||
/>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -245,6 +317,7 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,51 +40,69 @@ 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">
|
||||
<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={pageTitle}
|
||||
subtitle={pageSubtitle}
|
||||
icon={<StopIcon />}
|
||||
/>
|
||||
</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>
|
||||
<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"
|
||||
>
|
||||
<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="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}
|
||||
<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>
|
||||
</div>
|
||||
</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" }}
|
||||
/>
|
||||
<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={() => setRevealedPin(null)}
|
||||
className="text-xs font-medium text-[#5a5d5a] underline-offset-2 hover:underline"
|
||||
>
|
||||
I've saved it — dismiss
|
||||
</button>
|
||||
</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 */}
|
||||
<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">
|
||||
<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={72}
|
||||
max={168}
|
||||
value={sessionDuration}
|
||||
onChange={(e) => setSessionDuration(parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
onChange={(e) => setSessionDuration(parseInt(e.target.value, 10))}
|
||||
className="flex-1 accent-[#1a4d2e]"
|
||||
aria-label="Session duration in hours"
|
||||
/>
|
||||
<span className="w-16 text-center text-sm font-bold text-zinc-100">{sessionDuration}h</span>
|
||||
</div>
|
||||
<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 */}
|
||||
<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>
|
||||
</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"}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${value ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<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>
|
||||
</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 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>
|
||||
)}
|
||||
</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 });
|
||||
}
|
||||
|
||||
// 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],
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Missing brandId or pin" },
|
||||
{ status: 400 },
|
||||
);
|
||||
const settings = settingsRes.rows[0]?.get_water_admin_settings;
|
||||
if (!settings?.enabled) {
|
||||
return NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 });
|
||||
}
|
||||
|
||||
// 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 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 },
|
||||
);
|
||||
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 />;
|
||||
}
|
||||
+63
-23
@@ -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" />
|
||||
</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."}
|
||||
<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 && (
|
||||
<p className="mt-2 text-xs text-zinc-600 font-mono">Digest: {error.digest}</p>
|
||||
<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 className="mt-8 flex items-center justify-center gap-4">
|
||||
</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="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"
|
||||
className="atelier-cta"
|
||||
aria-label="Retry loading this page"
|
||||
>
|
||||
Try again
|
||||
<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>
|
||||
<span className="relative z-10">Try again</span>
|
||||
<span className="atelier-cta-shimmer" aria-hidden="true" />
|
||||
</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"
|
||||
className="atelier-discard text-center"
|
||||
>
|
||||
Go home
|
||||
← Return home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+195
-32
@@ -177,6 +177,89 @@ 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%); }
|
||||
}
|
||||
|
||||
/* 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 +309,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 +340,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 +396,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 +426,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 +748,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 +765,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 +788,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 +829,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 +963,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 +1019,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 +1028,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 +1047,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 +1121,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;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-4xl px-6 py-20">
|
||||
{/* Hero skeleton */}
|
||||
<div className="mb-20 animate-pulse text-center">
|
||||
<div className="h-3 w-24 rounded bg-blue-100 mx-auto mb-5" />
|
||||
<div className="h-20 w-full max-w-xl mx-auto rounded-lg bg-blue-50 mb-5" />
|
||||
<div className="h-8 w-full max-w-2xl mx-auto rounded bg-blue-50" />
|
||||
</div>
|
||||
|
||||
{/* Content sections skeleton */}
|
||||
<div className="space-y-20">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="grid md:grid-cols-2 gap-12 items-center"
|
||||
>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 w-20 rounded bg-blue-100" />
|
||||
<div className="h-8 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
||||
</div>
|
||||
<div className="h-64 rounded-3xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,62 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-4xl px-6 py-20">
|
||||
{/* Header skeleton */}
|
||||
<div className="mb-16 animate-pulse text-center">
|
||||
<div className="h-4 w-20 rounded bg-blue-100 mx-auto mb-5" />
|
||||
<div className="h-16 w-64 mx-auto rounded-lg bg-stone-200 mb-5" />
|
||||
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Contact cards skeleton */}
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-16">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="rounded-3xl bg-white border-2 border-stone-200 p-8 shadow-xl text-center"
|
||||
>
|
||||
<div className="h-14 w-14 rounded-2xl bg-blue-100 mx-auto mb-5" />
|
||||
<div className="h-5 w-24 mx-auto rounded bg-stone-200 mb-3" />
|
||||
<div className="h-4 w-full max-w-[160px] mx-auto rounded bg-stone-100" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form skeleton */}
|
||||
<div className="rounded-3xl bg-white border-2 border-stone-200 p-10 shadow-xl">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 w-32 rounded bg-stone-200" />
|
||||
<div className="h-8 w-48 rounded bg-stone-200" />
|
||||
<div className="grid gap-6 md:grid-cols-2 mt-8">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
<div className="h-14 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
<div className="h-14 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-stone-100" />
|
||||
<div className="h-14 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
<div className="h-32 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
<div className="h-14 w-40 rounded-xl bg-blue-100 mt-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,41 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-3xl px-6 py-20">
|
||||
{/* Header skeleton */}
|
||||
<div className="mb-14 text-center animate-pulse">
|
||||
<div className="h-4 w-16 rounded bg-blue-100 mx-auto mb-4" />
|
||||
<div className="h-16 w-48 mx-auto rounded-lg bg-stone-200 mb-5" />
|
||||
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Search skeleton */}
|
||||
<div className="mb-12">
|
||||
<div className="h-14 rounded-2xl bg-white border-2 border-stone-200 shadow-lg" />
|
||||
</div>
|
||||
|
||||
{/* FAQ items skeleton */}
|
||||
<div className="space-y-4">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="rounded-2xl bg-white border-2 border-stone-200 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-5">
|
||||
<div className="h-5 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-8 w-8 rounded-full bg-blue-100" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,23 +1,35 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
// BreadcrumbList schema for SEO
|
||||
const indianRiverBreadcrumbSchema = {
|
||||
// Structured data for the Indian River Direct storefront.
|
||||
// LocalBusiness + BreadcrumbList for the family farm brand.
|
||||
const indianRiverStructuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": BASE_URL,
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Home", item: BASE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Indian River Direct", item: `${BASE_URL}/indian-river-direct` },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Indian River Direct",
|
||||
"item": `${BASE_URL}/indian-river-direct`,
|
||||
"@type": "LocalBusiness",
|
||||
"@id": `${BASE_URL}/indian-river-direct/#localbusiness`,
|
||||
name: "Indian River Direct",
|
||||
description:
|
||||
"Fresh peaches and citrus from our Florida groves to truckload sales in your neighborhood. Family-owned since 1985.",
|
||||
url: `${BASE_URL}/indian-river-direct`,
|
||||
image: `${BASE_URL}/og-default.svg`,
|
||||
priceRange: "$$",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressRegion: "FL",
|
||||
addressCountry: "US",
|
||||
},
|
||||
foundingDate: "1985",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -41,7 +53,7 @@ export const metadata: Metadata = {
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/og-indian-river.jpg",
|
||||
url: "/og-default.svg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Indian River Direct - Fresh Peaches & Citrus",
|
||||
@@ -54,7 +66,7 @@ export const metadata: Metadata = {
|
||||
description: "Fresh peaches and citrus from our Florida groves. Family-owned since 1985. Pre-order for 2026 season.",
|
||||
site: "@IndianRiverDirect",
|
||||
creator: "@IndianRiverDirect",
|
||||
images: ["/og-indian-river.jpg"],
|
||||
images: ["/og-default.svg"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/indian-river-direct`,
|
||||
@@ -68,7 +80,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
},
|
||||
other: {
|
||||
"application/ld+json": JSON.stringify(indianRiverBreadcrumbSchema),
|
||||
"application/ld+json": JSON.stringify(indianRiverStructuredData),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -91,8 +103,10 @@ export default function IndianRiverLayout({ children }: { children: React.ReactN
|
||||
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-gradient-to-br from-blue-50/40 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Content wrapper */}
|
||||
<div className="relative">{children}</div>
|
||||
{/* Content wrapper — crossfades on navigation. */}
|
||||
<div id="page-content" className="relative outline-none">
|
||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +1,7 @@
|
||||
"use client";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="min-h-screen bg-white/50 backdrop-blur-xl">
|
||||
<div className="mx-auto max-w-6xl px-6 py-16">
|
||||
{/* Header skeleton with blue accent */}
|
||||
<div className="mb-16 animate-pulse">
|
||||
<div className="h-3 w-20 rounded bg-blue-100 mb-4" />
|
||||
<div className="h-16 w-80 rounded-lg bg-blue-50 mb-4" />
|
||||
<div className="h-6 w-96 max-w-full rounded bg-blue-50" />
|
||||
</div>
|
||||
|
||||
{/* Product cards skeleton */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="rounded-3xl bg-white border-2 border-stone-200 overflow-hidden shadow-lg"
|
||||
>
|
||||
{/* Image skeleton */}
|
||||
<div className="h-48 bg-gradient-to-br from-blue-50 to-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-blue-100/60 to-transparent" />
|
||||
</div>
|
||||
{/* Content skeleton */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-6 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
||||
<div className="pt-4 flex items-center justify-between">
|
||||
<div className="h-8 w-20 rounded-lg bg-blue-50" />
|
||||
<div className="h-11 w-28 rounded-xl bg-blue-100" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stops section skeleton */}
|
||||
<div className="mt-20">
|
||||
<div className="animate-pulse mb-10">
|
||||
<div className="h-3 w-24 rounded bg-blue-100 mb-4" />
|
||||
<div className="h-10 w-64 rounded-lg bg-blue-50 mb-3" />
|
||||
<div className="h-4 w-48 rounded bg-blue-50" />
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="rounded-3xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100 p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="h-6 w-32 rounded bg-stone-200 mb-2" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
</div>
|
||||
<div className="h-6 w-16 rounded-full bg-blue-100" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="h-4 w-28 rounded bg-stone-100" />
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
/** Indian River Direct storefront loading — light blue glass backdrop
|
||||
* stays mounted in the layout. */
|
||||
export default function IndianRiverLoading() {
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,70 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50/80 backdrop-blur-xl">
|
||||
<div className="mx-auto max-w-5xl px-6 py-20">
|
||||
{/* Back navigation skeleton */}
|
||||
<div className="mb-10 animate-pulse">
|
||||
<div className="h-5 w-32 rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Stop header skeleton */}
|
||||
<div className="mb-12 animate-pulse rounded-3xl bg-white/60 border border-white/50 p-8">
|
||||
<div className="h-3 w-32 rounded bg-blue-100 mb-4" />
|
||||
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
|
||||
<div className="h-5 w-full max-w-md rounded bg-stone-200 mb-4" />
|
||||
<div className="h-px w-12 bg-blue-100" />
|
||||
</div>
|
||||
|
||||
{/* Stop info skeleton */}
|
||||
<div className="mb-14 rounded-3xl bg-white/80 border border-white/50 p-8 shadow-xl">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
|
||||
>
|
||||
<div className="h-10 w-10 rounded-xl bg-blue-50" />
|
||||
<div className="flex-1">
|
||||
<div className="h-3 w-12 rounded bg-stone-200 mb-2" />
|
||||
<div className="h-5 w-24 rounded bg-stone-200" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products section skeleton */}
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 w-20 rounded bg-blue-100 mb-4" />
|
||||
<div className="h-10 w-48 rounded bg-stone-200 mb-4" />
|
||||
<div className="h-4 w-64 rounded bg-stone-200 mb-10" />
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="rounded-3xl bg-white overflow-hidden shadow-lg"
|
||||
>
|
||||
<div className="h-48 bg-gradient-to-br from-blue-50 to-white" />
|
||||
<div className="p-6 space-y-3">
|
||||
<div className="h-6 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
+7
-42
@@ -1,8 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Loading Route Commerce...",
|
||||
description: "Loading...",
|
||||
title: "Loading — Route Commerce",
|
||||
description: "Loading content from Route Commerce",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
@@ -10,44 +11,8 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden" style={{ backgroundColor: "#faf8f5" }}>
|
||||
{/* Subtle loading animation */}
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div
|
||||
className="w-16 h-16 rounded-2xl flex items-center justify-center animate-pulse"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #1a4d2e 0%, #166534 100%)",
|
||||
boxShadow: "0 8px 32px rgba(26, 77, 46, 0.3)"
|
||||
}}
|
||||
aria-label="Loading"
|
||||
role="status"
|
||||
>
|
||||
<svg className="w-8 h-8 text-white animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
Loading
|
||||
</p>
|
||||
<p className="text-sm text-stone-500 mt-1">Please wait...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div
|
||||
className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-10"
|
||||
style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-10"
|
||||
style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// No skeleton. The previous page stays visible while the next one
|
||||
// streams in, and the View Transition API crossfades them. This
|
||||
// thin top bar is the only signal that navigation is in flight.
|
||||
return <LoadingFade />;
|
||||
}
|
||||
+172
-93
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useId } from "react";
|
||||
|
||||
type LoginClientProps = {
|
||||
error: string | null;
|
||||
@@ -12,9 +12,12 @@ const REDIRECT_URL = "/admin";
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
export default function LoginClient({ error }: LoginClientProps) {
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [googleLoading, setGoogleLoading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
async function handleSignIn() {
|
||||
@@ -48,160 +51,236 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleSignIn() {
|
||||
setGoogleLoading(true);
|
||||
setLocalError(null);
|
||||
try {
|
||||
const { signInWithGoogleAction } = await import("@/actions/auth-actions");
|
||||
const result = await signInWithGoogleAction({ callbackURL: REDIRECT_URL });
|
||||
if (result.error || !result.url) {
|
||||
setLocalError(
|
||||
result.error ??
|
||||
"Google sign-in is not available. Contact your administrator or sign in with email + password.",
|
||||
);
|
||||
setGoogleLoading(false);
|
||||
return;
|
||||
}
|
||||
window.location.href = result.url;
|
||||
} catch {
|
||||
setLocalError("Google sign-in failed. Please try again.");
|
||||
setGoogleLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDevLogin(role: string) {
|
||||
// Set the dev_session cookie for local development bypass
|
||||
document.cookie = `dev_session=${role}; path=/; max-age=86400`;
|
||||
window.location.href = REDIRECT_URL;
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className="min-h-screen flex flex-col relative overflow-hidden"
|
||||
style={{ backgroundColor: "#faf8f5", height: "100vh" }}
|
||||
>
|
||||
<style jsx global>{`
|
||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
||||
html, body { overflow: hidden; }
|
||||
`}</style>
|
||||
const displayError = error ?? localError;
|
||||
|
||||
return (
|
||||
<main className="atelier-canvas relative min-h-screen flex flex-col overflow-hidden">
|
||||
{/* Decorative grain layer */}
|
||||
<div className="atelier-grain" aria-hidden="true" />
|
||||
|
||||
{/* Ambient background flourishes */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
|
||||
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
|
||||
<div className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10" style={{ background: "radial-gradient(circle, #1a4d2e15 0%, transparent 70%)", filter: "blur(30px)" }} />
|
||||
<div
|
||||
className="absolute -top-32 -right-32 w-[28rem] h-[28rem] rounded-full opacity-30"
|
||||
style={{ background: "radial-gradient(circle at 30% 30%, rgba(202, 138, 4, 0.18) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-48 -left-48 w-[36rem] h-[36rem] rounded-full opacity-25"
|
||||
style={{ background: "radial-gradient(circle at 70% 70%, rgba(34, 78, 47, 0.16) 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" />
|
||||
{/* Editorial corner flourish */}
|
||||
<div className="absolute top-6 left-6 atelier-flourish w-20 h-20 opacity-40" aria-hidden="true" />
|
||||
|
||||
<div className="p-8 sm:p-10">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Editorial numeral + section label */}
|
||||
<div className="text-center mb-6">
|
||||
<span className="atelier-section-num">No. 01</span>
|
||||
</div>
|
||||
|
||||
<div className="atelier-enter relative bg-white/90 backdrop-blur-xl rounded-2xl ring-1 ring-stone-200/80 shadow-[0_24px_64px_-24px_rgba(20,83,45,0.25)] overflow-hidden">
|
||||
{/* Top hairline rule */}
|
||||
<div className="atelier-rule" />
|
||||
|
||||
<div className="px-8 sm:px-10 py-10">
|
||||
{/* Brand mark */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div
|
||||
className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5"
|
||||
className="inline-flex h-14 w-14 items-center justify-center rounded-2xl mb-5"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
||||
boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)",
|
||||
background: "linear-gradient(135deg, #14532D 0%, #1F6B3E 50%, #14532D 100%)",
|
||||
boxShadow: "0 10px 28px -6px rgba(20, 83, 45, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12)",
|
||||
}}
|
||||
>
|
||||
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
className="text-3xl font-semibold text-stone-900"
|
||||
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
Welcome back
|
||||
<h1 className="atelier-title text-3xl sm:text-4xl text-center">
|
||||
Welcome <span className="atelier-italic text-[#786B53]">back</span>
|
||||
</h1>
|
||||
<p
|
||||
className="mt-2 text-sm"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
|
||||
>
|
||||
Sign in to your account
|
||||
<p className="mt-2 text-sm text-stone-500 text-center">
|
||||
Sign in to your <em className="atelier-italic not-italic font-normal">atelier</em> account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSignIn();
|
||||
}}
|
||||
className="space-y-5"
|
||||
noValidate
|
||||
>
|
||||
{/* Google sign-in (primary path — works for both new and existing users) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={googleLoading || loading}
|
||||
className="atelier-cta atelier-cta-secondary w-full"
|
||||
style={{ background: "white", color: "#1c1917", border: "1px solid #e7e5e4", boxShadow: "0 1px 2px rgba(0,0,0,0.04)" }}
|
||||
>
|
||||
{googleLoading ? (
|
||||
<span className="relative z-10 inline-flex items-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Redirecting to Google…
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative z-10 inline-flex items-center gap-2.5">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path fill="#FBBC05" d="M5.84 14.1c-.22-.66-.35-1.36-.35-2.1s.13-1.44.35-2.1V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.83z" />
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
Continue with Google
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3" aria-hidden="true">
|
||||
<div className="h-px flex-1 bg-stone-200" />
|
||||
<span className="text-[10px] font-semibold tracking-[0.15em] uppercase text-stone-400" style={{ fontFamily: "var(--font-fragment-mono)" }}>
|
||||
or
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-stone-200" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={emailId} className="atelier-section-label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id={emailId}
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
|
||||
placeholder="you@example.com"
|
||||
className="atelier-input"
|
||||
placeholder="you@yourfarm.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSignIn(); }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor={passwordId} className="atelier-section-label">
|
||||
Password
|
||||
</label>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="text-[10px] tracking-[0.15em] uppercase text-stone-500 hover:text-[#14532D] transition-colors"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)" }}
|
||||
>
|
||||
Forgot?
|
||||
</a>
|
||||
</div>
|
||||
<input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
|
||||
className="atelier-input"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSignIn(); }}
|
||||
/>
|
||||
</div>
|
||||
{(error || localError) && (
|
||||
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">
|
||||
{error ?? localError}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignIn}
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl px-6 py-3.5 text-sm font-semibold text-white shadow-sm transition-all disabled:opacity-60 disabled:cursor-not-allowed active:scale-[0.98]"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: loading
|
||||
? "linear-gradient(135deg, #6b8f71 0%, #7ba085 100%)"
|
||||
: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
||||
boxShadow: "0 8px 24px rgba(26, 77, 46, 0.20)",
|
||||
}}
|
||||
|
||||
{displayError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="atelier-stagger rounded-lg border border-rose-200/80 bg-rose-50/80 px-3.5 py-2.5 text-sm text-rose-800 flex items-start gap-2"
|
||||
>
|
||||
{loading ? "Signing in…" : "Sign in with email"}
|
||||
</button>
|
||||
<svg className="h-4 w-4 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<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" />
|
||||
</svg>
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="atelier-cta w-full"
|
||||
>
|
||||
<span className="relative z-10">{loading ? "Signing in…" : "Sign in"}</span>
|
||||
{!loading && (
|
||||
<svg className="h-4 w-4 relative z-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="atelier-cta-shimmer" aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Development bypass buttons */}
|
||||
{isDev && (
|
||||
<div className="mt-6 pt-6 border-t border-stone-200">
|
||||
<p className="text-xs text-stone-500 text-center mb-3" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
Dev Mode — Quick Access
|
||||
</p>
|
||||
<div className="mt-8 pt-6 border-t border-stone-200/80">
|
||||
<p className="atelier-section-label text-center mb-3">Dev Mode · Quick Access</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ role: "platform_admin", label: "Platform", color: "#14532D" },
|
||||
{ role: "brand_admin", label: "Brand", color: "#1F6B3E" },
|
||||
{ role: "store_employee", label: "Store", color: "#6F8562" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.role}
|
||||
type="button"
|
||||
onClick={() => handleDevLogin("platform_admin")}
|
||||
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
|
||||
onClick={() => handleDevLogin(opt.role)}
|
||||
className="rounded-lg px-3 py-2 text-[11px] font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: "#1a4d2e",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
background: opt.color,
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
Platform Admin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDevLogin("brand_admin")}
|
||||
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: "#2d6a45",
|
||||
}}
|
||||
>
|
||||
Brand Admin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDevLogin("store_employee")}
|
||||
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: "#6b8f71",
|
||||
}}
|
||||
>
|
||||
Store Employee
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="atelier-fineprint text-center mt-6">
|
||||
Protected by Neon Auth · Encrypted at rest
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,59 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Loading...",
|
||||
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 flex items-center justify-center px-6 py-12 bg-gradient-to-br from-stone-50 to-amber-50/30">
|
||||
{/* Background orbs */}
|
||||
<div
|
||||
className="fixed w-96 h-96 rounded-full pointer-events-none animate-pulse"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(34, 197, 94, 0.15) 0%, transparent 70%)',
|
||||
top: '10%',
|
||||
left: '10%',
|
||||
filter: 'blur(60px)'
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Loading card */}
|
||||
<div className="w-full max-w-sm relative">
|
||||
<div className="bg-white/70 backdrop-blur-2xl rounded-[2rem] shadow-lg p-8">
|
||||
{/* Logo skeleton */}
|
||||
<div className="flex justify-center mb-10">
|
||||
<div className="h-20 w-20 rounded-3xl bg-slate-200 animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Text skeleton */}
|
||||
<div className="space-y-3">
|
||||
<div className="h-8 w-32 rounded bg-slate-200 mx-auto animate-pulse" />
|
||||
<div className="h-4 w-24 rounded bg-slate-100 mx-auto animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Form skeleton */}
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="h-12 rounded-xl bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="h-12 rounded-xl bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="h-12 rounded-xl bg-slate-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span role="status" className="sr-only">Loading login page...</span>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export default function MaintenancePage() {
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<h1 className="text-4xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h1 className="text-4xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Under Maintenance
|
||||
</h1>
|
||||
<p className="text-lg text-[#6b8f71] mb-6">
|
||||
|
||||
+61
-58
@@ -1,79 +1,82 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page Not Found — Route Commerce",
|
||||
description: "The page you are looking for could not be found. Explore the rest of Route Commerce.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#faf8f5] flex items-center justify-center px-6">
|
||||
{/* Background decorations */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-20 left-20 w-40 h-40 bg-[#1a4d2e]/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-40 right-20 w-60 h-60 bg-[#c97a3e]/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/3 left-1/3 w-96 h-96 rounded-full opacity-30"
|
||||
style={{ background: "radial-gradient(circle, rgba(34, 78, 47, 0.16) 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-1/3 right-1/3 w-96 h-96 rounded-full opacity-25"
|
||||
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">
|
||||
{/* 404 Illustration */}
|
||||
<div className="relative mb-8">
|
||||
<div className="w-24 h-24 mx-auto bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] rounded-3xl flex items-center justify-center shadow-xl">
|
||||
<svg className="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="absolute -top-4 -right-4 w-12 h-12 bg-[#c97a3e]/20 rounded-full flex items-center justify-center text-2xl">📦</span>
|
||||
</div>
|
||||
<div className="relative z-10 max-w-lg w-full text-center atelier-enter">
|
||||
<div className="atelier-section-num justify-center mb-4">No. 404</div>
|
||||
|
||||
<h1 className="text-6xl font-bold text-[#1a1a1a] mb-4 tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
{/* Large editorial numeral */}
|
||||
<div
|
||||
className="atelier-numeral text-[10rem] sm:text-[14rem] leading-none select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
404
|
||||
</div>
|
||||
|
||||
<h1 className="atelier-title text-3xl sm:text-4xl mt-2">
|
||||
Off the <span className="atelier-italic text-[#786B53]">beaten path</span>
|
||||
</h1>
|
||||
<h2 className="text-2xl font-semibold text-[#1a1a1a] mb-3">
|
||||
Page not found
|
||||
</h2>
|
||||
<p className="text-[#6b8f71] mb-8">
|
||||
Looks like this route got lost along the way. Let's get you back on track.
|
||||
<p className="mt-4 text-stone-600 text-sm leading-relaxed max-w-sm mx-auto">
|
||||
We searched the field, but couldn't find that page. The route may have moved or never existed.
|
||||
</p>
|
||||
|
||||
{/* Search suggestion */}
|
||||
<div className="bg-white rounded-2xl p-4 border border-[#e5e5e5] mb-8">
|
||||
<p className="text-sm text-[#888] mb-3">Try searching for what you need:</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="flex-1 px-4 py-2 rounded-xl border border-[#e5e5e5] text-sm focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50"
|
||||
/>
|
||||
<button className="px-4 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="atelier-rule my-8" />
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[#888]">Or explore these pages:</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Link href="/" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Home
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
{[
|
||||
{ href: "/", label: "Home", desc: "Overview" },
|
||||
{ href: "/pricing", label: "Pricing", desc: "Plans" },
|
||||
{ href: "/contact", label: "Contact", desc: "Get in touch" },
|
||||
{ href: "/tuxedo", label: "Tuxedo", desc: "Sweet corn" },
|
||||
].map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group flex flex-col items-center gap-1 px-3 py-3.5 rounded-xl border border-stone-200/80 bg-white/70 backdrop-blur-sm hover:border-[#14532D] hover:bg-white transition-all"
|
||||
>
|
||||
<span className="text-sm font-semibold text-stone-900 group-hover:text-[#14532D] transition-colors">
|
||||
{link.label}
|
||||
</span>
|
||||
<span className="text-[10px] tracking-[0.12em] uppercase text-stone-500" style={{ fontFamily: "var(--font-fragment-mono)" }}>
|
||||
{link.desc}
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/pricing" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Pricing
|
||||
</Link>
|
||||
<Link href="/blog" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Blog
|
||||
</Link>
|
||||
<Link href="/roadmap" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Roadmap
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Report broken link */}
|
||||
<div className="mt-12 pt-8 border-t border-[#e5e5e5]">
|
||||
<p className="text-xs text-[#888]">
|
||||
Found a broken link?{" "}
|
||||
<a href="mailto:support@routecommerce.com" className="text-[#1a4d2e] hover:underline">
|
||||
Let us know
|
||||
<p className="atelier-fineprint text-center mt-10">
|
||||
Lost? Reach us at{" "}
|
||||
<a href="mailto:support@routecommerce.com" className="text-[#14532D] hover:underline normal-case tracking-normal">
|
||||
support@routecommerce.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+69
-3
@@ -3,6 +3,63 @@ import LandingPageClient from "./LandingPageClient";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
// JSON-LD structured data for the platform landing page.
|
||||
// Schema.org SoftwareApplication + Organization + FAQPage so Google
|
||||
// can render rich results for the product.
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": `${BASE_URL}/#organization`,
|
||||
name: "Route Commerce",
|
||||
url: BASE_URL,
|
||||
logo: `${BASE_URL}/logo.png`,
|
||||
description:
|
||||
"Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
|
||||
sameAs: [
|
||||
// Add social profiles here as they come online
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": `${BASE_URL}/#software`,
|
||||
name: "Route Commerce",
|
||||
applicationCategory: "BusinessApplication",
|
||||
applicationSubCategory: "Wholesale Distribution Platform",
|
||||
operatingSystem: "Web",
|
||||
url: BASE_URL,
|
||||
description:
|
||||
"All-in-one platform for produce wholesale distribution: orders, stops, routes, customer communications, and Stripe/Square payments.",
|
||||
offers: {
|
||||
"@type": "AggregateOffer",
|
||||
priceCurrency: "USD",
|
||||
lowPrice: 49,
|
||||
highPrice: 399,
|
||||
priceSpecification: [
|
||||
{ "@type": "UnitPriceSpecification", name: "Starter", price: 49, priceCurrency: "USD", description: "$49/month" },
|
||||
{ "@type": "UnitPriceSpecification", name: "Farm", price: 149, priceCurrency: "USD", description: "$149/month" },
|
||||
{ "@type": "UnitPriceSpecification", name: "Enterprise", price: 399, priceCurrency: "USD", description: "Custom pricing" },
|
||||
],
|
||||
},
|
||||
aggregateRating: {
|
||||
"@type": "AggregateRating",
|
||||
// Placeholder — replace with real review data once collected.
|
||||
ratingValue: 4.9,
|
||||
reviewCount: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${BASE_URL}/#website`,
|
||||
url: BASE_URL,
|
||||
name: "Route Commerce",
|
||||
publisher: { "@id": `${BASE_URL}/#organization` },
|
||||
inLanguage: "en-US",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Route Commerce — Fresh Produce Wholesale Platform",
|
||||
description: "The all-in-one platform for produce wholesale distribution. Manage orders, stops, routes, and customer communications. Built for farms, Co-ops, and distributors.",
|
||||
@@ -19,7 +76,7 @@ export const metadata: Metadata = {
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/og-default.jpg",
|
||||
url: "/og-default.svg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Route Commerce Platform",
|
||||
@@ -32,7 +89,7 @@ export const metadata: Metadata = {
|
||||
description: "The all-in-one platform for produce wholesale distribution.",
|
||||
site: "@RouteCommerce",
|
||||
creator: "@RouteCommerce",
|
||||
images: ["/og-default.jpg"],
|
||||
images: ["/og-default.svg"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: BASE_URL,
|
||||
@@ -61,5 +118,14 @@ export const viewport = {
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
return <LandingPageClient />;
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<LandingPageClient />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Loading pricing...",
|
||||
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">
|
||||
{/* Nav skeleton */}
|
||||
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-xl bg-slate-200 animate-pulse" />
|
||||
<div className="h-6 w-36 rounded bg-slate-200 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="h-4 w-16 rounded bg-slate-200 animate-pulse" />
|
||||
<div className="h-4 w-20 rounded bg-slate-200 animate-pulse" />
|
||||
<div className="h-9 w-24 rounded-xl bg-slate-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero skeleton */}
|
||||
<section className="bg-gradient-to-b from-slate-50 to-white px-6 py-20 text-center">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="h-6 w-40 rounded-full bg-slate-200 mx-auto mb-4 animate-pulse" />
|
||||
<div className="h-16 w-80 rounded-lg bg-slate-200 mx-auto mb-6 animate-pulse" />
|
||||
<div className="h-6 w-96 rounded bg-slate-200 mx-auto animate-pulse" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Plan cards skeleton */}
|
||||
<section className="mx-auto max-w-6xl px-6 py-6">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-2xl border-2 border-slate-200 p-6">
|
||||
<div className="h-6 w-24 rounded bg-slate-200 mb-3 animate-pulse" />
|
||||
<div className="h-4 w-32 rounded bg-slate-200 mb-1 animate-pulse" />
|
||||
<div className="h-10 w-24 rounded bg-slate-200 mb-4 animate-pulse" />
|
||||
<div className="h-12 w-full rounded-xl bg-slate-200 animate-pulse" />
|
||||
<div className="mt-6 space-y-2.5">
|
||||
{[1, 2, 3, 4].map((j) => (
|
||||
<div key={j} className="h-4 w-full rounded bg-slate-100 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<span role="status" className="sr-only">Loading pricing page...</span>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { getSession, signOut } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// `getSession()` reads cookies(), which forces dynamic rendering.
|
||||
// Without this, Next.js tries to prerender this page at build time
|
||||
// and fails with "Dynamic server usage".
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* /protected-example
|
||||
*
|
||||
@@ -46,7 +51,7 @@ export default async function ProtectedExamplePage() {
|
||||
<header>
|
||||
<h1
|
||||
className="text-3xl font-semibold tracking-tight text-stone-900"
|
||||
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}
|
||||
style={{ fontFamily: "var(--font-fraunces)" }}
|
||||
>
|
||||
Protected example
|
||||
</h1>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function RoadmapPage() {
|
||||
{/* Hero */}
|
||||
<section className="bg-gradient-to-b from-white to-[#faf8f5] 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)" }}>
|
||||
Product Roadmap
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||
@@ -189,7 +189,7 @@ export default function RoadmapPage() {
|
||||
<section id="suggest" className="py-16 bg-white border-t border-[#e5e5e5]">
|
||||
<div className="max-w-2xl mx-auto px-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Suggest a Feature
|
||||
</h2>
|
||||
<p className="text-[#666]">Have an idea? We'd love to hear it. Share your suggestion and vote on others.</p>
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function SecurityPage() {
|
||||
{/* Hero */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 text-center">
|
||||
<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)" }}>
|
||||
Your Data is Safe with Us
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||
@@ -122,7 +122,7 @@ export default function SecurityPage() {
|
||||
{/* Security Features */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-12 text-center" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-12 text-center" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Enterprise-Grade Security
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -144,7 +144,7 @@ export default function SecurityPage() {
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<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)" }}>
|
||||
Built on Trusted Infrastructure
|
||||
</h2>
|
||||
<p className="text-[#666] mb-6">
|
||||
@@ -219,7 +219,7 @@ export default function SecurityPage() {
|
||||
{/* Privacy Compliance */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Privacy Compliance
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
@@ -242,7 +242,7 @@ export default function SecurityPage() {
|
||||
{/* Report Vulnerability */}
|
||||
<section className="py-16 bg-[#1a4d2e] text-white">
|
||||
<div className="max-w-2xl mx-auto px-6 text-center">
|
||||
<h2 className="text-3xl font-bold mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Found a Security Issue?
|
||||
</h2>
|
||||
<p className="text-white/80 mb-6">
|
||||
|
||||
@@ -1,40 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-4xl px-6 py-20">
|
||||
{/* Hero skeleton */}
|
||||
<div className="mb-20 animate-pulse text-center">
|
||||
<div className="h-3 w-24 rounded bg-stone-200 mx-auto mb-5" />
|
||||
<div className="h-20 w-full max-w-xl mx-auto rounded-lg bg-stone-200 mb-5" />
|
||||
<div className="h-8 w-full max-w-2xl mx-auto rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Content sections skeleton */}
|
||||
<div className="space-y-20">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="grid md:grid-cols-2 gap-12 items-center"
|
||||
>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 w-20 rounded bg-stone-200" />
|
||||
<div className="h-8 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
||||
</div>
|
||||
<div className="h-64 rounded-3xl bg-stone-200" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,62 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-4xl px-6 py-20">
|
||||
{/* Header skeleton */}
|
||||
<div className="mb-16 animate-pulse text-center">
|
||||
<div className="h-3 w-20 rounded bg-emerald-100 mx-auto mb-5" />
|
||||
<div className="h-16 w-64 mx-auto rounded-lg bg-stone-200 mb-5" />
|
||||
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Contact cards skeleton */}
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-16">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center"
|
||||
>
|
||||
<div className="h-12 w-12 rounded-2xl bg-emerald-50 mx-auto mb-5" />
|
||||
<div className="h-5 w-24 mx-auto rounded bg-stone-200 mb-3" />
|
||||
<div className="h-4 w-full max-w-[160px] mx-auto rounded bg-stone-100" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form skeleton */}
|
||||
<div className="rounded-3xl bg-white p-10 shadow-sm ring-1 ring-stone-200/60">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 w-32 rounded bg-stone-200" />
|
||||
<div className="h-8 w-48 rounded bg-stone-200" />
|
||||
<div className="grid gap-6 md:grid-cols-2 mt-8">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
<div className="h-12 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
<div className="h-12 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-stone-100" />
|
||||
<div className="h-12 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
<div className="h-32 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
<div className="h-14 w-40 rounded-xl bg-emerald-100 mt-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,41 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-3xl px-6 py-20">
|
||||
{/* Header skeleton */}
|
||||
<div className="mb-14 text-center animate-pulse">
|
||||
<div className="h-3 w-16 rounded bg-emerald-100 mx-auto mb-4" />
|
||||
<div className="h-16 w-48 mx-auto rounded-lg bg-stone-200 mb-5" />
|
||||
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Search skeleton */}
|
||||
<div className="mb-12">
|
||||
<div className="h-14 rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60" />
|
||||
</div>
|
||||
|
||||
{/* FAQ items skeleton */}
|
||||
<div className="space-y-4">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-5">
|
||||
<div className="h-5 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-7 w-7 rounded-full bg-stone-100" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
+43
-16
@@ -1,23 +1,45 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
// BreadcrumbList schema for SEO
|
||||
const tuxedoBreadcrumbSchema = {
|
||||
// Structured data for the Tuxedo Corn storefront. Combining a
|
||||
// BreadcrumbList with a LocalBusiness (the farm stand) gives Google
|
||||
// enough context to show rich results for the brand.
|
||||
const tuxedoStructuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": BASE_URL,
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Home", item: BASE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Tuxedo Corn", item: `${BASE_URL}/tuxedo` },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Tuxedo Corn",
|
||||
"item": `${BASE_URL}/tuxedo`,
|
||||
"@type": "LocalBusiness",
|
||||
"@id": `${BASE_URL}/tuxedo/#localbusiness`,
|
||||
name: "Tuxedo Corn",
|
||||
description:
|
||||
"Premium sweet corn and seasonal produce, delivered fresh from our Colorado farm to pickup stops near you.",
|
||||
url: `${BASE_URL}/tuxedo`,
|
||||
image: `${BASE_URL}/og-default.svg`,
|
||||
priceRange: "$$",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressRegion: "CO",
|
||||
addressCountry: "US",
|
||||
},
|
||||
openingHoursSpecification: [
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
dayOfWeek: ["Saturday", "Sunday"],
|
||||
description: "Pickup stops scheduled seasonally — see stops page",
|
||||
},
|
||||
],
|
||||
sameAs: [
|
||||
// Social profiles can be added here
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -41,7 +63,7 @@ export const metadata: Metadata = {
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/og-tuxedo.jpg",
|
||||
url: "/og-default.svg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Tuxedo Corn - Olathe Sweet Sweet Corn",
|
||||
@@ -54,7 +76,7 @@ export const metadata: Metadata = {
|
||||
description: "Premium sweet corn and seasonal produce delivered fresh from our Colorado farm to pickup stops near you.",
|
||||
site: "@TuxedoCorn",
|
||||
creator: "@TuxedoCorn",
|
||||
images: ["/og-tuxedo.jpg"],
|
||||
images: ["/og-default.svg"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/tuxedo`,
|
||||
@@ -68,7 +90,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
},
|
||||
other: {
|
||||
"application/ld+json": JSON.stringify(tuxedoBreadcrumbSchema),
|
||||
"application/ld+json": JSON.stringify(tuxedoStructuredData),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -86,7 +108,12 @@ export default function TuxedoLayout({ children }: { children: React.ReactNode }
|
||||
<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]" />
|
||||
</div>
|
||||
<div className="relative">{children}</div>
|
||||
{/* The storefront backdrop and ambient orbs are persistent. The
|
||||
page body itself crossfades between routes via the View
|
||||
Transitions API. */}
|
||||
<div id="page-content" className="relative outline-none">
|
||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,8 @@
|
||||
"use client";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-6xl px-6 py-16">
|
||||
{/* Header skeleton */}
|
||||
<div className="mb-16 animate-pulse">
|
||||
<div className="h-4 w-32 rounded bg-stone-200 mb-4" />
|
||||
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
|
||||
<div className="h-6 w-96 max-w-full rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Cards skeleton */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="rounded-3xl bg-white ring-1 ring-stone-200/60 overflow-hidden"
|
||||
>
|
||||
{/* Image skeleton */}
|
||||
<div className="h-48 bg-gradient-to-br from-stone-100 to-stone-50 relative overflow-hidden">
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-stone-200/60 to-transparent" />
|
||||
</div>
|
||||
{/* Content skeleton */}
|
||||
<div className="p-7 space-y-4">
|
||||
<div className="h-6 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
||||
<div className="pt-4 flex items-center justify-between">
|
||||
<div className="h-8 w-20 rounded-lg bg-stone-200" />
|
||||
<div className="h-11 w-28 rounded-xl bg-stone-200" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stops section skeleton */}
|
||||
<div className="mt-20">
|
||||
<div className="animate-pulse mb-10">
|
||||
<div className="h-3 w-24 rounded bg-stone-200 mb-4" />
|
||||
<div className="h-10 w-64 rounded-lg bg-stone-200 mb-3" />
|
||||
<div className="h-4 w-48 rounded bg-stone-200" />
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="rounded-3xl bg-white p-7 ring-1 ring-stone-200/60"
|
||||
>
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
<div className="h-10 w-10 rounded-xl bg-stone-100" />
|
||||
<div className="flex-1">
|
||||
<div className="h-8 w-32 rounded bg-stone-200 mb-2" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pl-[2.75rem]">
|
||||
<div className="h-4 w-28 rounded bg-stone-100" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
/** Tuxedo storefront loading — the emerald backdrop and grain
|
||||
* texture persist in the layout, so we only need to signal that
|
||||
* navigation is in flight. */
|
||||
export default function TuxedoLoading() {
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,70 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-5xl px-6 py-20">
|
||||
{/* Back navigation skeleton */}
|
||||
<div className="mb-10 animate-pulse">
|
||||
<div className="h-5 w-32 rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Stop header skeleton */}
|
||||
<div className="mb-12 animate-pulse rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
|
||||
<div className="h-3 w-32 rounded bg-emerald-100 mb-4" />
|
||||
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
|
||||
<div className="h-5 w-full max-w-md rounded bg-stone-200 mb-4" />
|
||||
<div className="h-px w-12 bg-emerald-600" />
|
||||
</div>
|
||||
|
||||
{/* Stop info skeleton */}
|
||||
<div className="mb-14 rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
|
||||
>
|
||||
<div className="h-10 w-10 rounded-xl bg-emerald-50" />
|
||||
<div className="flex-1">
|
||||
<div className="h-3 w-12 rounded bg-stone-200 mb-2" />
|
||||
<div className="h-5 w-24 rounded bg-stone-200" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products section skeleton */}
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 w-20 rounded bg-emerald-100 mb-4" />
|
||||
<div className="h-10 w-48 rounded bg-stone-200 mb-4" />
|
||||
<div className="h-4 w-64 rounded bg-stone-200 mb-10" />
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="rounded-3xl bg-white overflow-hidden shadow-lg"
|
||||
>
|
||||
<div className="h-48 bg-gradient-to-br from-stone-100 to-stone-50" />
|
||||
<div className="p-6 space-y-3">
|
||||
<div className="h-6 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export default function WaitlistPage() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#1a1a1a] tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>Route Commerce</span>
|
||||
<span className="text-xl font-bold text-[#1a1a1a] tracking-tight" style={{ fontFamily: "var(--font-fraunces)" }}>Route Commerce</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
@@ -37,7 +37,7 @@ export default function WaitlistPage() {
|
||||
<span className="w-2 h-2 bg-[#c97a3e] rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium text-[#c97a3e]">Early Access</span>
|
||||
</div>
|
||||
<h1 className="text-5xl sm:text-6xl font-bold text-[#1a1a1a] leading-tight mb-6" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h1 className="text-5xl sm:text-6xl font-bold text-[#1a1a1a] leading-tight mb-6" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Join 500+ farms on the waitlist
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71] mb-8 leading-relaxed">
|
||||
@@ -76,7 +76,7 @@ export default function WaitlistPage() {
|
||||
{/* Right - Form */}
|
||||
<div>
|
||||
<div className="bg-white rounded-3xl p-8 sm:p-10 shadow-xl border border-[#e5e5e5]">
|
||||
<h2 className="text-2xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-2xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Reserve Your Spot
|
||||
</h2>
|
||||
<p className="text-[#888] mb-6">No credit card required. We'll notify you when it's your turn.</p>
|
||||
@@ -89,7 +89,7 @@ export default function WaitlistPage() {
|
||||
{/* FAQ Section */}
|
||||
<section className="border-t border-[#e5e5e5] bg-white">
|
||||
<div className="max-w-3xl mx-auto px-6 py-16">
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-100 px-4 py-6">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<div className="animate-pulse space-y-5">
|
||||
<div className="h-16 rounded-xl bg-slate-800" />
|
||||
<div className="h-8 w-48 rounded bg-slate-300" />
|
||||
<div className="h-12 w-full rounded-xl bg-white" />
|
||||
<div className="h-12 w-full rounded-xl bg-white" />
|
||||
<div className="h-12 w-full rounded-xl bg-white" />
|
||||
<div className="h-12 w-full rounded-xl bg-white" />
|
||||
<div className="h-10 w-full rounded-xl bg-slate-300" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export default function WholesaleLoginPage() {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({ email: "", password: "", brandId: BRANDS[1].id });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [googleSubmitting, setGoogleSubmitting] = useState(false);
|
||||
// Read ?error=... from the URL in the lazy initializer. Safe in a client
|
||||
// component since window is always defined here, and avoids a
|
||||
// set-state-in-effect on mount.
|
||||
@@ -69,6 +70,9 @@ export default function WholesaleLoginPage() {
|
||||
if (err === "invalid_credentials") {
|
||||
return "Invalid email or password. Please try again.";
|
||||
}
|
||||
if (err === "oauth") {
|
||||
return "Google sign-in failed or was cancelled. Please try again or use your password.";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
|
||||
@@ -77,6 +81,37 @@ export default function WholesaleLoginPage() {
|
||||
// form.brandId is always a valid BRANDS id, so this lookup is O(n) over a 2-item array.
|
||||
const selectedBrand = BRANDS.find(b => b.id === form.brandId) ?? BRANDS[1];
|
||||
|
||||
async function handleGoogleSignIn() {
|
||||
setGoogleSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { signInWithGoogleAction } = await import("@/actions/auth-actions");
|
||||
const result = await signInWithGoogleAction({
|
||||
callbackURL: "/wholesale/portal",
|
||||
errorCallbackURL: "/wholesale/login?error=oauth",
|
||||
});
|
||||
if (result.error || !result.url) {
|
||||
// TODO: Wholesale auth still runs on Supabase Auth. The Google
|
||||
// button works for admins (who use Neon Auth); wholesale buyers
|
||||
// who hit it will be redirected through Neon Auth and then
|
||||
// bounced at /wholesale/portal because the session isn't
|
||||
// recognized by the wholesale auth path. Once wholesale auth
|
||||
// is migrated to Neon Auth, this will start working for them
|
||||
// too.
|
||||
setError(
|
||||
result.error ??
|
||||
"Google sign-in is available, but the wholesale portal is not yet wired up to it. Please use email + password for now.",
|
||||
);
|
||||
setGoogleSubmitting(false);
|
||||
return;
|
||||
}
|
||||
window.location.href = result.url;
|
||||
} catch {
|
||||
setError("Google sign-in failed. Please try again.");
|
||||
setGoogleSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
@@ -165,6 +200,43 @@ export default function WholesaleLoginPage() {
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Google sign-in — works once wholesale auth migrates to Neon Auth */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={googleSubmitting || submitting}
|
||||
className="w-full rounded-xl bg-white py-3.5 text-sm font-bold text-zinc-900 hover:bg-zinc-100 active:bg-zinc-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-zinc-300 flex items-center justify-center gap-2.5"
|
||||
>
|
||||
{googleSubmitting ? (
|
||||
<>
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Redirecting to Google…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path fill="#FBBC05" d="M5.84 14.1c-.22-.66-.35-1.36-.35-2.1s.13-1.44.35-2.1V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.83z" />
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
Continue with Google
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3" aria-hidden="true">
|
||||
<div className="h-px flex-1 bg-zinc-800" />
|
||||
<span className="text-[10px] font-semibold tracking-[0.15em] uppercase text-zinc-500">
|
||||
or
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-zinc-800" />
|
||||
</div>
|
||||
|
||||
<FormField label="Company" id="brand_id" error={null}>
|
||||
<select
|
||||
id="brand_id"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { MotionConfig } from "framer-motion";
|
||||
import { CartProvider } from "@/context/CartContext";
|
||||
import { usePathname } from "next/navigation";
|
||||
import SiteHeader from "@/components/layout/SiteHeader";
|
||||
@@ -28,6 +29,18 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
// The visibility:hidden pattern was causing click handling issues on hidden elements
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false} disableTransitionOnChange>
|
||||
{/*
|
||||
* MotionConfig caps every framer-motion animation in the tree:
|
||||
* - default transition is 0.2s easeOut (was usually 0.6-1s with custom cubic-bezier eases)
|
||||
* - `reducedMotion="user"` makes framer-motion automatically strip x/y/scale/rotate
|
||||
* from animations when the user has prefers-reduced-motion: reduce set, leaving
|
||||
* only opacity. This is the single most important lever for vestibular comfort —
|
||||
* it overrides the 71 `initial={{ y: 48 }}` / `initial={{ scale: 0 }}` reveals
|
||||
* across the public site without touching each file.
|
||||
* Combined with the prefers-reduced-motion block in globals.css, this means a user
|
||||
* with motion sensitivity gets the same content with zero positional movement.
|
||||
*/}
|
||||
<MotionConfig transition={{ duration: 0.2, ease: "easeOut" }} reducedMotion="user">
|
||||
<CartProvider>
|
||||
{!isBrandRoute && !isLandingPage && !isAuthPage && !isAdminRoute && !isStandalonePage && <SiteHeader />}
|
||||
{children}
|
||||
@@ -35,6 +48,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
<CartToast />
|
||||
<CartRestoredToast />
|
||||
</CartProvider>
|
||||
</MotionConfig>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{ background: "rgba(220, 38, 38, 0.06)", border: "1px solid rgba(220, 38, 38, 0.15)" }}
|
||||
style={{ background: "var(--admin-danger-soft)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)" }}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
|
||||
@@ -80,9 +80,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
|
||||
? [{ href: "/admin/route-trace", label: "Route Trace" }]
|
||||
: [];
|
||||
|
||||
const fullNavLinks = BASE_NAV_LINKS.concat(
|
||||
userRole === "platform_admin" ? [{ href: "/admin/command-center", label: "Command Center" }] : []
|
||||
).concat(moduleLinks);
|
||||
const fullNavLinks = BASE_NAV_LINKS.concat(moduleLinks);
|
||||
|
||||
const navLinks = isStoreEmployee ? EMPLOYEE_NAV_LINKS : fullNavLinks;
|
||||
const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin";
|
||||
|
||||
@@ -3,11 +3,31 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
MapPin,
|
||||
Package,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { markPickupComplete } from "@/actions/pickup";
|
||||
import { createAdminOrder, type AdminCreateOrderItem } from "@/actions/orders/create-admin-order";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import AdminBadge from "./design-system/AdminBadge";
|
||||
import { AdminButton, AdminSearchInput, AdminFilterTabs, AdminIconButton, useToast, AdminInput, AdminTextInput, AdminSelect } from "./design-system";
|
||||
import {
|
||||
AdminButton,
|
||||
AdminSearchInput,
|
||||
AdminFilterTabs,
|
||||
AdminIconButton,
|
||||
useToast,
|
||||
AdminInput,
|
||||
AdminTextInput,
|
||||
AdminSelect,
|
||||
EmptyState,
|
||||
KPIStat,
|
||||
} from "./design-system";
|
||||
import { Skeleton } from "./design-system";
|
||||
|
||||
type OrderItem = {
|
||||
@@ -66,55 +86,13 @@ function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
}
|
||||
|
||||
// Icons
|
||||
const Icons = {
|
||||
mapPin: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0z"/>
|
||||
</svg>
|
||||
),
|
||||
check: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
),
|
||||
x: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
chevronDown: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
),
|
||||
chevronLeft: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15 18-6-6 6-6"/>
|
||||
</svg>
|
||||
),
|
||||
chevronRight: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m9 18 6-6-6-6"/>
|
||||
</svg>
|
||||
),
|
||||
package: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16.5 9.4 7.55 4.24"/>
|
||||
<path d="M21 16V8a2 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"/>
|
||||
<polyline points="3.29 7 12 12 20.71 7"/>
|
||||
<line x1="12" y1="22" x2="12" y2="12"/>
|
||||
</svg>
|
||||
),
|
||||
selectAll: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<polyline points="9 11 12 14 22 4"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
// Icon wrappers (lucide-react) — kept compact for inline use.
|
||||
const MapPinIcon = () => <MapPin className="h-4 w-4" strokeWidth={2} />;
|
||||
const CheckIcon = () => <Check className="h-4 w-4" strokeWidth={2} />;
|
||||
const XIcon = () => <X className="h-3 w-3" strokeWidth={2} />;
|
||||
const ChevronDownIcon = () => <ChevronDown className="h-4 w-4" strokeWidth={2} />;
|
||||
const ChevronLeftIcon = () => <ChevronLeft className="h-4 w-4" strokeWidth={2} />;
|
||||
const ChevronRightIcon = () => <ChevronRight className="h-4 w-4" strokeWidth={2} />;
|
||||
|
||||
export default function AdminOrdersPanel({
|
||||
initialOrders,
|
||||
@@ -355,12 +333,23 @@ export default function AdminOrdersPanel({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)]">
|
||||
{Icons.package("h-5 w-5 text-white")}
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||
>
|
||||
<Package className="h-5 w-5 text-white" strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Orders</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
<h2
|
||||
className="text-base sm:text-lg font-bold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
Orders
|
||||
</h2>
|
||||
<p
|
||||
className="text-[10px] sm:text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
{filteredOrders.length} order{filteredOrders.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
@@ -368,7 +357,7 @@ export default function AdminOrdersPanel({
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{brandId && (
|
||||
<AdminBadge variant="info">Brand scoped</AdminBadge>
|
||||
<AdminBadge tone="info">Brand scoped</AdminBadge>
|
||||
)}
|
||||
<AdminButton
|
||||
onClick={openNewOrderModal}
|
||||
@@ -382,18 +371,21 @@ export default function AdminOrdersPanel({
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{isLoading ? <Skeleton variant="text" className="w-16 h-8" /> : orders.length}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Pending</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-amber-600 mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pendingCount}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Picked Up</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-accent)] mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pickedUpCount}</p>
|
||||
</div>
|
||||
<KPIStat
|
||||
label="Total"
|
||||
value={isLoading ? <Skeleton variant="text" className="w-16 h-8" /> : orders.length}
|
||||
tone="default"
|
||||
/>
|
||||
<KPIStat
|
||||
label="Pending"
|
||||
value={isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pendingCount}
|
||||
tone="warning"
|
||||
/>
|
||||
<KPIStat
|
||||
label="Picked Up"
|
||||
value={isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pickedUpCount}
|
||||
tone="primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -423,40 +415,92 @@ export default function AdminOrdersPanel({
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowStopDropdown(!showStopDropdown)}
|
||||
className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
className="flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors"
|
||||
style={
|
||||
selectedStops.length > 0
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]"
|
||||
: "border-[var(--admin-border)] bg-white text-stone-600 hover:bg-stone-50"
|
||||
}`}
|
||||
? {
|
||||
borderColor: "var(--admin-accent)",
|
||||
backgroundColor: "var(--admin-accent-light)",
|
||||
color: "var(--admin-accent-text)",
|
||||
}
|
||||
: {
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
color: "var(--admin-text-secondary)",
|
||||
}
|
||||
}
|
||||
>
|
||||
{Icons.mapPin("h-4 w-4")}
|
||||
<MapPinIcon />
|
||||
<span>Stops</span>
|
||||
{selectedStops.length > 0 && (
|
||||
<span className="rounded-full bg-[var(--admin-accent)] text-white px-1.5 py-0.5 text-xs font-semibold">{selectedStops.length}</span>
|
||||
<span
|
||||
className="rounded-full text-white px-1.5 py-0.5 text-xs font-semibold"
|
||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||
>
|
||||
{selectedStops.length}
|
||||
</span>
|
||||
)}
|
||||
{Icons.chevronDown("h-4 w-4")}
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
|
||||
{showStopDropdown && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowStopDropdown(false)} />
|
||||
<div className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border border-[var(--admin-border)] bg-white shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
|
||||
<span className="text-sm font-semibold text-stone-700">Filter by Stop</span>
|
||||
<button onClick={clearStops} className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] font-medium">Clear all</button>
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border shadow-lg"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between border-b px-4 py-3"
|
||||
style={{ borderColor: "var(--admin-border-light)" }}
|
||||
>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
Filter by Stop
|
||||
</span>
|
||||
<button
|
||||
onClick={clearStops}
|
||||
className="text-xs font-medium"
|
||||
style={{ color: "var(--admin-accent)" }}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto p-2">
|
||||
{stops.map((stop) => (
|
||||
<label key={stop.id} className="flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-stone-50 cursor-pointer">
|
||||
<label
|
||||
key={stop.id}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 cursor-pointer transition-colors"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStops.includes(stop.id)}
|
||||
onChange={() => toggleStop(stop.id)}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
|
||||
className="h-4 w-4 rounded"
|
||||
style={{
|
||||
borderColor: "var(--admin-border-strong)",
|
||||
accentColor: "var(--admin-accent)",
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-stone-700">{stop.city}, {stop.state}</span>
|
||||
<span className="ml-2 text-xs text-stone-400">{formatDate(stop.date)}</span>
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{stop.city}, {stop.state}
|
||||
</span>
|
||||
<span
|
||||
className="ml-2 text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
{formatDate(stop.date)}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
@@ -474,10 +518,23 @@ export default function AdminOrdersPanel({
|
||||
const stop = stops.find((s) => s.id === stopId);
|
||||
if (!stop) return null;
|
||||
return (
|
||||
<span key={stopId} className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)] px-3 py-1 text-xs font-medium text-[var(--admin-accent-text)]">
|
||||
<span
|
||||
key={stopId}
|
||||
className="inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-accent-light)",
|
||||
borderColor: "var(--admin-accent)",
|
||||
color: "var(--admin-accent-text)",
|
||||
}}
|
||||
>
|
||||
{stop.city}, {stop.state}
|
||||
<button onClick={() => toggleStop(stopId)} className="ml-1 hover:text-[var(--admin-accent-hover)]">
|
||||
{Icons.x("h-3 w-3")}
|
||||
<button
|
||||
onClick={() => toggleStop(stopId)}
|
||||
className="ml-1"
|
||||
style={{ color: "var(--admin-accent-hover)" }}
|
||||
aria-label={`Remove ${stop.city}, ${stop.state}`}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
@@ -487,14 +544,24 @@ export default function AdminOrdersPanel({
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedOrders.size > 0 && (
|
||||
<div className="mb-4 flex items-center justify-between rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-4 py-3">
|
||||
<div
|
||||
className="mb-4 flex items-center justify-between rounded-xl border px-4 py-3"
|
||||
style={{
|
||||
borderColor: "var(--admin-accent)",
|
||||
backgroundColor: "var(--admin-accent-light)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-[var(--admin-accent-text)]">
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--admin-accent-text)" }}
|
||||
>
|
||||
{selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedOrders(new Set())}
|
||||
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]"
|
||||
className="text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
@@ -504,7 +571,7 @@ export default function AdminOrdersPanel({
|
||||
size="sm"
|
||||
onClick={handleBulkMarkPickup}
|
||||
isLoading={bulkMarkingUp}
|
||||
icon={Icons.check("h-4 w-4")}
|
||||
icon={<CheckIcon />}
|
||||
>
|
||||
Mark All as Picked Up
|
||||
</AdminButton>
|
||||
@@ -513,7 +580,13 @@ export default function AdminOrdersPanel({
|
||||
|
||||
{/* Orders Table */}
|
||||
{isLoading ? (
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
|
||||
<div
|
||||
className="rounded-xl border p-6"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
@@ -526,80 +599,184 @@ export default function AdminOrdersPanel({
|
||||
</div>
|
||||
</div>
|
||||
) : paginatedOrders.length === 0 ? (
|
||||
<div className="text-center py-12 rounded-xl border border-[var(--admin-border)] bg-white">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
{Icons.package("h-8 w-8 text-stone-400")}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-stone-600">No orders found</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
|
||||
<div
|
||||
className="rounded-xl border"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<EmptyState
|
||||
icon={<Package className="h-9 w-9" strokeWidth={1.25} />}
|
||||
title={orders.length === 0 ? "No orders yet" : "No orders match your filters"}
|
||||
description={
|
||||
orders.length === 0
|
||||
? "Create your first order to get started."
|
||||
: "Try adjusting your filters or clearing your search."
|
||||
}
|
||||
action={
|
||||
orders.length === 0
|
||||
? { label: "Create your first order", href: "/admin/orders?new=true" }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-[var(--admin-border)] bg-white">
|
||||
<div
|
||||
className="overflow-x-auto rounded-xl border"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead className="bg-stone-50">
|
||||
<tr className="border-b border-[var(--admin-border)]">
|
||||
<thead style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<tr style={{ borderBottom: "1px solid var(--admin-border)" }}>
|
||||
<th className="w-10 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
className="h-4 w-4 rounded cursor-pointer"
|
||||
style={{
|
||||
borderColor: "var(--admin-border-strong)",
|
||||
accentColor: "var(--admin-accent)",
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Order</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Customer</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden md:table-cell">Stop</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden lg:table-cell">Date</th>
|
||||
<th className="text-center px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Items</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Total</th>
|
||||
<th className="text-center px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th>
|
||||
<th
|
||||
className="text-left px-4 py-3 font-semibold text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Order
|
||||
</th>
|
||||
<th
|
||||
className="text-left px-4 py-3 font-semibold text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Customer
|
||||
</th>
|
||||
<th
|
||||
className="text-left px-4 py-3 font-semibold text-xs hidden md:table-cell"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Stop
|
||||
</th>
|
||||
<th
|
||||
className="text-left px-4 py-3 font-semibold text-xs hidden lg:table-cell"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Date
|
||||
</th>
|
||||
<th
|
||||
className="text-center px-4 py-3 font-semibold text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Items
|
||||
</th>
|
||||
<th
|
||||
className="text-right px-4 py-3 font-semibold text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Total
|
||||
</th>
|
||||
<th
|
||||
className="text-center px-4 py-3 font-semibold text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
<tbody style={{ borderColor: "var(--admin-border)" }}>
|
||||
{paginatedOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-stone-50 transition-colors">
|
||||
<tr
|
||||
key={order.id}
|
||||
className="transition-colors"
|
||||
style={{ borderTop: "1px solid var(--admin-border-light)" }}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedOrders.has(order.id)}
|
||||
onChange={() => toggleOrderSelection(order.id)}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
className="h-4 w-4 rounded cursor-pointer"
|
||||
style={{
|
||||
borderColor: "var(--admin-border-strong)",
|
||||
accentColor: "var(--admin-accent)",
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/admin/orders/${order.id}`} className="font-mono text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]">
|
||||
<Link
|
||||
href={`/admin/orders/${order.id}`}
|
||||
className="font-mono text-xs"
|
||||
style={{ color: "var(--admin-accent)" }}
|
||||
>
|
||||
{shortId(order.id)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-[var(--admin-text-primary)]">{order.customer_name}</div>
|
||||
{order.customer_phone && <div className="font-mono text-xs text-stone-400">{order.customer_phone}</div>}
|
||||
<div
|
||||
className="font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.customer_name}
|
||||
</div>
|
||||
{order.customer_phone && (
|
||||
<div
|
||||
className="font-mono text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
{order.customer_phone}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
{order.stops ? (
|
||||
<span className="text-sm text-[var(--admin-text-primary)]">{order.stops.city}, {order.stops.state}</span>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{order.stops.city}, {order.stops.state}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-stone-300">—</span>
|
||||
<span style={{ color: "var(--admin-text-muted)" }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell">
|
||||
<span className="text-sm text-stone-500">{formatDate(order.created_at)}</span>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
{formatDate(order.created_at)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="font-mono text-sm text-stone-600">{order.order_items?.length ?? 0}</span>
|
||||
<span
|
||||
className="font-mono text-sm"
|
||||
style={{ color: "var(--admin-text-secondary)" }}
|
||||
>
|
||||
{order.order_items?.length ?? 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="font-mono font-semibold text-[var(--admin-text-primary)]">{formatCurrency(order.subtotal)}</span>
|
||||
<span
|
||||
className="font-mono font-semibold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{formatCurrency(order.subtotal)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{order.pickup_complete ? (
|
||||
<AdminBadge variant="success" dot>Picked Up</AdminBadge>
|
||||
<AdminBadge tone="success" dot>Picked Up</AdminBadge>
|
||||
) : (
|
||||
<AdminBadge variant="warning" dot>Pending</AdminBadge>
|
||||
<AdminBadge tone="warning" dot>Pending</AdminBadge>
|
||||
)}
|
||||
{order.payment_processor === "square" && (
|
||||
<AdminBadge variant="info">Square</AdminBadge>
|
||||
<AdminBadge tone="info">Square</AdminBadge>
|
||||
)}
|
||||
{!order.pickup_complete && (
|
||||
<AdminButton
|
||||
@@ -623,8 +800,14 @@ export default function AdminOrdersPanel({
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]">
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
<div
|
||||
className="flex items-center justify-between mt-4 pt-4 border-t"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, filteredOrders.length)} of {filteredOrders.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -636,9 +819,14 @@ export default function AdminOrdersPanel({
|
||||
disabled={page === 0}
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{Icons.chevronLeft("h-4 w-4")}
|
||||
<ChevronLeftIcon />
|
||||
</AdminIconButton>
|
||||
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
|
||||
<span
|
||||
className="px-3 text-sm font-medium"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -647,7 +835,7 @@ export default function AdminOrdersPanel({
|
||||
disabled={page >= totalPages - 1}
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{Icons.chevronRight("h-4 w-4")}
|
||||
<ChevronRightIcon />
|
||||
</AdminIconButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -656,19 +844,54 @@ export default function AdminOrdersPanel({
|
||||
|
||||
{/* New Order Modal (admin manual entry) - sibling to the main panel content */}
|
||||
{showNewOrderModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-2xl rounded-2xl border border-[var(--admin-border)] bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-4">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ backgroundColor: "rgba(26, 24, 20, 0.4)" }}>
|
||||
<div
|
||||
className="w-full max-w-2xl rounded-2xl border shadow-2xl"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between border-b px-6 py-4"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">New Order (Admin)</h3>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Manual entry for testing / phone orders</p>
|
||||
<h3
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
New Order (Admin)
|
||||
</h3>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Manual entry for testing / phone orders
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={closeNewOrderModal} className="text-2xl leading-none text-stone-400 hover:text-stone-600">×</button>
|
||||
<button
|
||||
onClick={closeNewOrderModal}
|
||||
className="text-2xl leading-none"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5 max-h-[70vh] overflow-auto">
|
||||
{newOrderError && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-2 text-sm text-red-700">{newOrderError}</div>
|
||||
<div
|
||||
className="rounded-lg border px-4 py-2 text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-danger-soft)",
|
||||
borderColor: "var(--admin-danger-soft)",
|
||||
color: "var(--admin-danger)",
|
||||
}}
|
||||
>
|
||||
{newOrderError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customer */}
|
||||
@@ -715,8 +938,18 @@ export default function AdminOrdersPanel({
|
||||
{/* Items */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-xs font-semibold text-[var(--admin-text-secondary)]">Items</label>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">Total: ${newOrderTotal.toFixed(2)}</span>
|
||||
<label
|
||||
className="block text-xs font-semibold"
|
||||
style={{ color: "var(--admin-text-secondary)" }}
|
||||
>
|
||||
Items
|
||||
</label>
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
Total: ${newOrderTotal.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{products.length > 0 ? (
|
||||
@@ -733,20 +966,41 @@ export default function AdminOrdersPanel({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-amber-600 mb-2">No products loaded for this brand. You can still enter custom items if the action supports it.</p>
|
||||
<p
|
||||
className="text-xs mb-2"
|
||||
style={{ color: "var(--admin-warning)" }}
|
||||
>
|
||||
No products loaded for this brand. You can still enter custom items if the action supports it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{newOrderItems.length === 0 && (
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">No items yet. Use the selector above.</p>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
No items yet. Use the selector above.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{newOrderItems.length > 0 && (
|
||||
<div className="space-y-2 border border-[var(--admin-border)] rounded-xl p-3 bg-[var(--admin-bg)]">
|
||||
<div
|
||||
className="space-y-2 border rounded-xl p-3"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-bg)",
|
||||
}}
|
||||
>
|
||||
{newOrderItems.map((item, idx) => {
|
||||
const prod = products.find((p) => p.id === item.product_id);
|
||||
return (
|
||||
<div key={idx} className="grid grid-cols-12 gap-2 items-center text-sm">
|
||||
<div className="col-span-5 font-medium truncate">{prod?.name ?? item.product_id}</div>
|
||||
<div
|
||||
className="col-span-5 font-medium truncate"
|
||||
style={{ color: "var(--admin-text-primary)" }}
|
||||
>
|
||||
{prod?.name ?? item.product_id}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -754,6 +1008,11 @@ export default function AdminOrdersPanel({
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateNewOrderItem(idx, { quantity: Math.max(1, parseInt(e.target.value) || 1) })}
|
||||
className="w-full rounded border px-2 py-1 text-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
color: "var(--admin-text-primary)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
@@ -763,6 +1022,11 @@ export default function AdminOrdersPanel({
|
||||
value={item.price}
|
||||
onChange={(e) => updateNewOrderItem(idx, { price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full rounded border px-2 py-1 text-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
color: "var(--admin-text-primary)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
@@ -770,13 +1034,25 @@ export default function AdminOrdersPanel({
|
||||
value={item.fulfillment}
|
||||
onChange={(e) => updateNewOrderItem(idx, { fulfillment: e.target.value as "pickup" | "ship" })}
|
||||
className="w-full rounded border px-2 py-1 text-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
color: "var(--admin-text-primary)",
|
||||
}}
|
||||
>
|
||||
<option value="pickup">pickup</option>
|
||||
<option value="ship">ship</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-1 text-right">
|
||||
<button type="button" onClick={() => removeNewOrderItem(idx)} className="text-red-500 text-xs hover:underline">remove</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeNewOrderItem(idx)}
|
||||
className="text-xs hover:underline"
|
||||
style={{ color: "var(--admin-danger)" }}
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -786,7 +1062,13 @@ export default function AdminOrdersPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-[var(--admin-border)] px-6 py-4 bg-stone-50 rounded-b-2xl">
|
||||
<div
|
||||
className="flex items-center justify-end gap-3 border-t px-6 py-4 rounded-b-2xl"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
}}
|
||||
>
|
||||
<AdminButton variant="secondary" onClick={closeNewOrderModal} disabled={newOrderSubmitting}>
|
||||
Cancel
|
||||
</AdminButton>
|
||||
|
||||
@@ -1,225 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, KeyboardEvent, ComponentType } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ShoppingCart,
|
||||
MapPin,
|
||||
Package,
|
||||
Truck,
|
||||
Ship,
|
||||
Mail,
|
||||
Store,
|
||||
Upload,
|
||||
BrainCircuit,
|
||||
Clock,
|
||||
Droplets,
|
||||
Route,
|
||||
ChartBar,
|
||||
Receipt,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { signOutAction } from "@/actions/auth-actions";
|
||||
import BrandSelector from "@/components/admin/BrandSelector";
|
||||
import SideNavGroup from "@/components/admin/SideNavGroup";
|
||||
|
||||
// Elegant warm sidebar design
|
||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||
// Sidebar tokens used (from src/styles/admin-design-system.css):
|
||||
// --admin-sidebar-bg (#2A2520)
|
||||
// --admin-sidebar-text (#B8B4A8)
|
||||
// --admin-sidebar-hover (#3A352F)
|
||||
// --admin-sidebar-active (#4A443C)
|
||||
// --admin-sidebar-accent (#D4A24C — amber, used for active left-border + dot)
|
||||
// The "amber accent" replaces the old `--admin-accent` (citrus orange) in the
|
||||
// sidebar only — primary buttons elsewhere still use the deep botanical green.
|
||||
|
||||
type NavItem = {
|
||||
href?: string;
|
||||
type IconComponent = ComponentType<{ className?: string; size?: number | string }>;
|
||||
|
||||
type NavItemDef = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
divider?: boolean;
|
||||
icon: IconComponent;
|
||||
/** If set, this item is hidden when `enabledAddons[key] === false`. */
|
||||
addonKey?: string;
|
||||
/** When true, only `userRole === "platform_admin"` sees this item. */
|
||||
requiresPlatformAdmin?: boolean;
|
||||
};
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
// Main
|
||||
{ href: "/admin", label: "Dashboard", icon: "grid" },
|
||||
{ href: "/admin/orders", label: "Orders", icon: "shopping-cart" },
|
||||
{ href: "/admin/stops", label: "Stops & Routes", icon: "map-pin" },
|
||||
{ href: "/admin/products", label: "Products", icon: "package" },
|
||||
{ href: "/admin/communications", label: "Communications", icon: "mail" },
|
||||
// Settings section
|
||||
{ divider: true, label: "Settings" },
|
||||
{ href: "/admin/settings", label: "General", icon: "settings" },
|
||||
{ href: "/admin/time-tracking", label: "Workers & PINs", icon: "users" },
|
||||
{ href: "/admin/time-tracking?tab=tasks", label: "Tasks", icon: "clipboard" },
|
||||
{ href: "/admin/users", label: "Users & Permissions", icon: "shield" },
|
||||
{ href: "/admin/settings/integrations", label: "Integrations", icon: "plug" },
|
||||
{ href: "/admin/settings/billing", label: "Billing", icon: "billing" },
|
||||
type NavGroup = {
|
||||
label: string;
|
||||
items: NavItemDef[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Grouped nav IA per `docs/superpowers/specs/2026-06-17-admin-redesign.md` §4.
|
||||
* Order is intentional (top-to-bottom = frequency-of-use + mental flow).
|
||||
*
|
||||
* - Workspace : Dashboard is always shown; Command Center is platform_admin-only.
|
||||
* - Operations : Day-to-day order/stop/product flow.
|
||||
* - Communications: Harvest Reach only — the other sub-items live inside that page.
|
||||
* - Growth : Wholesale portal, data imports, AI assistant.
|
||||
* - Tracking : Time tracking is always on; Water Log / Route Trace are
|
||||
* gated on their respective add-ons via `enabledAddons`.
|
||||
* - Insights : Reporting + tax.
|
||||
* - Settings : Single entry; sub-pages are tabs inside /admin/settings.
|
||||
*/
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: "Workspace",
|
||||
items: [
|
||||
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
items: [
|
||||
{ href: "/admin/orders", label: "Orders", icon: ShoppingCart },
|
||||
{ href: "/admin/stops", label: "Stops & Routes", icon: MapPin },
|
||||
{ href: "/admin/products", label: "Products", icon: Package },
|
||||
{ href: "/admin/pickup", label: "Driver Pickup", icon: Truck },
|
||||
{ href: "/admin/shipping", label: "Shipping", icon: Ship },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Communications",
|
||||
items: [
|
||||
{ href: "/admin/communications", label: "Harvest Reach", icon: Mail },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Growth",
|
||||
items: [
|
||||
{ href: "/admin/wholesale", label: "Wholesale", icon: Store },
|
||||
{ href: "/admin/import", label: "Import Center", icon: Upload },
|
||||
{ href: "/admin/settings/ai", label: "AI Intelligence", icon: BrainCircuit },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Tracking",
|
||||
items: [
|
||||
{ href: "/admin/time-tracking", label: "Time Tracking", icon: Clock },
|
||||
{ href: "/admin/water-log", label: "Water Log", icon: Droplets, addonKey: "water_log" },
|
||||
{ href: "/admin/route-trace", label: "Route Trace", icon: Route, addonKey: "route_trace" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Insights",
|
||||
items: [
|
||||
{ href: "/admin/reports", label: "Reports", icon: ChartBar },
|
||||
{ href: "/admin/taxes", label: "Tax Dashboard", icon: Receipt },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
items: [
|
||||
{ href: "/admin/settings", label: "Settings", icon: Settings },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Icon components
|
||||
function GridIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25a2.25 2.25 0 01-2.25 2.25H15.75a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H15.75a2.25 2.25 0 01-2.25-2.25v-2.25z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CartIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MapPinIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ClipboardIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ClockIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MailIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SparkleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsCogIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 transition-transform duration-200 ${open ? "rotate-45" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09.542.56.94 1.11.94h2.64c.55 0 1.02-.398 1.11-.94l.213-1.999c.018-.158.04-.315.062-.472a.563.563 0 00-.122-.519l-.79-2.758A.562.562 0 0014.56 0H9.44a.563.563 0 00-.424.264l-.79 2.758a.563.563 0 00-.122.519c.022.157.044.314.062.472l.213 1.999zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HamburgerIcon() {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 0h3m-3.75 0h3m-3.75 0h3m3.75 0h3m-3.75 0h3m3.75 0h3m3.75 0h3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 00.658-.663 48.422 48.422 0 00-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 01-.61-.58v0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlugIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TruckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SquareIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ShieldIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
grid: <GridIcon />,
|
||||
"shopping-cart": <CartIcon />,
|
||||
"map-pin": <MapPinIcon />,
|
||||
package: <PackageIcon />,
|
||||
clipboard: <ClipboardIcon />,
|
||||
clock: <ClockIcon />,
|
||||
mail: <MailIcon />,
|
||||
settings: <SettingsCogIcon open={false} />,
|
||||
advanced: <SparkleIcon />,
|
||||
billing: <BillingIcon />,
|
||||
puzzle: <PuzzleIcon />,
|
||||
plug: <PlugIcon />,
|
||||
sparkles: <SparkleIcon />,
|
||||
truck: <TruckIcon />,
|
||||
square: <SquareIcon />,
|
||||
users: <UsersIcon />,
|
||||
shield: <ShieldIcon />,
|
||||
};
|
||||
|
||||
type SidebarProps = {
|
||||
userRole?: string | null;
|
||||
brandIds?: string[];
|
||||
activeBrandId?: string | null;
|
||||
brands?: { id: string; name: string; slug: string; logo_url: string | null }[];
|
||||
/**
|
||||
* Map of add-on key → enabled. When a key is `false` (or omitted, with the
|
||||
* add-on not in the map), any nav item with that `addonKey` is hidden.
|
||||
* When the prop itself is omitted, all add-on-gated items are shown
|
||||
* (back-compat with the current admin layout, which doesn't pass it).
|
||||
*/
|
||||
enabledAddons?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export default function AdminSidebar({
|
||||
@@ -227,6 +141,7 @@ export default function AdminSidebar({
|
||||
brandIds,
|
||||
activeBrandId,
|
||||
brands,
|
||||
enabledAddons,
|
||||
}: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -236,15 +151,52 @@ export default function AdminSidebar({
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||
: userRole === "brand_admin" ? "Brand Admin"
|
||||
: userRole === "store_employee" ? "Store Employee"
|
||||
const roleLabel =
|
||||
userRole === "platform_admin"
|
||||
? "Platform Admin"
|
||||
: userRole === "brand_admin"
|
||||
? "Brand Admin"
|
||||
: userRole === "store_employee"
|
||||
? "Store Employee"
|
||||
: null;
|
||||
|
||||
const isActive = useCallback((href: string) => {
|
||||
/**
|
||||
* Build the filtered list of visible nav items for the current viewer.
|
||||
* Honors role-gating (Command Center) and add-on gating (Water Log, Route Trace).
|
||||
* If `enabledAddons` is undefined, add-on-gated items are shown (no gating).
|
||||
*/
|
||||
const visibleItems: NavItemDef[] = NAV_GROUPS.flatMap((group) => {
|
||||
return group.items.filter((item) => {
|
||||
if (item.requiresPlatformAdmin && userRole !== "platform_admin") {
|
||||
return false;
|
||||
}
|
||||
if (item.addonKey && enabledAddons && enabledAddons[item.addonKey] === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Groups rendered, with empty ones removed. The Communications group is
|
||||
* always rendered today (Harvest Reach is always visible) — but if a
|
||||
* future iteration hides Harvest Reach for some role, the entire group
|
||||
* header goes away rather than rendering an empty section.
|
||||
*/
|
||||
const visibleGroups: NavGroup[] = NAV_GROUPS
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => visibleItems.includes(item)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
const isActive = useCallback(
|
||||
(href: string) => {
|
||||
if (href === "/admin") return pathname === "/admin";
|
||||
return pathname.startsWith(href);
|
||||
}, [pathname]);
|
||||
},
|
||||
[pathname],
|
||||
);
|
||||
|
||||
// Close mobile menu with animation
|
||||
const closeMobileMenu = useCallback(() => {
|
||||
@@ -282,31 +234,106 @@ export default function AdminSidebar({
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
// Keyboard navigation for nav items
|
||||
const handleNavKeyDown = useCallback((e: KeyboardEvent<HTMLAnchorElement>, href: string, index: number) => {
|
||||
const navLinks = NAV_ITEMS.filter(item => !item.divider);
|
||||
// Keyboard navigation for nav items (arrow up/down loops, enter/space activates)
|
||||
const handleNavKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLAnchorElement>, href: string, index: number) => {
|
||||
const total = visibleItems.length;
|
||||
if (total === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const nextIndex = (index + 1) % navLinks.length;
|
||||
const nextLink = document.querySelector(`[data-nav-index="${nextIndex}"]`) as HTMLAnchorElement;
|
||||
const nextIndex = (index + 1) % total;
|
||||
const nextLink = document.querySelector(
|
||||
`[data-nav-index="${nextIndex}"]`,
|
||||
) as HTMLAnchorElement | null;
|
||||
nextLink?.focus();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const prevIndex = index === 0 ? navLinks.length - 1 : index - 1;
|
||||
const prevLink = document.querySelector(`[data-nav-index="${prevIndex}"]`) as HTMLAnchorElement;
|
||||
const prevIndex = index === 0 ? total - 1 : index - 1;
|
||||
const prevLink = document.querySelector(
|
||||
`[data-nav-index="${prevIndex}"]`,
|
||||
) as HTMLAnchorElement | null;
|
||||
prevLink?.focus();
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
router.push(href);
|
||||
if (mobileOpen) closeMobileMenu();
|
||||
}
|
||||
}, [router, mobileOpen, closeMobileMenu]);
|
||||
},
|
||||
[router, mobileOpen, closeMobileMenu, visibleItems.length],
|
||||
);
|
||||
|
||||
async function handleLogout() {
|
||||
await signOutAction();
|
||||
}
|
||||
|
||||
// Shared nav-link renderer. Used by both the desktop sidebar and the mobile
|
||||
// slide-in panel — they're the same list, just in a different container.
|
||||
const renderNavLink = (item: NavItemDef, index: number) => {
|
||||
const active = isActive(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
data-nav-index={index}
|
||||
onClick={() => closeMobileMenu()}
|
||||
onKeyDown={(e) => handleNavKeyDown(e, item.href, index)}
|
||||
className={[
|
||||
"group flex items-center gap-2.5 pl-3 pr-2.5 py-2 rounded-r-md text-xs font-medium",
|
||||
"border-l-[3px] transition-all duration-200",
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--admin-sidebar-bg)]",
|
||||
].join(" ")}
|
||||
style={
|
||||
active
|
||||
? {
|
||||
backgroundColor: "var(--admin-sidebar-active)",
|
||||
color: "#F4F1E8",
|
||||
borderLeftColor: "var(--admin-sidebar-accent)",
|
||||
}
|
||||
: {
|
||||
color: "var(--admin-sidebar-text)",
|
||||
borderLeftColor: "transparent",
|
||||
}
|
||||
}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--admin-sidebar-hover)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}
|
||||
}}
|
||||
aria-current={active ? "page" : undefined}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span
|
||||
className="flex-shrink-0 transition-colors duration-200"
|
||||
style={{
|
||||
color: active
|
||||
? "var(--admin-sidebar-accent)"
|
||||
: "rgba(208, 203, 180, 0.6)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
{active && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile hamburger button - accessible */}
|
||||
@@ -318,7 +345,7 @@ export default function AdminSidebar({
|
||||
aria-controls="admin-sidebar"
|
||||
type="button"
|
||||
>
|
||||
<HamburgerIcon />
|
||||
<Menu className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{/* Mobile overlay backdrop with blur */}
|
||||
@@ -331,20 +358,20 @@ export default function AdminSidebar({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar panel - Elegant warm dark with smooth transitions */}
|
||||
{/* Sidebar panel - dark canvas, grouped nav, amber accent for active */}
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
id="admin-sidebar"
|
||||
className={[
|
||||
"fixed top-0 left-0 h-full w-56 z-50",
|
||||
"fixed top-0 left-0 h-full w-60 z-50",
|
||||
"border-r flex flex-col",
|
||||
"transition-transform duration-300 ease-out lg:translate-x-0",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
||||
isClosing ? "opacity-90" : "opacity-100"
|
||||
isClosing ? "opacity-90" : "opacity-100",
|
||||
].join(" ")}
|
||||
style={{
|
||||
backgroundColor: "var(--admin-sidebar-bg)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)"
|
||||
borderColor: "rgba(208, 203, 180, 0.2)",
|
||||
}}
|
||||
role="navigation"
|
||||
aria-label="Admin navigation"
|
||||
@@ -363,11 +390,9 @@ export default function AdminSidebar({
|
||||
>
|
||||
<div
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm transition-transform hover:scale-105"
|
||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
|
||||
>
|
||||
<svg className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<ArrowLeft className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-tight">Admin</span>
|
||||
</Link>
|
||||
@@ -380,113 +405,93 @@ export default function AdminSidebar({
|
||||
aria-label="Close navigation menu"
|
||||
type="button"
|
||||
>
|
||||
<CloseIcon />
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back to site link */}
|
||||
<div className="px-5 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
|
||||
<div
|
||||
className="px-5 py-3 border-b flex-shrink-0"
|
||||
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xs transition-colors flex items-center gap-1.5 hover:text-white"
|
||||
style={{ color: "var(--admin-sidebar-text)" }}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
<ArrowLeft className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
Back to Site
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav links with keyboard navigation */}
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
|
||||
<ul className="space-y-1" role="list">
|
||||
{NAV_ITEMS.map((item, index) => {
|
||||
// Divider with optional label
|
||||
if (item.divider) {
|
||||
return (
|
||||
<li key={`divider-${index}`} role="separator" aria-hidden="true">
|
||||
<div className="flex items-center gap-2 px-3 py-3 mt-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest" style={{ color: "rgba(195, 195, 193, 0.5)" }}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const active = isActive(item.href!);
|
||||
const icon = item.icon ? ICON_MAP[item.icon] : null;
|
||||
const navIndex = NAV_ITEMS.filter(i => !i.divider).findIndex(i => i.href === item.href);
|
||||
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href!}
|
||||
data-nav-index={navIndex}
|
||||
onClick={() => closeMobileMenu()}
|
||||
onKeyDown={(e) => handleNavKeyDown(e, item.href!, navIndex)}
|
||||
className={[
|
||||
"group flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs font-medium transition-all duration-200",
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--admin-sidebar-bg)]",
|
||||
active
|
||||
? "border-l-[3px]"
|
||||
: "border-l-[3px] hover:border-l-[3px]",
|
||||
].join(" ")}
|
||||
style={active ? {
|
||||
backgroundColor: "rgba(202, 117, 67, 0.15)",
|
||||
color: "#dea889",
|
||||
borderColor: "var(--admin-accent)",
|
||||
borderLeftColor: "var(--admin-accent)"
|
||||
} : {
|
||||
color: "var(--admin-sidebar-text)",
|
||||
borderColor: "transparent"
|
||||
}}
|
||||
aria-current={active ? "page" : undefined}
|
||||
tabIndex={0}
|
||||
{/* Nav groups with keyboard navigation */}
|
||||
<nav
|
||||
ref={mobileMenuRef}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden pt-3 pb-2 scrollbar-thin"
|
||||
>
|
||||
<span
|
||||
className="flex-shrink-0 transition-colors duration-200"
|
||||
style={{
|
||||
color: active ? "var(--admin-accent)" : "rgba(208, 203, 180, 0.6)"
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{active && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0 animate-pulse"
|
||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
{visibleGroups.map((group) => (
|
||||
<SideNavGroup key={group.label} label={group.label}>
|
||||
{group.items.map((item) => {
|
||||
const idx = visibleItems.findIndex((v) => v.href === item.href);
|
||||
return renderNavLink(item, idx);
|
||||
})}
|
||||
</ul>
|
||||
</SideNavGroup>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Bottom: role + sign out */}
|
||||
{/* Bottom: command-palette tip + brand picker + role + sign out */}
|
||||
<div
|
||||
className="px-4 py-5 border-t flex-shrink-0"
|
||||
className="px-4 py-4 border-t flex-shrink-0 space-y-3"
|
||||
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
||||
>
|
||||
{roleLabel && (
|
||||
{/* Cmd+K hint — the command palette itself is mounted separately
|
||||
in the admin layout; this is just a discoverability nudge. */}
|
||||
<div
|
||||
className="px-3 py-2.5 mb-3 rounded-xl border"
|
||||
className="flex items-center justify-between text-[10px] uppercase tracking-widest"
|
||||
style={{ color: "rgba(195, 195, 193, 0.5)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>Tip</span>
|
||||
<kbd
|
||||
className="font-mono px-1.5 py-0.5 rounded border"
|
||||
style={{
|
||||
color: "rgba(195, 195, 193, 0.7)",
|
||||
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)"
|
||||
borderColor: "rgba(208, 203, 180, 0.2)",
|
||||
}}
|
||||
>
|
||||
<p className="text-[10px] font-medium uppercase tracking-widest mb-0.5" style={{ color: "rgba(195, 195, 193, 0.6)" }}>
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Brand selector — only show when admin has access to brands */}
|
||||
{brands && brands.length > 0 && (
|
||||
<BrandSelector
|
||||
brands={brands}
|
||||
activeBrandId={activeBrandId ?? null}
|
||||
showAllBrandsOption={userRole === "platform_admin"}
|
||||
isMultiBrandAdmin={(brandIds?.length ?? 0) > 1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{roleLabel && (
|
||||
<div
|
||||
className="px-3 py-2.5 rounded-xl border"
|
||||
style={{
|
||||
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-[10px] font-medium uppercase tracking-widest mb-0.5"
|
||||
style={{ color: "rgba(195, 195, 193, 0.6)" }}
|
||||
>
|
||||
{roleLabel}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: "rgba(195, 195, 193, 0.8)" }}>Signed in</p>
|
||||
<p className="text-xs" style={{ color: "rgba(195, 195, 193, 0.8)" }}>
|
||||
Signed in
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
@@ -496,7 +501,7 @@ export default function AdminSidebar({
|
||||
aria-label="Sign out of admin"
|
||||
type="button"
|
||||
>
|
||||
<LogoutIcon />
|
||||
<LogOut className="w-4 h-4" aria-hidden="true" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,6 @@ type Stop = {
|
||||
time: string;
|
||||
location: string;
|
||||
active: boolean;
|
||||
deleted_at?: string | null;
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
brands: { name: string } | { name: string }[];
|
||||
|
||||
@@ -1,622 +0,0 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getPlatformMetrics,
|
||||
getPlatformActivityFeed,
|
||||
getBrandHealthSnapshot,
|
||||
getPainLog,
|
||||
resolvePainLogItem,
|
||||
createPainLogItem,
|
||||
type PlatformMetrics,
|
||||
type ActivityEvent,
|
||||
type BrandHealth,
|
||||
type PainLogItem,
|
||||
} from "@/actions/platform/command-center";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const REFRESH_INTERVAL = 30000;
|
||||
|
||||
const EVENT_COLORS: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
order_placed: { bg: "bg-violet-500/20", text: "text-violet-300", dot: "bg-violet-400" },
|
||||
order_completed: { bg: "bg-emerald-500/20", text: "text-emerald-300", dot: "bg-emerald-400" },
|
||||
pickup_completed: { bg: "bg-blue-900/300/20", text: "text-blue-300", dot: "bg-blue-400" },
|
||||
payment_failed: { bg: "bg-red-900/300/20", text: "text-red-300", dot: "bg-red-400" },
|
||||
stop_created: { bg: "bg-amber-900/300/20", text: "text-amber-300", dot: "bg-amber-400" },
|
||||
route_updated: { bg: "bg-cyan-500/20", text: "text-cyan-300", dot: "bg-cyan-400" },
|
||||
default: { bg: "bg-zinc-900/10", text: "text-white/60", dot: "bg-zinc-900/40" },
|
||||
};
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { bg: string; text: string; border: string }> = {
|
||||
critical: { bg: "bg-red-900/300/15", text: "text-red-300", border: "border-red-500/30" },
|
||||
high: { bg: "bg-orange-500/15",text: "text-orange-300", border: "border-orange-500/30" },
|
||||
medium: { bg: "bg-amber-900/300/15",text: "text-amber-300", border: "border-amber-500/30" },
|
||||
low: { bg: "bg-zinc-900/8", text: "text-white/60", border: "border-white/10" },
|
||||
};
|
||||
|
||||
const HEALTH_CONFIG: Record<string, { label: string; bg: string; text: string; border: string; dot: string }> = {
|
||||
healthy: { label: "Operational", bg: "bg-emerald-500/15", text: "text-emerald-300", border: "border-emerald-500/25", dot: "bg-emerald-400" },
|
||||
warning: { label: "Attention", bg: "bg-amber-900/300/15", text: "text-amber-300", border: "border-amber-500/25", dot: "bg-amber-400" },
|
||||
critical: { label: "Critical", bg: "bg-red-900/300/15", text: "text-red-300", border: "border-red-500/25", dot: "bg-red-400" },
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatCurrency(n: number): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency", currency: "USD",
|
||||
minimumFractionDigits: 0, maximumFractionDigits: 0,
|
||||
}).format(n);
|
||||
}
|
||||
|
||||
function formatCurrencyFull(n: number): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency", currency: "USD",
|
||||
minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
}).format(n);
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (diff < 60) return `${diff}s`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400)return `${Math.floor(diff / 3600)}h`;
|
||||
return `${Math.floor(diff / 86400)}d`;
|
||||
}
|
||||
|
||||
// ── Metric Card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
prefix,
|
||||
suffix,
|
||||
status,
|
||||
delay = 0,
|
||||
tv = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
status?: "healthy" | "warning" | "critical";
|
||||
delay?: number;
|
||||
tv?: boolean;
|
||||
}) {
|
||||
const valueColor = {
|
||||
healthy: "text-white",
|
||||
warning: "text-amber-300",
|
||||
critical: "text-red-300",
|
||||
}[status ?? "healthy"]!;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-5
|
||||
${status === "critical" ? "border-red-500/20" : ""}
|
||||
`}
|
||||
>
|
||||
<p className={`text-[10px] font-semibold uppercase tracking-[0.18em] text-white/25`}>{label}</p>
|
||||
<div className="mt-3 flex items-baseline gap-2">
|
||||
{prefix && <span className="text-2xl text-white/25">{prefix}</span>}
|
||||
<span className={`${tv ? "text-5xl" : "text-4xl"} font-black tracking-tighter leading-none ${valueColor}`}>
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
</span>
|
||||
{suffix && <span className="text-2xl text-white/25">{suffix}</span>}
|
||||
</div>
|
||||
{sub && <p className={`text-xs mt-2 text-white/20`}>{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Brand Health Card ─────────────────────────────────────────────────────────
|
||||
|
||||
function BrandCard({ brand, tv = false }: { brand: BrandHealth; tv?: boolean }) {
|
||||
const c = HEALTH_CONFIG[brand.health_status as keyof typeof HEALTH_CONFIG] ?? HEALTH_CONFIG.warning;
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border ${c.border} ${c.bg} p-6`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`font-bold text-white truncate leading-tight ${tv ? "text-2xl" : "text-lg"}`}>{brand.brand_name}</p>
|
||||
<p className={`text-sm text-white/20 mt-1`}>{brand.brand_slug}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<span className={`rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-wider ${c.bg} ${c.text} ${c.border}`}>
|
||||
{c.label}
|
||||
</span>
|
||||
<div className={`h-2 w-2 rounded-full ${c.dot}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-6 grid grid-cols-3 gap-6`}>
|
||||
{[
|
||||
{ label: "Orders", value: brand.orders_today },
|
||||
{ label: "Revenue", value: formatCurrency(Number(brand.revenue_today)) },
|
||||
{ label: "Routes", value: brand.active_stops },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="text-center">
|
||||
<p className={`text-xs font-medium text-white/20`}>{label}</p>
|
||||
<p className={`${tv ? "text-2xl" : "text-xl"} font-black text-white mt-1 leading-none`}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{brand.open_pain_items > 0 && (
|
||||
<div className="mt-4 flex items-center gap-2 rounded-xl bg-red-900/300/10 border border-red-500/20 px-3 py-2.5">
|
||||
<span className="h-2 w-2 rounded-full bg-red-400" />
|
||||
<span className={`text-sm font-semibold text-red-300`}>
|
||||
{brand.open_pain_items} open issue{brand.open_pain_items !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Activity Item ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ActivityItem({ event }: { event: ActivityEvent }) {
|
||||
const c = EVENT_COLORS[event.event_type] ?? EVENT_COLORS.default;
|
||||
const label = event.event_type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase());
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3.5 py-3.5 border-b border-white/[0.03] last:border-0">
|
||||
<div className="mt-1.5 flex-shrink-0">
|
||||
<span className={`block h-2 w-2 rounded-full ${c.dot}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`rounded-md px-2 py-0.5 text-xs font-semibold ${c.bg} ${c.text}`}>
|
||||
{label}
|
||||
</span>
|
||||
{event.brand_name && (
|
||||
<span className={`text-xs font-medium text-white/25`}>{event.brand_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<p className={`text-sm text-white/40 truncate`}>
|
||||
{event.entity_type && <span>{event.entity_type}</span>}
|
||||
{event.entity_id && <span className="font-mono text-white/15 ml-1">#{event.entity_id.slice(0, 6)}</span>}
|
||||
</p>
|
||||
<span className={`text-xs text-white/15 shrink-0`}>
|
||||
{event.created_at ? timeAgo(event.created_at) : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pain Item ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PainItem({
|
||||
item,
|
||||
onResolve,
|
||||
onSnooze,
|
||||
}: {
|
||||
item: PainLogItem;
|
||||
onResolve: (id: string) => void;
|
||||
onSnooze: (id: string) => void;
|
||||
}) {
|
||||
const c = SEVERITY_CONFIG[item.severity] ?? SEVERITY_CONFIG.low;
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border ${c.border} ${c.bg} p-4`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5 flex-wrap min-w-0">
|
||||
<span className={`rounded-full border px-2.5 py-0.5 text-xs font-bold uppercase tracking-wider ${c.bg} ${c.text} ${c.border}`}>
|
||||
{item.severity}
|
||||
</span>
|
||||
<span className={`text-sm font-semibold text-white/80 leading-tight`}>{item.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => onSnooze(item.id)}
|
||||
className="rounded-lg bg-zinc-900/5 border border-white/10 px-2.5 py-1 text-xs font-medium text-white/35 hover:bg-zinc-900/10 hover:text-white/60 transition-all"
|
||||
>
|
||||
Snooze
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onResolve(item.id)}
|
||||
className="rounded-lg bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 text-xs font-medium text-emerald-300 hover:bg-emerald-500/30 transition-all"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-2 flex items-center gap-2 text-xs text-white/25`}>
|
||||
{item.brand_name && <span>{item.brand_name}</span>}
|
||||
<span>·</span>
|
||||
<span>{item.category}</span>
|
||||
<span>·</span>
|
||||
<span>{item.created_at ? timeAgo(item.created_at) + " ago" : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Briefing ────────────────────────────────────────────────────────────────
|
||||
|
||||
function AIBriefing({ metrics, brandHealth }: {
|
||||
metrics: PlatformMetrics | null;
|
||||
brandHealth: BrandHealth[];
|
||||
}) {
|
||||
const lines: string[] = [];
|
||||
if (!metrics) return <div className="h-16 animate-pulse bg-zinc-900/[0.04] rounded-xl" />;
|
||||
|
||||
if (metrics.orders_today > 0) {
|
||||
lines.push(`${metrics.orders_today} order${metrics.orders_today !== 1 ? "s" : ""} placed today.`);
|
||||
}
|
||||
if (Number(metrics.revenue_today) > 0) {
|
||||
lines.push(`${formatCurrencyFull(Number(metrics.revenue_today))} in revenue processed.`);
|
||||
}
|
||||
if (metrics.failed_orders_today > 0) {
|
||||
lines.push(`${metrics.failed_orders_today} failed order${metrics.failed_orders_today !== 1 ? "s" : ""} need attention.`);
|
||||
}
|
||||
if (metrics.pending_orders_today > 0) {
|
||||
lines.push(`${metrics.pending_orders_today} order${metrics.pending_orders_today !== 1 ? "s" : ""} pending confirmation.`);
|
||||
}
|
||||
const crit = brandHealth.filter(b => b.health_status === "critical").length;
|
||||
if (crit > 0) lines.push(`${crit} brand${crit !== 1 ? "s" : ""} in critical state.`);
|
||||
const warn = brandHealth.filter(b => b.health_status === "warning").length;
|
||||
if (warn > 0 && crit === 0) lines.push(`${warn} brand${warn !== 1 ? "s" : ""} need attention.`);
|
||||
|
||||
if (lines.length === 0) {
|
||||
lines.push("Platform is running smoothly — no active issues.");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{lines.map((line, i) => (
|
||||
<p key={i} className="text-sm text-white/50 leading-relaxed">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CommandCenterDashboard() {
|
||||
const [metrics, setMetrics] = useState<PlatformMetrics | null>(null);
|
||||
const [activity, setActivity] = useState<ActivityEvent[]>([]);
|
||||
const [brandHealth, setBrandHealth] = useState<BrandHealth[]>([]);
|
||||
const [painLog, setPainLog] = useState<PainLogItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resolving, setResolving] = useState<Set<string>>(new Set());
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [tvMode, setTvMode] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ severity: "medium", category: "operations", title: "", description: "" });
|
||||
const [snoozed, setSnoozed] = useState<Set<string>>(new Set());
|
||||
|
||||
const tv = tvMode;
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
const [m, a, b, p] = await Promise.all([
|
||||
getPlatformMetrics(),
|
||||
getPlatformActivityFeed(),
|
||||
getBrandHealthSnapshot(),
|
||||
getPainLog(),
|
||||
]);
|
||||
setMetrics(m);
|
||||
setActivity(a);
|
||||
setBrandHealth(b);
|
||||
setPainLog(p);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
const iv = setInterval(fetchAll, REFRESH_INTERVAL);
|
||||
return () => clearInterval(iv);
|
||||
}, [fetchAll]);
|
||||
|
||||
const handleResolve = async (id: string) => {
|
||||
setResolving(prev => new Set([...prev, id]));
|
||||
try {
|
||||
const r = await resolvePainLogItem(id);
|
||||
if (r.success) setPainLog(prev => prev.filter(i => i.id !== id));
|
||||
} finally {
|
||||
setResolving(prev => { const n = new Set(prev); n.delete(id); return n; });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSnooze = (id: string) => {
|
||||
setSnoozed(prev => new Set([...prev, id]));
|
||||
setTimeout(() => {
|
||||
setSnoozed(prev => { const n = new Set(prev); n.delete(id); return n; });
|
||||
}, 3600000);
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!addForm.title.trim()) return;
|
||||
const r = await createPainLogItem({
|
||||
severity: addForm.severity as "low" | "medium" | "high" | "critical",
|
||||
category: addForm.category,
|
||||
title: addForm.title,
|
||||
description: addForm.description || null,
|
||||
});
|
||||
if (r.success) {
|
||||
setAddForm({ severity: "medium", category: "operations", title: "", description: "" });
|
||||
setShowAddForm(false);
|
||||
fetchAll();
|
||||
}
|
||||
};
|
||||
|
||||
const openItems = painLog.filter(p => p.status === "open" && !snoozed.has(p.id));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="h-14 w-14 rounded-full border-2 border-white/[0.08] border-t-white/25 animate-spin" />
|
||||
<p className="text-xs text-white/25 tracking-widest uppercase">Initializing</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center max-w-md">
|
||||
<div className="mb-5 text-5xl">⚠</div>
|
||||
<h3 className="text-xl font-bold text-white">Connection Lost</h3>
|
||||
<p className="text-sm text-white/35 mt-2">{error}</p>
|
||||
<button
|
||||
onClick={fetchAll}
|
||||
className="mt-8 rounded-2xl bg-zinc-900/10 border border-white/20 px-8 py-3 text-sm font-semibold text-white hover:bg-zinc-900/20 transition-all"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||
<div className="mb-10 flex items-center justify-between px-8">
|
||||
<div>
|
||||
<h1 className={`${tv ? "text-4xl" : "text-3xl"} font-black tracking-tight text-white`}>
|
||||
Command Center
|
||||
</h1>
|
||||
<p className={`${tv ? "text-base" : "text-sm"} text-white/25 mt-2 tracking-wide`}>
|
||||
{new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
|
||||
{" · "}
|
||||
<span className="text-emerald-400/50 font-semibold">LIVE</span>
|
||||
<span className="ml-2 inline-block h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={fetchAll}
|
||||
className="flex items-center gap-2.5 rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-2 text-sm text-white/35 hover:bg-zinc-900/10 hover:text-white/60 transition-all"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTvMode(v => !v)}
|
||||
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all ${
|
||||
tv
|
||||
? "bg-zinc-900/15 border border-white/25 text-white/80"
|
||||
: "bg-zinc-900/5 border border-white/10 text-white/35 hover:text-white/60"
|
||||
}`}
|
||||
>
|
||||
{tv ? "Exit TV" : "TV Mode"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── KPI Row ──────────────────────────────────────────────────────────── */}
|
||||
<div className={`grid ${tv ? "grid-cols-3" : "grid-cols-2 lg:grid-cols-6"} gap-5 px-8 mb-12`}>
|
||||
<MetricCard label="Active Brands" value={metrics?.active_brands ?? 0} status="healthy" />
|
||||
<MetricCard label="Orders Today" value={metrics?.orders_today ?? 0} status={metrics?.failed_orders_today ? "critical" : "healthy"} />
|
||||
<MetricCard label="Revenue Today" value={formatCurrency(Number(metrics?.revenue_today ?? 0))} status="healthy" />
|
||||
<MetricCard label="Active Routes" value={metrics?.active_routes ?? 0} status={(metrics?.pending_orders_today ?? 0) > 5 ? "warning" : "healthy"} />
|
||||
<MetricCard label="Failed Orders" value={metrics?.failed_orders_today ?? 0} status={(metrics?.failed_orders_today ?? 0) > 0 ? "critical" : "healthy"} />
|
||||
<MetricCard label="Pending Orders" value={metrics?.pending_orders_today ?? 0} status={(metrics?.pending_orders_today ?? 0) > 5 ? "warning" : "healthy"} />
|
||||
</div>
|
||||
|
||||
{/* ── AI Briefing (full width) ──────────────────────────────────────── */}
|
||||
<div className="px-8 mb-10">
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-8">
|
||||
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-white/[0.04]">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-violet-500/15 border border-violet-500/20">
|
||||
<span className="text-lg">🤖</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xs font-bold uppercase tracking-[0.22em] text-white/40">Daily Briefing</h2>
|
||||
<p className="text-xs text-white/20 mt-0.5">Platform intelligence summary</p>
|
||||
</div>
|
||||
</div>
|
||||
<AIBriefing metrics={metrics} brandHealth={brandHealth} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 2-Column Body ───────────────────────────────────────────────────── */}
|
||||
<div className={`grid gap-10 px-8 ${tv ? "grid-cols-12" : "grid-cols-1 lg:grid-cols-2"}`}>
|
||||
|
||||
{/* ── LEFT: Brand Health + Activity ───────────────────────────── */}
|
||||
<div className="space-y-10">
|
||||
{/* Brand Health */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
|
||||
Brand Health
|
||||
</h2>
|
||||
<span className="rounded-full bg-zinc-900/5 px-2.5 py-0.5 text-xs text-white/15">
|
||||
{brandHealth.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{brandHealth.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center">
|
||||
<p className="text-sm text-white/25">No active brands</p>
|
||||
</div>
|
||||
) : (
|
||||
brandHealth.map((b) => (
|
||||
<BrandCard key={b.brand_id} brand={b} tv={tv} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Feed */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
|
||||
Live Activity
|
||||
</h2>
|
||||
<span className="rounded-full bg-zinc-900/5 px-2.5 py-0.5 text-xs text-white/15">
|
||||
{activity.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl overflow-hidden">
|
||||
<div className="max-h-[420px] overflow-y-auto p-5">
|
||||
{activity.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm text-white/20">No recent events</p>
|
||||
</div>
|
||||
) : (
|
||||
activity.slice(0, 30).map((e) => (
|
||||
<ActivityItem key={e.id} event={e} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT: Founder Queue ─────────────────────────────── */}
|
||||
<div className="space-y-10">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
|
||||
Founder Queue
|
||||
</h2>
|
||||
{openItems.length > 0 && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-amber-900/300/20 text-xs font-black text-amber-300">
|
||||
{openItems.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddForm(v => !v)}
|
||||
className="text-xs text-white/25 hover:text-white/50 transition-colors"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="mb-5 space-y-2.5 rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-5">
|
||||
<select
|
||||
value={addForm.severity}
|
||||
onChange={e => setAddForm(f => ({ ...f, severity: e.target.value }))}
|
||||
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 outline-none focus:border-white/20"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
<input
|
||||
value={addForm.title}
|
||||
onChange={e => setAddForm(f => ({ ...f, title: e.target.value }))}
|
||||
placeholder="Issue title..."
|
||||
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
|
||||
/>
|
||||
<input
|
||||
value={addForm.category}
|
||||
onChange={e => setAddForm(f => ({ ...f, category: e.target.value }))}
|
||||
placeholder="Category (e.g. deployment, payment)"
|
||||
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
|
||||
/>
|
||||
<textarea
|
||||
value={addForm.description}
|
||||
onChange={e => setAddForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Description (optional)"
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
|
||||
/>
|
||||
<div className="flex gap-2.5">
|
||||
<button onClick={handleAdd} className="flex-1 rounded-xl bg-emerald-500/20 border border-emerald-500/30 px-4 py-2.5 text-sm font-semibold text-emerald-300 hover:bg-emerald-500/30 transition-all">
|
||||
Log Issue
|
||||
</button>
|
||||
<button onClick={() => setShowAddForm(false)} className="flex-1 rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-2.5 text-sm font-medium text-white/35 hover:bg-zinc-900/10 transition-all">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{openItems.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center">
|
||||
<p className="text-base font-semibold text-white/35">All clear</p>
|
||||
<p className="text-sm text-white/20 mt-1.5">Add an issue to track it here.</p>
|
||||
</div>
|
||||
) : (
|
||||
openItems.map((item) => (
|
||||
<PainItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onResolve={handleResolve}
|
||||
onSnooze={handleSnooze}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Access */}
|
||||
<div>
|
||||
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25 mb-5">
|
||||
Quick Access
|
||||
</h2>
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-4 space-y-1">
|
||||
{[
|
||||
{ label: "Reports", href: "/admin/reports" },
|
||||
{ label: "Orders", href: "/admin/orders" },
|
||||
{ label: "Stops", href: "/admin/stops" },
|
||||
{ label: "Settings", href: "/admin/settings" },
|
||||
].map(link => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="flex items-center justify-between rounded-xl px-4 py-3.5 text-sm text-white/30 hover:bg-zinc-900/5 hover:text-white/70 transition-all"
|
||||
>
|
||||
<span className="font-medium">{link.label}</span>
|
||||
<span className="text-white/10">→</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-24" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CommandPalette — Cmd+K / Ctrl+K global quick-launcher for the admin.
|
||||
*
|
||||
* Behavior:
|
||||
* - Toggled with Cmd+K (mac) or Ctrl+K (win/linux) on `document`.
|
||||
* - Closes on Escape, on backdrop click, or after a navigation.
|
||||
* - Fuzzy case-insensitive match on `label` / `category` / `keywords`.
|
||||
* - Top 8 results. Renders null when closed (no DOM noise).
|
||||
* - Keyboard: ↑/↓ to move, Enter to select, type to filter.
|
||||
*
|
||||
* Self-contained: does not read from the sidebar, does not depend on any
|
||||
* server data. The static list lives in `./command-palette-data.ts`.
|
||||
*
|
||||
* The component is intentionally NOT mounted in the admin layout by this
|
||||
* file. The main thread / sidebar agent wires it in once it's reviewed.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
BarChart3,
|
||||
Clock,
|
||||
Command,
|
||||
CreditCard,
|
||||
Droplets,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
LucideIcon,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Megaphone,
|
||||
Package,
|
||||
Plug,
|
||||
PlusCircle,
|
||||
Puzzle,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route,
|
||||
ScrollText,
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Sparkles,
|
||||
Store,
|
||||
Tag,
|
||||
Truck,
|
||||
Upload,
|
||||
UserPlus,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { PALETTE_ENTRIES, type PaletteEntry } from "./command-palette-data";
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
// Pages
|
||||
LayoutDashboard,
|
||||
Command,
|
||||
ShoppingCart,
|
||||
MapPin,
|
||||
Package,
|
||||
Truck,
|
||||
Send,
|
||||
Megaphone,
|
||||
FileText,
|
||||
Users,
|
||||
ScrollText,
|
||||
Store,
|
||||
Upload,
|
||||
Sparkles,
|
||||
Clock,
|
||||
Droplets,
|
||||
Route,
|
||||
BarChart3,
|
||||
Receipt,
|
||||
Settings,
|
||||
Tag,
|
||||
CreditCard,
|
||||
Plug,
|
||||
Wallet,
|
||||
Puzzle,
|
||||
// Quick actions
|
||||
PlusCircle,
|
||||
UserPlus,
|
||||
RefreshCw,
|
||||
// Misc / fallbacks
|
||||
Search,
|
||||
Map: MapIcon,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtering / scoring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function scoreEntry(entry: PaletteEntry, query: string): number {
|
||||
if (!query) return 1;
|
||||
const q = query.toLowerCase();
|
||||
const label = entry.label.toLowerCase();
|
||||
const category = entry.category.toLowerCase();
|
||||
|
||||
let score = 0;
|
||||
if (label === q) score += 100;
|
||||
else if (label.startsWith(q)) score += 50;
|
||||
else if (label.includes(q)) score += 25;
|
||||
|
||||
if (category.toLowerCase().includes(q)) score += 10;
|
||||
if (entry.keywords?.some((k) => k.toLowerCase().includes(q))) score += 5;
|
||||
|
||||
// Quick actions get a small boost when no specific match (so they surface
|
||||
// on the empty query list, alongside the first batch of pages).
|
||||
if (!query && entry.type === "action") score = 0.5;
|
||||
if (!query && entry.type === "page") score = 1 - entry.label.length * 0.001;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function filterEntries(query: string): PaletteEntry[] {
|
||||
const scored = PALETTE_ENTRIES
|
||||
.map((e) => ({ entry: e, score: scoreEntry(e, query) }))
|
||||
.filter((r) => r.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, MAX_RESULTS);
|
||||
return scored.map((r) => r.entry);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||
if (!query) return <>{text}</>;
|
||||
const q = query.toLowerCase();
|
||||
const idx = text.toLowerCase().indexOf(q);
|
||||
if (idx === -1) return <>{text}</>;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark
|
||||
style={{
|
||||
background: "transparent",
|
||||
color: "var(--admin-primary)",
|
||||
fontWeight: 600,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Open/close on Cmd+K / Ctrl+K. Toggle so the same shortcut closes it.
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
// Reset on open, focus the input, and lock body scroll.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
document.body.style.overflow = "";
|
||||
return;
|
||||
}
|
||||
setQuery("");
|
||||
setSelected(0);
|
||||
document.body.style.overflow = "hidden";
|
||||
// Defer focus to next frame so the input is mounted and the modal
|
||||
// animation has started (keeps the focus ring from flickering in).
|
||||
const raf = requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Global Escape while open (covers the case where focus is not on input).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
// Filtering
|
||||
const results = useMemo(() => filterEntries(query), [query]);
|
||||
|
||||
// Reset selection on every query change so the top result is always active.
|
||||
useEffect(() => {
|
||||
setSelected(0);
|
||||
}, [query]);
|
||||
|
||||
// Clamp selection to results length
|
||||
useEffect(() => {
|
||||
if (selected >= results.length && results.length > 0) {
|
||||
setSelected(results.length - 1);
|
||||
} else if (results.length === 0) {
|
||||
setSelected(0);
|
||||
}
|
||||
}, [results, selected]);
|
||||
|
||||
const navigate = useCallback(
|
||||
(href: string) => {
|
||||
setOpen(false);
|
||||
router.push(href);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// In-input keyboard nav. Attached to the input itself so it works whether
|
||||
// the user is typing or the input just has focus.
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (results.length === 0) return;
|
||||
setSelected((s) => (s + 1) % results.length);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (results.length === 0) return;
|
||||
setSelected((s) => (s - 1 + results.length) % results.length);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const entry = results[selected];
|
||||
if (entry) navigate(entry.href);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
backgroundColor: "rgba(26, 24, 20, 0.4)",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
paddingTop: "15vh",
|
||||
animation: "cp-fade-in 180ms ease-out",
|
||||
}}
|
||||
>
|
||||
{/* Local keyframes — scoped via the dialog's runtime so they don't
|
||||
leak into the global stylesheet. The panel now fades in without
|
||||
the scale(0.98) start (removed — the slight scale combined with
|
||||
the backdrop fade read as a "pop" when the palette opened). */}
|
||||
<style>{`
|
||||
@keyframes cp-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes cp-scale-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@keyframes cp-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes cp-scale-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "36rem",
|
||||
margin: "0 1rem",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
border: "1px solid var(--admin-border)",
|
||||
borderRadius: "var(--admin-radius-lg)",
|
||||
boxShadow: "var(--admin-shadow-lg)",
|
||||
overflow: "hidden",
|
||||
animation: "cp-scale-in 180ms ease-out",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Search input row */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "0.875rem 1rem",
|
||||
borderBottom: "1px solid var(--admin-border)",
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
size={18}
|
||||
style={{ color: "var(--admin-text-muted)", flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="Search pages, actions, anything…"
|
||||
aria-label="Search command palette"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
fontSize: "1rem",
|
||||
lineHeight: 1.4,
|
||||
color: "var(--admin-text-primary)",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Results"
|
||||
style={{
|
||||
maxHeight: "60vh",
|
||||
overflowY: "auto",
|
||||
padding: "0.375rem",
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
TODO (next pass): recent items
|
||||
- Persist a small ring buffer of last-visited entries in localStorage
|
||||
under "rc-cmdk-recent" (cap at 5).
|
||||
- Read on mount, render as a "Recent" section above "Pages" when
|
||||
present and the query is empty.
|
||||
- Update on `navigate()` after a successful route push.
|
||||
Skipped for v1 per the design spec.
|
||||
*/}
|
||||
{results.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "2rem 1rem",
|
||||
textAlign: "center",
|
||||
color: "var(--admin-text-muted)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
No results for “{query}”
|
||||
</div>
|
||||
) : (
|
||||
results.map((entry, i) => {
|
||||
const Icon = ICON_MAP[entry.iconName] ?? Search;
|
||||
const isSelected = i === selected;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => navigate(entry.href)}
|
||||
onMouseEnter={() => setSelected(i)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
width: "100%",
|
||||
padding: "0.625rem 0.75rem",
|
||||
borderRadius: "var(--admin-radius-md)",
|
||||
border: "none",
|
||||
background: isSelected
|
||||
? "var(--admin-primary-soft)"
|
||||
: "transparent",
|
||||
color: isSelected
|
||||
? "var(--admin-primary)"
|
||||
: "var(--admin-text-primary)",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.3,
|
||||
fontFamily: "inherit",
|
||||
transition:
|
||||
"background-color 80ms ease-out, color 80ms ease-out",
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
opacity: isSelected ? 1 : 0.7,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<HighlightedText text={entry.label} query={query} />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.6875rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--admin-text-muted)",
|
||||
fontWeight: 500,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{entry.category}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem 1rem 0.625rem",
|
||||
borderTop: "1px solid var(--admin-border)",
|
||||
fontSize: "0.6875rem",
|
||||
color: "var(--admin-text-muted)",
|
||||
textAlign: "center",
|
||||
letterSpacing: "0.02em",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
↑↓ to navigate · ↵ to select · esc to close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,14 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
type SuccessState = {
|
||||
user: import("@/actions/admin/users").AdminUserRow;
|
||||
tempPassword: string;
|
||||
emailSent: boolean;
|
||||
emailError?: string;
|
||||
authPath?: "admin" | "signup";
|
||||
};
|
||||
|
||||
const FLAG_LABELS: Record<string, string> = {
|
||||
can_manage_products: "Products",
|
||||
can_manage_stops: "Stops",
|
||||
@@ -61,6 +69,8 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
|
||||
const [flags, setFlags] = useState<Record<string, boolean>>({ ...defaultFlags });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<SuccessState | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const showBrandSelect = role === "brand_admin" || role === "store_employee";
|
||||
|
||||
@@ -82,6 +92,8 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
|
||||
setBrandId(null);
|
||||
setFlags({ ...defaultFlags });
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setCopied(false);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -111,10 +123,16 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.user) {
|
||||
if (result.user && result.tempPassword) {
|
||||
onSuccess(result.user);
|
||||
resetForm();
|
||||
onClose();
|
||||
setSuccess({
|
||||
user: result.user,
|
||||
tempPassword: result.tempPassword,
|
||||
emailSent: result.emailSent ?? false,
|
||||
emailError: result.emailError,
|
||||
authPath: result.authPath,
|
||||
});
|
||||
// Don't close — show the success state so the caller can copy the temp password.
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "An unexpected error occurred.");
|
||||
@@ -130,6 +148,17 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPassword() {
|
||||
if (!success) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(success.tempPassword);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard not available — caller can select manually.
|
||||
}
|
||||
}
|
||||
|
||||
const roleDescriptions: Record<Role, string> = {
|
||||
platform_admin: "Full platform access",
|
||||
brand_admin: "Brand-level admin",
|
||||
@@ -138,6 +167,117 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Success state — show temp password + email status, then "Done" closes.
|
||||
if (success) {
|
||||
return (
|
||||
<GlassModal
|
||||
title="User Created"
|
||||
titleIcon={
|
||||
<svg className="h-5 w-5 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
}
|
||||
subtitle={`${success.user.display_name ?? success.user.email} is ready to sign in`}
|
||||
onClose={handleClose}
|
||||
maxWidth="max-w-lg"
|
||||
>
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
<p className="text-sm text-[var(--admin-text-secondary)]">
|
||||
The account has been created in Neon Auth and linked to the local admin record.
|
||||
Share the temporary password below with the new user — it will not be shown again.
|
||||
</p>
|
||||
|
||||
{/* Email */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-3 sm:p-4">
|
||||
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
|
||||
Sign-in email
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)] break-all">
|
||||
{success.user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Temp password */}
|
||||
<div className="rounded-xl border border-amber-300/80 bg-amber-50/80 p-3 sm:p-4">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-800">
|
||||
Temporary password
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyPassword}
|
||||
className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-900 hover:text-amber-700 transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="font-mono text-sm text-amber-900 break-all select-all">
|
||||
{success.tempPassword}
|
||||
</p>
|
||||
<p className="text-[11px] text-amber-700 mt-2">
|
||||
The user should change this on first sign-in (must_change_password is set).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email delivery status */}
|
||||
<div
|
||||
className={`rounded-xl border p-3 sm:p-4 ${
|
||||
success.emailSent
|
||||
? "border-emerald-200/80 bg-emerald-50/60"
|
||||
: "border-rose-200/80 bg-rose-50/60"
|
||||
}`}
|
||||
>
|
||||
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
|
||||
Welcome email
|
||||
</p>
|
||||
{success.emailSent ? (
|
||||
<p className="text-sm text-emerald-800">
|
||||
Sent to {success.user.email}. The user has the password in their inbox.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-rose-800">
|
||||
Could not be sent automatically
|
||||
{success.emailError ? `: ${success.emailError}` : "."} Share the password above out-of-band.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{success.authPath === "signup" && (
|
||||
<p className="text-[11px] text-[var(--admin-text-muted)]">
|
||||
Note: the admin sign-up endpoint was unavailable, so the account was created via
|
||||
the public sign-up path. This is fine — the user can sign in normally.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassModal
|
||||
title="Create User"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,473 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { createStop } from "@/actions/stops/create-stop";
|
||||
import { updateStop } from "@/actions/stops/update-stop";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
active: boolean;
|
||||
brand_id: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
brandId: string;
|
||||
stop?: Stop | null;
|
||||
onSuccess?: (stopId: string) => void;
|
||||
};
|
||||
|
||||
/* Pin icon for the modal header */
|
||||
const PinIcon = () => (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function EditStopModal({ isOpen, onClose, brandId, stop, onSuccess }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [city, setCity] = useState("");
|
||||
const [stateField, setStateField] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [zip, setZip] = useState("");
|
||||
const [cutoffTime, setCutoffTime] = useState("");
|
||||
const [status, setStatus] = useState<"draft" | "active">("draft");
|
||||
|
||||
const cityRef = useRef<HTMLInputElement>(null);
|
||||
const isEditing = Boolean(stop);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (stop) {
|
||||
// Edit mode - populate from existing stop.
|
||||
// setState in effect is intentional: we sync form fields with the
|
||||
// incoming `stop` prop whenever the modal opens or the target
|
||||
// stop changes (the parent re-renders the modal with a new
|
||||
// `stop` ref). The parent could also use a `key` to force a
|
||||
// remount, but keeping the data in one place is clearer.
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
setCity(stop.city);
|
||||
setStateField(stop.state);
|
||||
setLocation(stop.location);
|
||||
setDate(stop.date);
|
||||
setTime(stop.time || "");
|
||||
setAddress(stop.address ?? "");
|
||||
setZip(stop.zip ?? "");
|
||||
setCutoffTime(stop.cutoff_time ? stop.cutoff_time.slice(0, 16) : "");
|
||||
setStatus(stop.active ? "active" : "draft");
|
||||
} else {
|
||||
// Add mode - reset form
|
||||
setCity("");
|
||||
setStateField("");
|
||||
setLocation("");
|
||||
setDate("");
|
||||
setTime("");
|
||||
setAddress("");
|
||||
setZip("");
|
||||
setCutoffTime("");
|
||||
setStatus("draft");
|
||||
}
|
||||
setError(null);
|
||||
requestAnimationFrame(() => cityRef.current?.focus());
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}
|
||||
}, [isOpen, stop]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
|
||||
setError("City, state, location, and date are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
let result;
|
||||
if (isEditing && stop) {
|
||||
result = await updateStop(stop.id, brandId, {
|
||||
city: city.trim(),
|
||||
state: stateField.trim(),
|
||||
location: location.trim(),
|
||||
date,
|
||||
time: time || "08:00",
|
||||
address: address || null,
|
||||
zip: zip || null,
|
||||
cutoff_time: cutoffTime || null,
|
||||
active: status === "active",
|
||||
});
|
||||
} else {
|
||||
result = await createStop(brandId, {
|
||||
city: city.trim(),
|
||||
state: stateField.trim(),
|
||||
location: location.trim(),
|
||||
date,
|
||||
time: time || "08:00",
|
||||
address: address || undefined,
|
||||
zip: zip || undefined,
|
||||
cutoff_time: cutoffTime || undefined,
|
||||
active: status === "active",
|
||||
});
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
const newStopId = isEditing ? stop!.id : (result as { success: true; id: string }).id;
|
||||
onSuccess?.(newStopId);
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error ?? `Failed to ${isEditing ? "update" : "create"} stop`);
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
brandId,
|
||||
city,
|
||||
stateField,
|
||||
location,
|
||||
date,
|
||||
time,
|
||||
address,
|
||||
zip,
|
||||
cutoffTime,
|
||||
status,
|
||||
isEditing,
|
||||
stop,
|
||||
onSuccess,
|
||||
onClose,
|
||||
]
|
||||
);
|
||||
|
||||
const title = isEditing ? "Edit Stop" : "Add Stop";
|
||||
const eyebrow = isEditing
|
||||
? `${stop?.city}, ${stop?.state}`
|
||||
: "New stop on the route";
|
||||
const submitLabel = loading
|
||||
? isEditing ? "Saving…" : "Creating…"
|
||||
: isEditing
|
||||
? "Save Changes"
|
||||
: status === "active"
|
||||
? "Create & Publish"
|
||||
: "Save as Draft";
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<GlassModal
|
||||
title={title}
|
||||
eyebrow={eyebrow}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-xl"
|
||||
compact
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
|
||||
style={{ background: "var(--admin-danger-soft)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)" }}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 1 — Where: City + State */}
|
||||
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-city" className="ha-field-label">
|
||||
<PinIcon />
|
||||
<span>City</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
ref={cityRef}
|
||||
id="stop-city"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Denver"
|
||||
className="ha-field-input"
|
||||
required
|
||||
autoComplete="address-level2"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-state" className="ha-field-label">
|
||||
<span>State</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-state"
|
||||
type="text"
|
||||
value={stateField}
|
||||
onChange={(e) => setStateField(e.target.value.toUpperCase())}
|
||||
placeholder="CO"
|
||||
maxLength={2}
|
||||
className="ha-field-input ha-field-input-mono text-center"
|
||||
style={{ textTransform: "uppercase" }}
|
||||
required
|
||||
autoComplete="address-level1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 — Where at: Location (full) */}
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-location" className="ha-field-label">
|
||||
<span>Location / Venue</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-location"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Whole Foods Market — Highlands"
|
||||
className="ha-field-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 3 — When: Date + Time */}
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-date" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Date</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-time" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Time</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-time"
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4 — Address + ZIP + Cutoff */}
|
||||
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-address" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
|
||||
<path d="M9 22V12h6v10" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>Street Address</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-address"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="ha-field-input"
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-zip" className="ha-field-label">
|
||||
<span>ZIP</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-zip"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80202"
|
||||
maxLength={10}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
autoComplete="postal-code"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-cutoff" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Cutoff</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-cutoff"
|
||||
type="time"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5 — Status: segmented control */}
|
||||
<div className="ha-field pt-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="ha-field-label">
|
||||
<span>Visibility</span>
|
||||
</label>
|
||||
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
|
||||
Draft is hidden from customers
|
||||
</span>
|
||||
</div>
|
||||
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "draft"}
|
||||
onClick={() => setStatus("draft")}
|
||||
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
|
||||
>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Save as Draft</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "active"}
|
||||
onClick={() => setStatus("active")}
|
||||
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
|
||||
>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Publish Now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="ha-modal-footer">
|
||||
<span className="ha-modal-footer-hint">
|
||||
<kbd>Esc</kbd>
|
||||
to close
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ha-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="ha-btn-primary"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isEditing ? (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : status === "active" ? (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook for easy integration
|
||||
export function useEditStopModal(brandId: string) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [editingStop, setEditingStop] = useState<{
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
active: boolean;
|
||||
brand_id: string;
|
||||
} | null>(null);
|
||||
|
||||
const open = useCallback((stop?: typeof editingStop) => {
|
||||
setEditingStop(stop ?? null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setEditingStop(null);
|
||||
}, []);
|
||||
|
||||
const Modal = useCallback(() => (
|
||||
<EditStopModal
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
brandId={brandId}
|
||||
stop={editingStop}
|
||||
/>
|
||||
), [isOpen, close, brandId, editingStop]);
|
||||
|
||||
return { open, close, Modal, editingStop };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user