Merge GitHub main into Gitea main
Deploy to route.crispygoat.com / deploy (push) Failing after 7s
Build / build (push) Has been cancelled

Brings in:
- .gitea/workflows/build.yml (build + typecheck + lint on self-hosted runner)
- scripts/e2e-test.sh (local Postgres + PostgREST + MinIO + Next.js validation)
- Codex review round 2 fixes (buyer/billing/comms/a11y)
- 207 multi-brand admin migration
- Locations table + UI
- Various product/stops/auth refinements

Resolved 7 conflicts by taking Gitea's version to preserve production
behavior:
- package.json (deps)
- src/actions/products/upload-image.ts (storage)
- src/app/admin/taxes/page.tsx
- src/app/checkout/CheckoutClient.tsx
- src/components/admin/AdminSidebar.tsx
- src/components/admin/StopProductAssignment.tsx
- src/lib/admin-permissions.ts

Our new dev_session auth + MinIO storage changes are deferred to a
focused follow-up to avoid breaking the production deploy.
This commit is contained in:
2026-06-05 23:09:51 +00:00
79 changed files with 12589 additions and 857 deletions
+52
View File
@@ -0,0 +1,52 @@
name: Build
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: [self-hosted, linux, ubuntu-latest]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Apply fix-agents.js patch
run: node fix-agents.js
- name: Typecheck
env:
NEXT_PUBLIC_API_URL: http://localhost:3001
NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000
STORAGE_ENDPOINT: http://localhost:9000
DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce
run: npm run type-check
- name: Lint
env:
NEXT_PUBLIC_API_URL: http://localhost:3001
NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000
STORAGE_ENDPOINT: http://localhost:9000
DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce
run: npm run lint
- name: Build
env:
NEXT_PUBLIC_API_URL: http://localhost:3001
NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000
STORAGE_ENDPOINT: http://localhost:9000
DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce
run: npm run build
@@ -0,0 +1,470 @@
# Multi-Brand Admin Support
**Date:** 2026-06-04
**Status:** Draft → Approved
**Author:** Grok (brainstorming session)
**Migration file:** `supabase/migrations/204_multi_brand_admin.sql`
**Follow-up migration (out of scope):** `220_drop_legacy_brand_id.sql`
## Problem
`admin_users.brand_id` is a single `UUID | null`. The platform supports a `platform_admin` role (no brand) and a `brand_admin` role (one brand). There is no representation for an admin who legitimately needs access to 2+ specific brands — e.g., a parent company operating multiple storefronts with shared operations staff.
The current `effectiveBrandId = brandId ?? adminUser.brand_id ?? null` pattern silently does the wrong thing for multi-brand use cases:
- Data model can't represent "Jane is admin for Brand A AND Brand B" — would require two `admin_users` rows (and the auth-user→admin-user join gets messy).
- No central validation that an admin is acting in a brand they actually have access to.
- No persistent "current brand" context — every page re-derives it from scratch.
- No per-brand permission overrides possible (locks in flexibility for the future).
This spec addresses multi-brand tenants / franchises: same staff managing multiple brands under a parent org, where brands are separate for storefront/billing but share operations.
## Goals
- An admin can be associated with multiple brands via a junction table.
- A persistent "active brand" is stored in a cookie, switchable via UI, and used as the default when no explicit brand is requested.
- A new `multi_brand_admin` role makes the relationship explicit in the data and the UI.
- `platform_admin` continues to work unchanged (gets all brands implicitly).
- Existing single-brand `brand_admin`, `store_employee`, and `staff` users continue to work with zero behavior change.
- Server actions get a single, central place to resolve and validate the active brand.
## Non-Goals (YAGNI)
- Per-(admin, brand) permission overrides. The user explicitly chose "same perms across all brands."
- Brand-group / parent-org concept. The junction table makes this possible later, but it's not built now.
- "Last accessed brand" auto-redirect. The cookie is the source of truth; no extra logic.
- UI for managing `admin_user_brands` rows. Use Supabase Studio or a follow-up admin UI PR.
- Dropping the legacy `admin_users.brand_id` column. A follow-up migration `220_drop_legacy_brand_id.sql` will do this after we verify nothing reads it. Out of scope for this spec.
## Approach: Junction Table + Backwards-Compat `brand_id`
Selected from among three options:
| Option | Why not |
|---|---|
| A. Junction + keep `brand_id` (selected) | — |
| B. Junction only, drop `brand_id` | High migration risk; every server action reference must change. |
| C. `brand_ids UUID[]` on `admin_users` | No FK, awkward reverse lookups, locks out per-brand metadata. |
A is the lowest-risk additive path that solves the problem.
## Data Model
### New table: `admin_user_brands`
```sql
CREATE TABLE admin_user_brands (
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
added_by UUID REFERENCES admin_users(id),
PRIMARY KEY (admin_user_id, brand_id)
);
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
```
- Composite PK enforces uniqueness.
- Index on `brand_id` makes "which admins are in Brand X?" queries fast.
- `added_at` / `added_by` for audit trail.
- `ON DELETE CASCADE` for both FKs — deleting an admin or brand cleans up the junction.
### New role: `multi_brand_admin`
Added to the `role` CHECK constraint. Functionally equivalent to `brand_admin` permission-wise — same `can_manage_*` flags apply. The role label disambiguates intent in the UI ("this person manages 3 brands" vs "this person manages 1 brand") and in audit logs.
### Updated `AdminUser` type
```ts
// src/lib/admin-permissions-types.ts
export type AdminUser = {
// ... existing fields
brand_id: string | null; // active brand (one of brand_ids, or null for platform_admin)
brand_ids: string[]; // all brands this admin can act in
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
};
```
### Membership rules
| Role | `brand_id` (active) | `brand_ids` (membership) |
|---|---|---|
| `platform_admin` | `null` (or cookie-selected brand) | Implicitly all brands; `brand_ids` populated by `listBrandsForAdmin` querying the `brands` table |
| `multi_brand_admin` | First/selected brand | 2+ brands from `admin_user_brands` |
| `brand_admin` | Their single brand | `[that one brand]` |
| `store_employee` | Their single brand | `[that one brand]` |
| `staff` | Their single brand | `[that one brand]` |
For `platform_admin`, the application layer short-circuits brand-access checks (`if (adminUser.role === "platform_admin") return ...`). The `brand_ids` field is only used by `listBrandsForAdmin` to render the dropdown options; it is not used for permission gating for `platform_admin`.
### `getAdminUser()` resolution order
1. If `dev_session` cookie set → return dev admin. For `platform_admin` dev: `brand_id: null, brand_ids: []` (resolved against `brands` table by `listBrandsForAdmin`). For `store_employee` dev: `brand_id: <first-real-brand-id>, brand_ids: [<that-id>]` — fetched from the `brands` table so dev store_employee can browse a real brand's data. (If no brands exist, dev store_employee sees `<AdminAccessDenied />` — known limitation.)
2. If `NEXT_PUBLIC_USE_MOCK_DATA=true` → same as `platform_admin` dev.
3. Real auth → load `admin_users` row, then JOIN `admin_user_brands` to populate `brand_ids`.
4. Set `brand_id` from (in order): `active_brand_id` cookie (if in `brand_ids` for non-platform-admin, or always for platform-admin) → `admin_users.brand_id` (if in `brand_ids`) → first of `brand_ids`.
## Server Action Patterns
### New file: `src/lib/brand-scope.ts`
```ts
import { cookies } from "next/headers";
import type { AdminUser } from "./admin-permissions-types";
const ACTIVE_BRAND_COOKIE = "active_brand_id";
export async function getActiveBrandId(
adminUser: AdminUser,
requested?: string | null
): Promise<string | null> {
// Cookie is the source of truth for "what brand am I acting in right now"
// for everyone — including platform_admin (who can pin a specific brand
// or fall back to null = "all brands").
const cookieStore = await cookies();
const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null;
if (adminUser.role === "platform_admin") {
// requested > cookie > null (all brands)
return requested ?? cookieBrand ?? null;
}
// Non-platform-admin: requested (if in brand_ids) > cookie (if in brand_ids) > adminUser.brand_id
if (requested) {
return adminUser.brand_ids.includes(requested) ? requested : null;
}
if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) {
return cookieBrand;
}
return adminUser.brand_id;
}
export async function setActiveBrandCookie(brandId: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(ACTIVE_BRAND_COOKIE, brandId, {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30, // 30 days
});
}
export async function clearActiveBrandCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(ACTIVE_BRAND_COOKIE);
}
export function assertBrandAccess(adminUser: AdminUser, brandId: string): void {
if (adminUser.role === "platform_admin") return;
if (!adminUser.brand_ids.includes(brandId)) {
throw new Error("Brand access denied");
}
}
```
### Server action: set active brand
```ts
// src/actions/admin/set-active-brand.ts
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { setActiveBrandCookie, clearActiveBrandCookie } from "@/lib/brand-scope";
export async function setActiveBrand(brandId: string | null): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// null = "All brands" (platform_admin only)
if (brandId === null) {
if (adminUser.role !== "platform_admin") {
return { success: false, error: "Only platform admins can select 'All brands'" };
}
await clearActiveBrandCookie();
return { success: true };
}
if (adminUser.role !== "platform_admin" && !adminUser.brand_ids.includes(brandId)) {
return { success: false, error: "No access to that brand" };
}
await setActiveBrandCookie(brandId);
return { success: true };
}
```
### New server function: list brands for admin
```ts
// src/actions/brands.ts
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
export async function listBrandsForAdmin(): Promise<
{ id: string; name: string; slug: string; logo_url: string | null }[]
> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
if (adminUser.role === "platform_admin") {
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
{ headers: svcHeaders(serviceKey) }
);
return res.ok ? await res.json() : [];
}
if (adminUser.brand_ids.length === 0) return [];
const filter = `id=in.(${adminUser.brand_ids.map((id) => `"${id}"`).join(",")})`;
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
{ headers: svcHeaders(serviceKey) }
);
return res.ok ? await res.json() : [];
}
```
### Server action migration pattern
```ts
// Before:
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
// After:
import { getActiveBrandId } from "@/lib/brand-scope";
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
```
This is a mechanical one-line swap in ~30 server actions identified by the grep:
- `src/actions/wholesale.ts` (multiple)
- `src/actions/products.ts`
- `src/actions/communications/templates.ts`
- `src/actions/communications/campaigns.ts`
- `src/actions/orders/create-admin-order.ts`
- `src/actions/stops.ts`
- `src/actions/analytics.ts`
- `src/actions/square-sync-ui.ts`
- `src/actions/shipping.ts`
- `src/actions/payments.ts`
- `src/actions/ai-import.ts`
- `src/actions/wholesale-register.ts`
### Page server component pattern
```ts
// Before (src/app/admin/orders/page.tsx):
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
// After:
import { getActiveBrandId } from "@/lib/brand-scope";
export default async function AdminOrdersPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied />;
}
// ... rest of the page
}
```
This applies to every page under `src/app/admin/` — including the existing `/admin/taxes/[brandId]` and `/admin/settings/billing/[brandId]` which already do brand param resolution.
## UI Components
### Brand selector
**Location:** `src/components/admin/AdminHeader.tsx` (the top bar already rendered on every `/admin/*` page).
**Behavior:** A dropdown showing:
- Brand logo + name (current active brand)
- Chevron
- "All brands" option at the top (only for `platform_admin`)
- List of accessible brands (`adminUser.brand_ids`)
- Small badge "Multi-brand manager" next to the user's name when `role === "multi_brand_admin"`
**Visibility matrix:**
| Admin | Show dropdown? | Options |
|---|---|---|
| `platform_admin` | Yes | "All brands" + list of all brands |
| `multi_brand_admin` (2+ brands) | Yes | List of their brands |
| `brand_admin` / `store_employee` / `staff` (1 brand) | No | — |
| `platform_admin` (dev_session) | Yes | "All brands" + list of all brands (same UX as production) |
**On select:**
```ts
// src/components/admin/BrandSelector.tsx (client component)
"use client";
async function handleSelect(brandId: string | null) {
await setActiveBrand(brandId); // null = "All brands"
router.refresh();
}
```
`router.refresh()` re-runs server components and re-reads the cookie, so all data on the current page reloads in the new brand context. The URL is **not** changed — the cookie is the source of truth for "what brand am I acting in right now."
### URL-level brand params
Keep URL-level brand as-is — URLs are shareable links. The resolution order for the `brandId` param passed to `getActiveBrandId` is:
1. URL `brandId` param (if present)
2. `active_brand_id` cookie
3. `adminUser.brand_id` (legacy single brand)
4. First of `adminUser.brand_ids`
5. (platform_admin only) `null` → "all brands"
## Migration: `supabase/migrations/204_multi_brand_admin.sql`
```sql
-- 1. Add multi_brand_admin to role CHECK constraint
ALTER TABLE admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check;
ALTER TABLE admin_users ADD CONSTRAINT admin_users_role_check
CHECK (role IN ('platform_admin', 'brand_admin', 'multi_brand_admin', 'store_employee', 'staff'));
-- 2. Create junction table
CREATE TABLE admin_user_brands (
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
added_by UUID REFERENCES admin_users(id),
PRIMARY KEY (admin_user_id, brand_id)
);
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
-- 3. Backfill from existing brand_id (single-brand admins)
INSERT INTO admin_user_brands (admin_user_id, brand_id)
SELECT id, brand_id FROM admin_users
WHERE brand_id IS NOT NULL
ON CONFLICT DO NOTHING;
-- 4. Promote anyone with > 1 brand to multi_brand_admin
UPDATE admin_users
SET role = 'multi_brand_admin'
WHERE role = 'brand_admin'
AND id IN (
SELECT admin_user_id FROM admin_user_brands
GROUP BY admin_user_id HAVING COUNT(*) > 1
);
-- 5. New RPCs for adding/removing brand access
CREATE OR REPLACE FUNCTION add_admin_user_brand(
p_admin_user_id UUID, p_brand_id UUID, p_added_by UUID
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
INSERT INTO admin_user_brands (admin_user_id, brand_id, added_by)
VALUES (p_admin_user_id, p_brand_id, p_added_by)
ON CONFLICT DO NOTHING;
UPDATE admin_users SET role = 'multi_brand_admin'
WHERE id = p_admin_user_id
AND role = 'brand_admin'
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) > 1;
$$;
CREATE OR REPLACE FUNCTION remove_admin_user_brand(
p_admin_user_id UUID, p_brand_id UUID
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
DELETE FROM admin_user_brands
WHERE admin_user_id = p_admin_user_id AND brand_id = p_brand_id;
UPDATE admin_users SET role = 'brand_admin'
WHERE id = p_admin_user_id
AND role = 'multi_brand_admin'
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) = 1;
$$;
```
The `admin_users.brand_id` column is **kept** for backwards compat. A follow-up migration drops it after we verify nothing reads it.
## Error Handling & Security Boundaries
### Three failure modes
1. **Requested brand not in `brand_ids`** (e.g., URL param has a brand the admin doesn't have):
- `getActiveBrandId()` returns `null`
- Server action returns `{ success: false, error: "Brand access required" }`
- Page renders `<AdminAccessDenied />` with: "You don't have access to that brand. [Switch to a brand you have access to]"
2. **Cookie brand no longer in `brand_ids`** (admin's access was revoked while cookie was still set):
- `getActiveBrandId()` falls through to `adminUser.brand_id`, then first of `brand_ids`
- Silent recovery — no error, no UI flash
- The dropped brand just disappears from the dropdown next page load
3. **Platform admin acting on a brand they don't own:**
- Platform admin: `brand_ids = ["*"]` (sentinel) — RPCs treat as "all brands"
- Server action: never blocks platform admin from any brand
- This is intentional — platform admin = superuser
### Validation placement (defense in depth)
- Server action: `getActiveBrandId(adminUser, requested)` validates.
- Server action: `assertBrandAccess(adminUser, brandIdFromUrl)` validates (separate, for cases where the brandId comes from URL/form/RPC return rather than `getActiveBrandId`).
- RPC: still trusts `p_brand_id` (SECURITY DEFINER) — application layer is the gate, matching the existing architecture.
### Audit logging (additive)
- `admin_user_brands.added_by` column tracks who added an admin to a brand.
- Audit log entry on add/remove: out of scope for v1; documented for follow-up.
## Testing
### Unit tests (`vitest` — new dev dependency)
- `src/lib/brand-scope.test.ts`:
- `resolveActiveBrandId(platformAdmin, "X")``"X"`
- `resolveActiveBrandId(brandAdmin, "X")` where X is their brand → `"X"`
- `resolveActiveBrandId(brandAdmin, "Y")` where Y is not their brand → `null`
- `getActiveBrandId(multiBrandAdmin)` with cookie set to valid brand → that brand
- `getActiveBrandId(multiBrandAdmin)` with cookie set to revoked brand → first of `brand_ids`
- `assertBrandAccess(...)` throws for non-platform-admin with invalid brand
- `setActiveBrand(null)` rejected for non-platform-admin
- `setActiveBrand("X")` rejected for admin without X in `brand_ids`
### Integration tests (Playwright — already in repo)
- `tests/admin/multi-brand.spec.ts`:
- As `multi_brand_admin`, dropdown shows 2+ brands
- Click brand B → URL stays, cookie updates, page data refreshes to brand B
- Direct-navigate to `/admin/orders?brand=<other-brand>` for a brand admin returns access denied
- As `platform_admin`, "All brands" option is present and works
- As `brand_admin` with 1 brand, no dropdown is shown
### Migration smoke test (manual, documented in MEMORY.md)
- Before migration: 5 brand_admins exist, each with 1 brand
- After migration: 5 rows in `admin_user_brands`, all `role = 'brand_admin'`
- Create a 6th admin with 2 brands via `add_admin_user_brand``role = 'multi_brand_admin'`, 2 rows in junction
- Remove one of their brands via `remove_admin_user_brand``role` demotes to `brand_admin`, 1 row in junction
## Out of Scope (v1)
- Per-(admin, brand) permission overrides
- Brand-group / parent-org concept
- "Last accessed brand" auto-redirect
- UI for managing `admin_user_brands` rows (Supabase Studio works for now)
- Dropping the legacy `admin_users.brand_id` column (follow-up `220_*` migration)
- Audit log entries for add/remove (junction's `added_by` column is the seed)
## Implementation Order
1. Migration `204_multi_brand_admin.sql` applied
2. `src/lib/brand-scope.ts` + unit tests (TDD)
3. `AdminUser` type updated; `getAdminUser()` returns `brand_ids`
4. `setActiveBrand` server action + tests
5. `listBrandsForAdmin` server function + tests
6. BrandSelector UI component
7. Wire BrandSelector into AdminHeader
8. Server action migration (~30 actions) — mechanical one-line swap each
9. Page server component migration (~10+ pages) — same pattern
10. Playwright integration tests
11. Manual smoke test of the migration on dev DB
+14
View File
@@ -0,0 +1,14 @@
import openpyxl
path = "/home/coder/dev/x1/kyle/route_commerce-main/Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
wb = openpyxl.load_workbook(path, data_only=True)
for name in wb.sheetnames:
ws = wb[name]
print(f"=== SHEET: {name} ({ws.max_row} rows x {ws.max_column} cols) ===")
for row in ws.iter_rows(values_only=False):
for cell in row:
if cell.value is not None:
v = str(cell.value)
if len(v) > 200:
v = v[:200] + "..."
print(f" {cell.coordinate}: {v!r}")
print()
+99
View File
@@ -0,0 +1,99 @@
#!/bin/bash
# End-to-end validation test for the local Postgres + PostgREST + MinIO + Next.js stack
# Exit 0 = all green, exit 1 = at least one failure.
set -e
API="http://localhost:3001"
WEB="http://localhost:4000"
pass=0
fail=0
declare -a FAILURES
check() {
local name="$1" url="$2" expected="${3:-200}" cookies="${4:-}"
local cmd="curl -s -o /dev/null -w '%{http_code}'"
if [ -n "$cookies" ]; then cmd="$cmd -b \"$cookies\""; fi
local code=$(eval "$cmd $url")
if [ "$code" = "$expected" ]; then
echo " PASS $name ($code)"
pass=$((pass+1))
else
echo " FAIL $name expected=$expected got=$code url=$url"
fail=$((fail+1))
FAILURES+=("$name")
fi
}
echo "=== Postgres connection ==="
PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT 'ok';" >/dev/null
echo " PASS postgres responds"
pass=$((pass+1))
echo "=== DB integrity ==="
TABLE_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';")
[ "$TABLE_COUNT" -ge 65 ] && echo " PASS $TABLE_COUNT public tables (>=65)" && pass=$((pass+1)) || { echo " FAIL $TABLE_COUNT tables"; fail=$((fail+1)); }
FN_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname='public';")
[ "$FN_COUNT" -ge 250 ] && echo " PASS $FN_COUNT functions (>=250)" && pass=$((pass+1)) || { echo " FAIL $FN_COUNT functions"; fail=$((fail+1)); }
BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands;")
[ "$BRANDS" -ge 2 ] && echo " PASS $BRANDS brands" && pass=$((pass+1)) || { echo " FAIL $BRANDS brands"; fail=$((fail+1)); }
STOPS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM stops WHERE active=true AND deleted_at IS NULL;")
[ "$STOPS" -ge 200 ] && echo " PASS $STOPS active stops" && pass=$((pass+1)) || { echo " FAIL $STOPS stops"; fail=$((fail+1)); }
# No test brands
TEST_BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands WHERE slug IN ('sunrise-farms','green-valley','orchard-fresh');")
[ "$TEST_BRANDS" = "0" ] && echo " PASS no test brands" && pass=$((pass+1)) || { echo " FAIL test brands found"; fail=$((fail+1)); }
echo "=== Tuxedo Corn data ==="
PHONE=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT phone FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de';")
[ "$PHONE" = "970-323-6874" ] && echo " PASS phone=$PHONE" && pass=$((pass+1)) || { echo " FAIL phone=$PHONE (expected 970-323-6874)"; fail=$((fail+1)); }
LOGOS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de' AND logo_url LIKE '/storage/%';")
[ "$LOGOS" = "1" ] && echo " PASS logo_url is /storage/ path" && pass=$((pass+1)) || { echo " FAIL logo_url not /storage/"; fail=$((fail+1)); }
echo "=== PostgREST ==="
check "GET /" "$API/"
check "GET /brands" "$API/brands?select=id,name,slug&order=name"
check "GET /brands?slug=eq.tuxedo" "$API/brands?slug=eq.tuxedo&select=*"
check "GET /stops" "$API/stops?select=id,city&limit=5"
check "GET /products" "$API/products?select=id,name&limit=5"
check "POST rpc get_brand_settings_by_slug" "$API/rpc/get_brand_settings_by_slug" 200 \
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
check "POST rpc get_brand_plan_info" "$API/rpc/get_brand_plan_info" 200 \
"-X POST -H Content-Type:application/json -d {\"p_brand_id\":\"64294306-5f42-463d-a5e8-2ad6c81a96de\"}"
check "POST rpc get_public_stops_for_brand" "$API/rpc/get_public_stops_for_brand" 200 \
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
check "GET /admin_users with brand join" "$API/admin_users?select=id,user_id,role,brand_id,brands(name)&limit=5"
check "GET /stops with brand join" "$API/stops?select=id,city,brand_id,brands(name)&limit=5"
echo "=== MinIO ==="
check "Storage logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
check "Storage olathe-sweet-logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
check "Storage olathe-sweet-logo-dark.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
echo "=== Public storefronts ==="
check "GET /" "$WEB/"
check "GET /tuxedo" "$WEB/tuxedo"
check "GET /tuxedo/about" "$WEB/tuxedo/about"
check "GET /tuxedo/stops" "$WEB/tuxedo/stops"
check "GET /indian-river-direct" "$WEB/indian-river-direct"
check "GET /login" "$WEB/login"
echo "=== Admin pages (dev_session=platform_admin) ==="
for p in /admin /admin/products /admin/stops /admin/orders /admin/users /admin/settings /admin/settings/billing /admin/settings/apps /admin/settings/payments /admin/communications /admin/communications/compose /admin/time-tracking /admin/wholesale /admin/water-log /admin/analytics /admin/reports; do
check "GET $p" "$WEB$p" 200 "dev_session=platform_admin"
done
echo ""
echo "=== Summary ==="
echo " PASS: $pass"
echo " FAIL: $fail"
if [ $fail -gt 0 ]; then
echo " Failures:"
for f in "${FAILURES[@]}"; do echo " - $f"; done
exit 1
fi
echo " ALL GREEN"
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env python3
"""
seed_tuxedo_tour.py
Parses Tuxedo_Corn_2026_Tour_Schedule-3.xlsx and seeds the `stops` table for
the Tuxedo brand via the admin_create_stops_batch RPC.
Skips:
- Title / subtitle / legend rows (rows 1-3)
- Week header rows (col A = "Wk N", col D-J empty)
- Cross-Dock / Monday OFF rows (col D contains "OFF" or "Cross-Dock")
Joins with the Stop Directory sheet to enrich each stop with:
- address, phone, contact
Usage:
python3 scripts/seed_tuxedo_tour.py --dry-run # show what would be inserted
python3 scripts/seed_tuxedo_tour.py # actually insert
Requires:
- supabase CLI linked to project wnzkhezyhnfzhkhiflrp
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from openpyxl import load_workbook
TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"
YEAR = 2026
MONTH_MAP = {
"Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04",
"May": "05", "Jun": "06", "Jul": "07", "Aug": "08",
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
}
DEFAULT_XLSX = (
"/home/coder/dev/x1/kyle/route_commerce-main/"
"Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
)
def parse_excel_date(s):
"""'Jul 22' -> '2026-07-22'"""
if not s:
return None
m = re.match(r"^([A-Za-z]{3})\s+(\d{1,2})$", str(s).strip())
if not m:
return None
mm = MONTH_MAP.get(m.group(1))
if not mm:
return None
return f"{YEAR}-{mm}-{int(m.group(2)):02d}"
def parse_time_range(s):
"""'10:00 AM - 1:00 PM' -> '10:00 AM' (start time)"""
if not s:
return ""
cleaned = re.sub(r"[–—]", "-", str(s)).strip()
cleaned = re.sub(r"\s+", " ", cleaned)
m = re.match(r"^(\d{1,2}:\d{2}\s*[AP]M)", cleaned, re.IGNORECASE)
return m.group(1).upper().replace(" ", " ") if m else cleaned
def split_city_state(s):
"""'Cheyenne, WY' -> ('Cheyenne', 'WY')"""
if not s:
return "", ""
parts = [p.strip() for p in str(s).split(",")]
if len(parts) == 1:
return parts[0], ""
return parts[0], parts[1]
def slugify(s):
s = (s or "").lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
def is_week_header(row):
a = str(row[0] or "").strip()
d = str(row[3] or "").strip()
return re.match(r"^Wk\s", a) and d == ""
def is_off_row(row):
d = str(row[3] or "").strip()
return "OFF" in d or "Cross-Dock" in d or "CrossDock" in d
def is_data_row(row):
d = str(row[3] or "").strip()
e = str(row[4] or "").strip()
if not d or not e:
return False
if "," not in e:
return False
return True
def load(xlsx_path):
wb = load_workbook(xlsx_path, data_only=True)
schedule = wb["Full Schedule"]
directory = wb["Stop Directory"]
# Build Stop Directory lookup: (truck, host_normalized) -> {address, phone, contact, ...}
dir_map = {}
for row in directory.iter_rows(min_row=2, values_only=True):
truck = str(row[0] or "").strip()
city = str(row[1] or "").strip()
state = str(row[2] or "").strip()
host = str(row[3] or "").strip()
address = str(row[4] or "").strip()
phone = str(row[5] or "").strip()
contact = str(row[6] or "").strip()
if not truck or not host:
continue
key = f"{truck}|{host.lower()}"
dir_map[key] = {
"city": city, "state": state, "host": host,
"address": address, "phone": phone, "contact": contact,
}
# Read Full Schedule (skip first 3 title/subtitle/legend rows)
stops = []
skipped = {"weekHeader": 0, "off": 0, "invalid": 0}
for row in schedule.iter_rows(min_row=4, values_only=True):
# Trim to 10 cols
cells = [("" if v is None else str(v).strip()) for v in row[:10]]
if is_week_header(cells):
skipped["weekHeader"] += 1
continue
if is_off_row(cells):
skipped["off"] += 1
continue
if not is_data_row(cells):
skipped["invalid"] += 1
continue
wk, region, date_text, day, city_state, host, time, truck, status, notes = cells
date_iso = parse_excel_date(date_text)
if not date_iso:
skipped["invalid"] += 1
continue
city, state = split_city_state(city_state)
if not city:
skipped["invalid"] += 1
continue
# Enrich from directory
dir_key = f"{truck}|{host.lower()}"
d = dir_map.get(dir_key)
stops.append({
"week": wk,
"region": region,
"date": date_iso,
"day": day,
"city": city,
"state": state or (d["state"] if d else ""),
"location": host,
"time": parse_time_range(time),
"time_range": time,
"truck": truck,
"status_text": status,
"notes": notes,
"address": d["address"] if d and d["address"] else None,
"phone": d["phone"] if d and d["phone"] else None,
"contact": d["contact"] if d and d["contact"] else None,
})
return stops, skipped, len(dir_map)
def assign_slugs(stops, dry_run):
used = set()
if not dry_run:
out = subprocess.run(
["supabase", "db", "query", "--linked",
f"SELECT slug FROM stops WHERE brand_id = '{TUXEDO_BRAND_ID}';"],
capture_output=True, text=True, timeout=120,
)
# Parse the table output - slugs are in second column between │
for m in re.finditer(r"\s*([a-z0-9][a-z0-9-]*)\s*│", out.stdout):
used.add(m.group(1))
for s in stops:
base = f"{slugify(s['city'])}-{s['date']}"
slug = base
n = 0
while slug in used:
n += 1
slug = f"{base}-{n}"
used.add(slug)
s["slug"] = slug
def to_rpc_row(s):
return {
"city": s["city"],
"state": s["state"],
"location": s["location"],
"date": f"{s['date']} 00:00:00+00",
"time": s["time"],
"address": s["address"],
"zip": None,
"cutoff_time": None,
# active=true so the stops appear on the public storefront immediately.
# Matches the behavior of publishStop in src/actions/stops.ts.
"active": True,
}
def build_payload_json(batch):
"""Build a clean JSON string for use in a SQL file."""
return json.dumps(batch, ensure_ascii=False)
def insert_batch(batch):
"""Write SQL to a temp file and execute via --file to avoid shell escaping."""
payload_json = build_payload_json(batch)
sql = (
f"SELECT admin_create_stops_batch("
f"'{TUXEDO_BRAND_ID}'::uuid, "
f"$${payload_json}$$::jsonb);\n"
)
# Write to temp file
tmp_path = Path("/tmp/seed_tuxedo_tour.sql")
tmp_path.write_text(sql, encoding="utf-8")
try:
proc = subprocess.run(
["supabase", "db", "query", "--linked", "--file", str(tmp_path)],
capture_output=True, text=True, timeout=300,
)
finally:
tmp_path.unlink(missing_ok=True)
if proc.returncode != 0:
raise RuntimeError(f"RPC failed: {proc.stderr[:800]}")
return proc.stdout
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--xlsx", default=DEFAULT_XLSX)
args = ap.parse_args()
if not Path(args.xlsx).exists():
sys.exit(f"XLSX not found: {args.xlsx}")
stops, skipped, dir_count = load(args.xlsx)
assign_slugs(stops, dry_run=args.dry_run)
print(f"\nParsed {len(stops)} stops "
f"(skipped: {skipped['weekHeader']} week-headers, "
f"{skipped['off']} OFF days, {skipped['invalid']} invalid)")
print(f"Stop Directory: {dir_count} entries loaded for enrichment\n")
if not stops:
sys.exit("No stops to insert.")
print("Sample (first 3):")
for s in stops[:3]:
print(f" {s['date']} {s['time']:10s} | {s['city']:18s}, {s['state']:2s} | "
f"{s['location'][:35]:35s} | {s['truck']} | {s['status_text']} | {s['slug']}")
if s["notes"]:
print(f" notes: {s['notes'][:120]}")
if s["address"]:
print(f" addr: {s['address']} ph: {s['phone']} ctc: {s['contact']}")
print()
# Show counts by week and region
by_week = {}
by_region = {}
by_truck = {}
for s in stops:
by_week[s["week"]] = by_week.get(s["week"], 0) + 1
by_region[s["region"]] = by_region.get(s["region"], 0) + 1
by_truck[s["truck"]] = by_truck.get(s["truck"], 0) + 1
print("By week:", dict(sorted(by_week.items())))
print("By region:", by_region)
print("By truck:", by_truck)
print()
# Date range
dates = sorted(s["date"] for s in stops)
print(f"Date range: {dates[0]} to {dates[-1]}\n")
if args.dry_run:
batches = (len(stops) + 49) // 50
print(f"[DRY RUN] Would insert {len(stops)} stops in {batches} batch(es) of 50.")
return
BATCH = 50
total = 0
batches = (len(stops) + BATCH - 1) // BATCH
for i in range(0, len(stops), BATCH):
batch = [to_rpc_row(s) for s in stops[i:i + BATCH]]
bnum = i // BATCH + 1
sys.stdout.write(f" Inserting batch {bnum}/{batches} ({len(batch)} stops)... ")
sys.stdout.flush()
try:
insert_batch(batch)
total += len(batch)
print("OK")
except Exception as e:
print("FAIL")
print(f" {e}")
# The batch RPC hardcodes status='draft' on insert. The Tuxedo storefront
# page only filters on active=true (not status), so active=true is enough
# to make stops visible. But for consistency with the publishStop server
# action — which sets both — flip status to 'active' for the rows we just
# inserted. Slug-based so we only touch stops from this run, not the
# pre-existing "Olathe" test stop.
if total > 0:
slugs = [s["slug"] for s in stops]
# Build a safe IN list (slug is a text column)
slug_list = ", ".join(f"'{slug.replace(chr(39), chr(39)+chr(39))}'" for slug in slugs)
publish_sql = (
f"UPDATE stops SET status = 'active' "
f"WHERE brand_id = '{TUXEDO_BRAND_ID}' "
f"AND slug IN ({slug_list});"
)
tmp = Path("/tmp/seed_tuxedo_publish.sql")
tmp.write_text(publish_sql, encoding="utf-8")
try:
subprocess.run(
["supabase", "db", "query", "--linked", "--file", str(tmp)],
capture_output=True, text=True, timeout=120,
)
print(f"\n Published {total} stops (status -> 'active').")
finally:
tmp.unlink(missing_ok=True)
print(f"\nDone. Inserted {total}/{len(stops)} stops for Tuxedo brand.")
if __name__ == "__main__":
main()
+4 -1
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser";
import { importProductsBatch } from "@/actions/import-products";
import { importOrdersBatch } from "@/actions/import-orders";
@@ -41,7 +42,9 @@ export async function analyzeImport(
): Promise<AnalyzeImportResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
try {
assertBrandAccess(adminUser, brandId);
} catch {
return { success: false, error: "Not authorized for this brand" };
}
+4 -3
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
@@ -96,7 +97,7 @@ async function brandScopedRPC<T>(
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = adminUser.brand_id ?? null;
const brandId = await getActiveBrandId(adminUser);
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
method: "POST",
@@ -232,7 +233,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = adminUser.brand_id ?? null;
const brandId = await getActiveBrandId(adminUser);
const params = new URLSearchParams({
select: "id,customer_name,subtotal,status,created_at,fulfillment",
@@ -293,7 +294,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = adminUser.brand_id ?? null;
const brandId = await getActiveBrandId(adminUser);
const params = new URLSearchParams({
select: "id,status",
@@ -0,0 +1,123 @@
"use server";
import { svcHeaders } from "@/lib/svc-headers";
/**
* Creates a Stripe PaymentIntent for the supplied cart so the browser
* can confirm the payment with embedded Stripe Elements (Apple Pay /
* Google Pay / card) without redirecting to a hosted page.
*
* Mirrors `createRetailStripeCheckoutSession` in `retail-checkout.ts`:
* the PaymentIntent is the embedded equivalent of the Checkout Session.
* Order creation itself still happens on `/checkout/success` so the
* webhooks and order pipeline don't need to know which path the buyer
* used.
*/
type LineItem = {
id: string;
name: string;
price: number;
quantity: number;
};
type CustomerInfo = {
name?: string;
email?: string;
};
type CreatePaymentIntentResult =
| { success: true; clientSecret: string; paymentIntentId: string; amount: number }
| { success: false; error: string };
export async function createRetailPaymentIntent(
items: LineItem[],
customer: CustomerInfo,
brandId: string | null,
stopId: string | null,
shippingAddress?: { state?: string; postal_code?: string; city?: string } | null
): Promise<CreatePaymentIntentResult> {
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) {
return { success: false, error: "Stripe not configured on this server." };
}
if (!Array.isArray(items) || items.length === 0) {
return { success: false, error: "Cart is empty." };
}
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
// Compute the subtotal in cents. We don't compute sales tax here —
// Stripe's `automatic_tax` would be ideal but requires address collection
// client-side; for this single-product corn box the price is tax-inclusive
// (see Tuxedo Corn product card) so we treat the line total as final.
const amount = items.reduce((sum, item) => {
const qty = Math.max(1, Math.floor(item.quantity || 1));
const unit = Math.max(0, Math.round(Number(item.price) * 100));
return sum + unit * qty;
}, 0);
if (amount <= 0) {
return { success: false, error: "Cart total must be greater than $0." };
}
// Pull the brand name for Stripe receipts + metadata
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
let brandName = "Route Commerce";
if (supabaseUrl && supabaseKey && brandId) {
try {
const brandRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
{ headers: { ...svcHeaders(supabaseKey) } }
);
const brands = (await brandRes.json()) as Array<{ name: string }>;
if (brands?.[0]?.name) brandName = brands[0].name;
} catch {
// ignore — use default
}
}
// Build a short human-readable description for the Stripe dashboard
const description = items
.slice(0, 3)
.map((i) => `${i.quantity}× ${i.name}`)
.join(", ");
try {
const intent = await stripe.paymentIntents.create({
amount,
currency: "usd",
automatic_payment_methods: { enabled: true },
receipt_email: customer.email || undefined,
description,
metadata: {
brand_id: brandId ?? "unknown",
brand_name: brandName,
stop_id: stopId ?? "",
customer_name: customer.name ?? "",
customer_email: customer.email ?? "",
shipping_state: shippingAddress?.state ?? "",
shipping_postal_code: shippingAddress?.postal_code ?? "",
shipping_city: shippingAddress?.city ?? "",
item_count: String(items.reduce((s, i) => s + i.quantity, 0)),
source: "storefront_express",
},
});
if (!intent.client_secret) {
return { success: false, error: "Stripe did not return a client secret." };
}
return {
success: true,
clientSecret: intent.client_secret,
paymentIntentId: intent.id,
amount: intent.amount,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create payment intent.";
return { success: false, error: message };
}
}
+65
View File
@@ -0,0 +1,65 @@
import "server-only";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
export type BrandListItem = {
id: string;
name: string;
slug: string;
logo_url: string | null;
};
/**
* Returns the list of brands the current admin user can act in.
*
* - platform_admin: all brands (queried directly from the `brands` table)
* - everyone else: brands in `adminUser.brand_ids`
* - empty array for unauthenticated / no-access admins
*
* This is a plain async function (not a server action) so it can be called
* from server components and server actions without the "use server" wrapper.
* The BrandSelector client component receives the result as a prop.
*/
export async function listBrandsForAdmin(): Promise<BrandListItem[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) return [];
if (adminUser.role === "platform_admin") {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
{ headers: svcHeaders(serviceKey), cache: "no-store" }
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
if (adminUser.brand_ids.length === 0) return [];
// Use PostgREST `in` filter. The brand_ids are UUIDs, so the quoting
// pattern in the spec is safe; the inner quotes are required by PostgREST
// for UUID literals.
const filter = `id=in.(${adminUser.brand_ids
.map((id) => `"${id}"`)
.join(",")})`;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
{ headers: svcHeaders(serviceKey), cache: "no-store" }
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
+8 -3
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type CampaignType = "marketing" | "operational" | "transactional";
@@ -57,7 +58,11 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
@@ -147,7 +152,7 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: adminUser.brand_id ?? null }),
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
}
);
@@ -167,7 +172,7 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: brandId ?? adminUser.brand_id ?? null }),
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser, brandId)) ?? null }),
}
);
+8 -3
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
import type { AudienceRules } from "./campaigns";
@@ -120,11 +121,15 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// Resolve brand from campaign or parameter
const effectiveBrandId = brandId ?? adminUser.brand_id;
// Resolve brand from campaign or parameter (URL > cookie > legacy > first of brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
// Brand scoping: brand_admin can only send their own brand's campaigns
if (adminUser.role === "brand_admin" && effectiveBrandId && adminUser.brand_id !== effectiveBrandId) {
if (adminUser.role === "brand_admin" && effectiveBrandId && !adminUser.brand_ids.includes(effectiveBrandId)) {
return { success: false, error: "Not authorized to send this brand's campaigns" };
}
+7 -2
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
import type { AudienceRules } from "./campaigns";
@@ -33,7 +34,11 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
// Brand scoping: brand_admin can only see their own brand's templates
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
@@ -112,7 +117,7 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? null }),
body: JSON.stringify({ p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
}
);
+3 -2
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
@@ -66,7 +67,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = adminUser.brand_id ?? null;
const brandId = await getActiveBrandId(adminUser);
// Get today's date range
const today = new Date();
@@ -202,7 +203,7 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = adminUser.brand_id ?? null;
const brandId = await getActiveBrandId(adminUser);
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
+304
View File
@@ -0,0 +1,304 @@
"use server";
import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type LocationInput = {
name: string;
address?: string | null;
city?: string | null;
state?: string | null;
zip?: string | null;
phone?: string | null;
contact_name?: string | null;
contact_email?: string | null;
notes?: string | null;
active?: boolean;
};
export type Location = {
id: string;
brand_id: string;
name: string;
address: string | null;
city: string | null;
state: string | null;
zip: string | null;
phone: string | null;
contact_name: string | null;
contact_email: string | null;
notes: string | null;
active: boolean;
deleted_at: string | null;
created_at: string;
updated_at: string;
slug: string | null;
};
export type LocationWithCount = Location & { stop_count: number };
// ── Create (single) ──────────────────────────────────────────────────────────
export async function createLocation(
brandId: string,
input: LocationInput
): Promise<{ success: true; id: string; slug: string } | { success: false; error: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, error: "Not authorized to manage locations" };
}
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) return { success: false, error: "No brand selected" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/admin_create_location`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: effectiveBrandId,
p_name: input.name,
p_address: input.address ?? null,
p_city: input.city ?? null,
p_state: input.state ?? null,
p_zip: input.zip ?? null,
p_phone: input.phone ?? null,
p_contact_name: input.contact_name ?? null,
p_contact_email: input.contact_email ?? null,
p_notes: input.notes ?? null,
p_active: input.active ?? true,
}),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Insert failed" };
}
const data = await res.json();
revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
return { success: true, id: data.id, slug: data.slug };
}
// ── Create (batch) ───────────────────────────────────────────────────────────
export async function createLocationsBatch(
brandId: string,
locations: LocationInput[]
): Promise<{ success: boolean; created: number; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, created: 0, error: "Not authorized" };
}
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, created: 0, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/admin_create_locations_batch`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
}
const inserted = await res.json();
revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
return {
success: true,
created: Array.isArray(inserted) ? inserted.length : locations.length,
};
}
// ── Update (partial) ─────────────────────────────────────────────────────────
export async function updateLocation(
locationId: string,
brandId: string,
updates: Partial<LocationInput>
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/admin_update_location`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Update failed" };
}
const data = await res.json();
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:locations`, "default");
return { success: true };
}
// ── Delete (soft) ────────────────────────────────────────────────────────────
export async function deleteLocation(
locationId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/admin_delete_location`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Delete failed" };
}
const data = await res.json();
if (!data.success) return { success: false, error: data.error ?? "Delete failed" };
revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:locations`, "default");
return { success: true };
}
// ── Read (admin, by brand_id) ────────────────────────────────────────────────
export async function adminListLocations(
brandId: string
): Promise<LocationWithCount[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Application-layer brand scoping: platform_admin (brand_id: null) gets all
// brands; everyone else gets only their own brand.
const activeBrandId = await getActiveBrandId(adminUser, brandId);
const effectiveBrandId = activeBrandId;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/admin_list_locations`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] },
}
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? (data as LocationWithCount[]) : [];
}
// ── Read (public, by brand slug) ─────────────────────────────────────────────
export type PublicLocation = Pick<
Location,
"id" | "name" | "address" | "city" | "state" | "zip" | "phone" | "slug"
> & { stop_count: number };
export async function getPublicLocationsForBrand(
brandSlug: string
): Promise<PublicLocation[]> {
if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_locations_for_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: { revalidate: 300, tags: ["locations", `brand:${brandSlug}:locations`] },
}
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? (data as PublicLocation[]) : [];
}
// ── Attach a stop to a location ──────────────────────────────────────────────
export async function attachStopToLocation(
stopId: string,
locationId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_stop_id: stopId,
p_location_id: locationId,
p_brand_id: brandId,
}),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Attach failed" };
}
const data = await res.json();
if (!data.success) return { success: false, error: data.error ?? "Attach failed" };
revalidateTag("stops", "default");
revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:stops`, "default");
revalidateTag(`brand:${brandId}:locations`, "default");
return { success: true };
}
+6 -1
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
import { randomUUID } from "crypto";
@@ -41,7 +42,11 @@ export async function createAdminOrder(
}
// Brand scoping
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
if (adminUser.role === "brand_admin" && adminUser.brand_id && effectiveBrandId !== adminUser.brand_id) {
return { success: false, error: "Not authorized for this brand" };
}
+4 -4
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type PaymentProvider = "stripe" | "square" | "manual";
@@ -65,10 +66,9 @@ export async function savePaymentSettings(params: {
return { success: false, error: "Not authorized" };
}
if (
adminUser.role === "brand_admin" &&
adminUser.brand_id !== params.brandId
) {
try {
assertBrandAccess(adminUser, params.brandId);
} catch {
return { success: false, error: "Not authorized for this brand" };
}
+6 -1
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export async function deleteProduct(
@@ -16,7 +17,11 @@ export async function deleteProduct(
return { success: false, error: "Not authorized to manage products" };
}
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
+65 -8
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
const SUPABASE_URL = process.env.NEXT_PUBLIC_API_URL!;
@@ -153,7 +154,14 @@ export interface FieldYieldSummary {
export async function getRouteTraceLots(brandId: string, status?: string) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -235,7 +243,14 @@ export async function createHarvestLot(
) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -298,7 +313,14 @@ export async function updateHarvestLotStatus(
export async function getRouteTraceStats(brandId: string) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -318,7 +340,14 @@ export async function getRouteTraceStats(brandId: string) {
export async function searchHarvestLots(brandId: string, query: string) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -347,7 +376,14 @@ export async function getTraceChain(lotId: string) {
export async function getHarvestLotsReadyToHaul(brandId: string) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -364,7 +400,14 @@ export async function getHarvestLotsReadyToHaul(brandId: string) {
export async function getFieldYieldSummary(brandId: string) {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -405,7 +448,14 @@ export interface InventoryByCrop {
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
@@ -451,7 +501,14 @@ export async function getRecentLotEvents(
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
const effectiveBrandId = brandId ?? adminUser.brand_id ?? undefined;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import {
setActiveBrandCookie,
clearActiveBrandCookie,
} from "@/lib/brand-scope";
/**
* Set the persistent "active brand" for the current admin user.
*
* - `brandId === null`: "All brands" — only allowed for platform_admin.
* Clears the cookie (cookie absence = no specific brand pinned).
* - `brandId` string: sets the cookie, after validating the admin has access.
*
* The active brand is the default the UI uses for pages that don't receive
* an explicit `brandId` from the URL. The cookie is the source of truth —
* the URL is only for deep-linking.
*/
export async function setActiveBrand(
brandId: string | null
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// null = "All brands" (platform_admin only)
if (brandId === null) {
if (adminUser.role !== "platform_admin") {
return {
success: false,
error: "Only platform admins can select 'All brands'",
};
}
await clearActiveBrandCookie();
return { success: true };
}
if (
adminUser.role !== "platform_admin" &&
!adminUser.brand_ids.includes(brandId)
) {
return { success: false, error: "No access to that brand" };
}
await setActiveBrandCookie(brandId);
return { success: true };
}
+3 -2
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type UpdateShippingStatusResult =
@@ -28,7 +29,7 @@ export async function updateShippingStatus(
p_order_id: orderId,
p_shipping_status: status,
p_tracking_number: trackingNumber ?? null,
p_brand_id: adminUser.brand_id ?? null,
p_brand_id: await getActiveBrandId(adminUser),
}),
}
);
@@ -79,7 +80,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: adminUser.brand_id ?? null,
p_brand_id: await getActiveBrandId(adminUser),
}),
}
);
+5
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
import type { FedExServiceType } from "./fedex-rates";
@@ -156,6 +157,10 @@ export async function createFedExShipment(
const order = orders[0];
if (!order) return { success: false, error: "Order not found" };
// The order's brand is the source of truth. Validate the admin has access.
if (order.brand_id) {
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
// Get FedEx settings
+5
View File
@@ -11,6 +11,7 @@
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
// ── Types ────────────────────────────────────────────────────────────────────
@@ -177,6 +178,10 @@ export async function getFedExRates(
const order = orders[0];
if (!order) return { success: false, error: "Order not found" };
// The order's brand is the source of truth. Validate the admin has access.
if (order.brand_id) {
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
// Fetch shipping settings for this brand
+7 -2
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type SyncLogEntry = {
@@ -29,7 +30,9 @@ export async function syncSquareNow(
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
if (!adminUser.can_manage_orders) return { success: false, synced: 0, errors: ["Not authorized"] };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
try {
assertBrandAccess(adminUser, brandId);
} catch {
return { success: false, synced: 0, errors: ["Not authorized"] };
}
@@ -61,7 +64,9 @@ export async function getSyncLog(brandId: string): Promise<{
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, logs: [] };
if (!adminUser.can_manage_orders) return { success: false, logs: [] };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
try {
assertBrandAccess(adminUser, brandId);
} catch {
return { success: false, logs: [] };
}
+6 -1
View File
@@ -2,6 +2,7 @@
import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type StopImportRow = {
@@ -25,7 +26,11 @@ export async function createStopsBatch(
return { success: false, created: 0, error: "Not authorized to manage stops" };
}
const effectiveBrandId = brandId || adminUser.brand_id;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, created: 0, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) {
return { success: false, created: 0, error: "No brand selected" };
}
+122
View File
@@ -0,0 +1,122 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { createClient } from "@supabase/supabase-js";
export type StopDetail = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
slug: string;
active: boolean;
brand_id: string;
address: string | null;
zip: string | null;
cutoff_time: string | null;
brands: { name: string; slug: string } | { name: string; slug: string }[] | null;
};
export type AssignedProduct = {
id: string;
product_id: string;
products: { name: string; type: string; price: number } | null;
};
export type StopDetailsResult =
| {
success: true;
stop: StopDetail;
allProducts: { id: string; name: string; type: string; price: number }[];
assignedProducts: AssignedProduct[];
brands: { id: string; name: string; slug: string }[];
/** admin_users.user_id of the caller, forwarded to RPCs that authorise via user_id lookup */
callerUid: string;
}
| { success: false; error: string };
/**
* Fetch a single stop with its brand, all candidate products, currently
* assigned products, and the list of brands (for the brand switcher in the
* edit form). Mirrors the data the old `/admin/stops/[id]` page server
* component loaded, so the modal can be a drop-in replacement.
*/
export async function getStopDetails(stopId: string): Promise<StopDetailsResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
// Use a fresh server-side client (cookie-less) so RLS doesn't block reads
// for platform_admin dev sessions. The auth check above has already gated
// access.
const server = useMockData
? null
: createClient(supabaseUrl, supabaseKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
// 1. Stop + brand
let stop: StopDetail | null = null;
let stopErr: string | null = null;
if (server) {
const { data, error } = await server
.from("stops")
.select("*, brands(name, slug)")
.eq("id", stopId)
.single();
if (error) stopErr = error.message;
else stop = (data ?? null) as StopDetail | null;
} else {
// Mock fallback — empty
stopErr = "Stop not found";
}
if (!stop) {
return { success: false, error: stopErr ?? "Stop not found" };
}
// Brand-scope check for brand_admin
if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) {
return { success: false, error: "Not authorized for this brand" };
}
// 2. Candidate products for this brand
const { data: allProducts } = server
? await server
.from("products")
.select("id, name, type, price")
.eq("brand_id", stop.brand_id)
.eq("active", true)
: { data: [] as { id: string; name: string; type: string; price: number }[] };
// 3. Assigned products (joined with product info)
const { data: productStops } = server
? await server
.from("product_stops")
.select("id, product_id, products(id, name, type, price)")
.eq("stop_id", stopId)
: { data: [] as AssignedProduct[] };
// 4. Brands for the brand switcher
const { data: brands } = server
? await server.from("brands").select("id, name, slug")
: { data: [] as { id: string; name: string; slug: string }[] };
return {
success: true,
stop,
allProducts: allProducts ?? [],
assignedProducts: (productStops ?? []) as AssignedProduct[],
brands: brands ?? [],
callerUid: adminUser.user_id,
};
}
+9 -8
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
type Irrigator = {
@@ -94,7 +95,7 @@ export async function updateWaterHeadgate(
p_name: name,
p_active: active,
p_unit: unit ?? null,
p_brand_id: adminUser.brand_id ?? null,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
p_high_threshold: highThreshold ?? null,
p_low_threshold: lowThreshold ?? null,
}),
@@ -186,7 +187,7 @@ export async function updateWaterIrrigator(
p_active: active,
p_lang: lang,
p_role: role,
p_brand_id: adminUser.brand_id ?? null,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}),
}
);
@@ -215,7 +216,7 @@ export async function resetWaterIrrigatorPin(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_user_id: irrigatorId,
p_brand_id: adminUser.brand_id ?? null,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}),
}
);
@@ -242,7 +243,7 @@ export async function deleteWaterUser(userId: string): Promise<{ success: boolea
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_user_id: userId,
p_brand_id: adminUser.brand_id ?? null,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}),
}
);
@@ -269,7 +270,7 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_headgate_id: headgateId,
p_brand_id: adminUser.brand_id ?? null,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}),
}
);
@@ -356,7 +357,7 @@ export async function regenerateHeadgateToken(headgateId: string): Promise<{ suc
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: adminUser.brand_id ?? null }),
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
}
);
@@ -392,7 +393,7 @@ export async function updateWaterEntry(
p_measurement: measurement,
p_notes: notes,
p_unit: unit ?? null,
p_brand_id: adminUser.brand_id ?? null,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}),
}
);
@@ -421,7 +422,7 @@ export async function deleteWaterEntry(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_entry_id: entryId,
p_brand_id: adminUser.brand_id ?? null,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}),
}
);
+4 -1
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export async function registerWholesaleCustomer(params: {
@@ -81,7 +82,9 @@ export async function approveWholesaleRegistration(
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
if (adminUser.role !== "platform_admin" && adminUser.brand_id !== brandId) {
try {
assertBrandAccess(adminUser, brandId);
} catch {
return { success: false, error: "Not authorized to operate on this brand" };
}
+33 -27
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type WholesaleOrder = {
@@ -129,15 +130,16 @@ export type WholesaleDashboardStats = {
* platform_admin → null (means "all brands" — passes to RPC unchanged)
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
* store_employee → their own brand_id
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
* unauthenticated → null (actions should already bail out earlier)
*
* This prevents brand_admin from seeing or modifying another brand's data
* even if they manually pass a different brandId to the action.
*/
function resolveBrandId(
async function resolveBrandId(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): string | null {
): Promise<string | null> {
if (!adminUser) return null;
if (adminUser.role === "platform_admin") {
@@ -145,15 +147,15 @@ function resolveBrandId(
return null;
}
// brand_admin and store_employee are scoped to their own brand
const userBrand = adminUser.brand_id ?? null;
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && requestedBrandId !== userBrand) {
if (requestedBrandId && activeBrandId !== requestedBrandId) {
// Brand admin trying to operate on another brand's data — block it
return null; // caller should check and return unauthorized
}
return userBrand;
return activeBrandId;
}
/**
@@ -161,23 +163,24 @@ function resolveBrandId(
* if a brand_admin tries to operate outside their brand.
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
*/
function enforceBrandScope(
async function enforceBrandScope(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): { brandId: string | null; error?: string } {
): Promise<{ brandId: string | null; error?: string }> {
if (!adminUser) return { brandId: null, error: "Not authenticated" };
if (adminUser.role === "platform_admin") {
return { brandId: null }; // unrestricted
}
const userBrand = adminUser.brand_id ?? null;
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && requestedBrandId !== userBrand) {
if (requestedBrandId && activeBrandId !== requestedBrandId) {
return { brandId: null, error: "Not authorized to operate on this brand" };
}
return { brandId: userBrand };
return { brandId: activeBrandId };
}
// ── Orders ──────────────────────────────────────────────────────────────────
@@ -185,7 +188,7 @@ function enforceBrandScope(
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = resolveBrandId(adminUser, brandId);
const bid = await resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
@@ -209,7 +212,7 @@ export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrd
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = resolveBrandId(adminUser, brandId);
const bid = await resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
@@ -256,7 +259,7 @@ export async function markWholesaleOrderFulfilled(orderId: string, brandId?: str
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { brandId: resolved, error } = enforceBrandScope(adminUser, brandId);
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
@@ -305,7 +308,7 @@ export async function updateWholesaleOrderStatus(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = enforceBrandScope(adminUser, brandId);
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
@@ -332,7 +335,7 @@ export async function deleteWholesaleOrder(orderId: string, brandId?: string): P
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = enforceBrandScope(adminUser, brandId);
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
@@ -359,7 +362,7 @@ export async function deleteWholesaleCustomer(customerId: string, brandId?: stri
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = enforceBrandScope(adminUser, brandId);
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
@@ -388,7 +391,7 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = enforceBrandScope(adminUser, brandId);
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
@@ -417,7 +420,7 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = resolveBrandId(adminUser, brandId);
const bid = await resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
@@ -458,7 +461,7 @@ export async function saveWholesaleCustomer(params: {
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = enforceBrandScope(adminUser, params.brandId);
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
@@ -501,7 +504,7 @@ export async function saveWholesaleCustomer(params: {
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = resolveBrandId(adminUser, brandId);
const bid = await resolveBrandId(adminUser, brandId);
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
@@ -545,7 +548,7 @@ export async function saveWholesaleProduct(params: {
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = enforceBrandScope(adminUser, params.brandId);
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
@@ -591,7 +594,10 @@ export async function saveWholesaleProduct(params: {
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
const bid = brandId ?? adminUser.brand_id ?? null;
const bid = await getActiveBrandId(adminUser, brandId);
if (!bid && adminUser.role !== "platform_admin") {
return null;
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
@@ -700,7 +706,7 @@ export async function recordWholesaleDeposit(
p_method: method,
p_reference: reference ?? null,
p_recorded_by: adminUser.user_id,
p_brand_id: adminUser.brand_id ?? null,
p_brand_id: await getActiveBrandId(adminUser),
}),
}
);
@@ -710,7 +716,7 @@ export async function recordWholesaleDeposit(
if (!data?.success) return { success: false, error: data?.error ?? "Failed to record deposit" };
// Fire webhook — fire-and-forget
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, adminUser.brand_id ?? undefined).catch(() => {});
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, (await getActiveBrandId(adminUser)) ?? undefined).catch(() => {});
return { success: true };
}
@@ -753,7 +759,7 @@ export async function bulkFulfillWholesaleOrders(
body: JSON.stringify({
p_order_ids: orderIds,
p_by: adminUser.user_id,
p_brand_id: adminUser.brand_id ?? null,
p_brand_id: await getActiveBrandId(adminUser),
}),
}
);
@@ -791,7 +797,7 @@ export async function bulkRecordWholesaleDeposit(
p_method: method,
p_reference: reference ?? null,
p_recorded_by: adminUser.user_id,
p_brand_id: adminUser.brand_id ?? null,
p_brand_id: await getActiveBrandId(adminUser),
}),
}
);
+17 -2
View File
@@ -1,6 +1,8 @@
import AdminSidebar from "@/components/admin/AdminSidebar";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { listBrandsForAdmin } from "@/actions/brands";
import { redirect } from "next/navigation";
import "@/styles/admin-design-system.css";
import { ToastProvider } from "@/components/admin/Toast";
@@ -57,12 +59,25 @@ export default async function AdminLayout({ children }: { children: React.ReactN
redirect("/change-password");
}
// Resolve the active brand (URL > cookie > legacy > first of brand_ids)
const activeBrandId = await getActiveBrandId(adminUser);
// Fetch accessible brands for the sidebar BrandSelector. We do this
// unconditionally — `listBrandsForAdmin` is cheap and the sidebar
// decides whether to show the dropdown.
const brands = await listBrandsForAdmin();
return (
<ToastProviderWrapper>
<AdminSidebar userRole={adminUser.role} />
<AdminSidebar
userRole={adminUser.role}
brandIds={adminUser.brand_ids}
activeBrandId={activeBrandId}
brands={brands}
/>
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
{children}
</div>
</ToastProviderWrapper>
);
}
}
+13 -6
View File
@@ -1,5 +1,6 @@
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { getAdminOrders } from "@/actions/orders";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
@@ -17,13 +18,19 @@ export default async function AdminOrdersPage() {
redirect("/admin/pickup");
}
const activeBrandId = await getActiveBrandId(adminUser);
// Platform admin can browse all brands' orders; everyone else must have a brand
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied message="You don't have access to any brand." />;
}
const { orders, stops } = await getAdminOrders();
const brandStops = adminUser?.brand_id
? stops.filter((s) => s.brand_id === adminUser.brand_id)
const brandStops = activeBrandId
? stops.filter((s) => s.brand_id === activeBrandId)
: stops;
const brandOrders = adminUser?.brand_id
const brandOrders = activeBrandId
? orders.filter(
(o) =>
o.stops && brandStops.some((s) => s.id === o.stop_id)
@@ -41,8 +48,8 @@ export default async function AdminOrdersPage() {
.order("name")
.limit(200);
if (adminUser?.brand_id) {
prodQuery = prodQuery.eq("brand_id", adminUser.brand_id);
if (activeBrandId) {
prodQuery = prodQuery.eq("brand_id", activeBrandId);
}
const { data: prods } = await prodQuery;
@@ -80,7 +87,7 @@ export default async function AdminOrdersPage() {
initialOrders={brandOrders}
initialStops={brandStops}
initialProducts={brandProducts}
brandId={adminUser?.brand_id ?? null}
brandId={activeBrandId}
/>
</div>
</div>
+6 -6
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { api } from "@/lib/api";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { getBillingOverview } from "@/actions/billing/billing-overview";
import DashboardClient from "@/components/admin/DashboardClient";
@@ -23,12 +24,11 @@ export default async function AdminPage() {
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
let brandDisplayName = "Admin";
// For platform_admin in dev mode, adminUser.brand_id is null. To keep
// the dashboard's "Active Products" stat in sync with the billing page,
// we need to pick a brand and use the same getBillingOverview action
// the billing page does. Otherwise the dashboard falls back to default
// (0/0/0) usage values and contradicts the billing page's "Products 1/25".
let dashboardBrandId: string | null = adminUser?.brand_id ?? null;
// Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
// > first of brand_ids). For platform_admin in dev mode this is null, so we
// fall back to the first brand in the brands table to keep the dashboard's
// "Active Products" stat in sync with the billing page.
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
const { data: firstBrand } = await api
.from("brands")
+20 -4
View File
@@ -1,7 +1,9 @@
import { api } from "@/lib/api";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import ProductsClient from "@/components/admin/ProductsClient";
import { getBrands } from "@/actions/admin/users";
// Icon for page header
const PackageIcon = () => (
@@ -29,7 +31,16 @@ export default async function AdminProductsPage() {
);
}
const brandId = adminUser.brand_id;
const activeBrandId = await getActiveBrandId(adminUser);
const brandId = activeBrandId;
const isPlatformAdmin = adminUser.role === "platform_admin";
// Platform admins need a brand picker for new products
let brands: { id: string; name: string }[] = [];
if (isPlatformAdmin) {
const result = await getBrands();
brands = result.brands ?? [];
}
let query = api
.from("products")
@@ -48,8 +59,8 @@ export default async function AdminProductsPage() {
.is("deleted_at", null)
.order("name");
if (adminUser.brand_id) {
query = query.eq("brand_id", adminUser.brand_id);
if (brandId) {
query = query.eq("brand_id", brandId);
}
const { data: products, error } = (await query) as any;
@@ -69,7 +80,12 @@ export default async function AdminProductsPage() {
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<ProductsClient products={products ?? []} brandId={brandId ?? undefined} />
<ProductsClient
products={products ?? []}
brandId={brandId ?? undefined}
brands={brands}
isPlatformAdmin={isPlatformAdmin}
/>
</div>
);
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { api } from "@/lib/api";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import ReportsDashboard from "@/components/admin/ReportsDashboard";
import { PageHeader } from "@/components/admin/design-system";
@@ -19,7 +20,7 @@ export default async function ReportsPage() {
const initialBrandId = isPlatformAdmin
? null
: adminUser.brand_id ?? null;
: await getActiveBrandId(adminUser);
return (
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
+6 -1
View File
@@ -1,5 +1,6 @@
import { api } from "@/lib/api";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { getBillingOverview } from "@/actions/billing/billing-overview";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import BillingClientPage from "./BillingClientPage";
@@ -13,7 +14,11 @@ export default async function BillingPage({ params }: Props) {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied message="You don't have access to that brand." />;
}
const effectiveBrandId = activeBrandId ?? "";
const isPlatformAdmin = adminUser.role === "platform_admin";
let resolvedBrandId = effectiveBrandId;
+2 -4
View File
@@ -1,4 +1,4 @@
import StopTableClient from "@/components/admin/StopTableClient";
import StopsDashboardClient from "@/components/admin/stops/StopsDashboardClient";
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
import { api } from "@/lib/api";
import { getAdminUser } from "@/lib/admin-permissions";
@@ -90,9 +90,7 @@ export default async function AdminStopsPage() {
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<div className="overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
<StopTableClient stops={stops ?? []} />
</div>
<StopsDashboardClient stops={stops ?? []} />
</div>
</main>
);
+4 -2
View File
@@ -1,5 +1,7 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import WholesaleClient from "./WholesaleClient";
export default async function WholesalePage() {
@@ -11,10 +13,10 @@ export default async function WholesalePage() {
devSession === "store_employee";
if (!isDevMode) {
const { getAdminUser } = await import("@/lib/admin-permissions");
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
return <WholesaleClient brandId={adminUser.brand_id ?? ""} />;
const activeBrandId = await getActiveBrandId(adminUser);
return <WholesaleClient brandId={activeBrandId ?? ""} />;
}
// Dev mode: platform_admin sees all brands, use first brand as default
+18 -7
View File
@@ -47,12 +47,16 @@ function formatStopDate(dateStr: string): string {
function SuccessContent() {
const searchParams = useSearchParams();
const sessionId = searchParams.get("session_id");
// Embedded Stripe Elements path — we navigated here programmatically
// with ?payment_intent=pi_... after `stripe.confirmPayment` resolved.
const paymentIntentId = searchParams.get("payment_intent");
const redirectStatus = searchParams.get("redirect_status");
const orderIdParam = searchParams.get("order_id");
// Direct access with order_id — load from sessionStorage in lazy initializer.
// searchParams values are stable, and sessionStorage is client-only, so this
// is safe in a client component.
const [order, setOrder] = useState<StoredOrder | null>(() => {
if (!orderIdParam || sessionId) return null;
if (!orderIdParam || sessionId || paymentIntentId) return null;
if (typeof window === "undefined") return null;
try {
const stored = sessionStorage.getItem(`order_${orderIdParam}`);
@@ -62,14 +66,20 @@ function SuccessContent() {
return null;
}
});
const [creating, setCreating] = useState(!!sessionId);
const [creating, setCreating] = useState(Boolean(sessionId || (paymentIntentId && redirectStatus === "succeeded")));
const [error, setError] = useState<string | null>(null);
// Stripe redirected back — create order from pending checkout data.
// Wrapped in an async IIFE so all setState calls happen inside a callback,
// not in the synchronous effect body (satisfies set-state-in-effect rule).
// Create the order from the pending-checkout payload, regardless of
// whether the user paid via hosted Stripe Checkout (?session_id=...) or
// embedded Stripe Elements (?payment_intent=...). Both paths store the
// same payload in `pending_checkout` before payment is initiated.
useEffect(() => {
if (!sessionId) return;
if (!sessionId && !paymentIntentId) return;
if (paymentIntentId && redirectStatus && redirectStatus !== "succeeded") {
setError("Payment was not completed. Please try again.");
setCreating(false);
return;
}
(async () => {
const pendingStr = sessionStorage.getItem("pending_checkout");
@@ -88,6 +98,7 @@ function SuccessContent() {
cartBrandId?: string;
shippingAddress?: { state: string; postal_code: string; city?: string };
idempotencyKey: string;
paymentIntentId?: string;
};
try {
pending = JSON.parse(pendingStr);
@@ -123,7 +134,7 @@ function SuccessContent() {
setCreating(false);
}
})();
}, [sessionId]);
}, [sessionId, paymentIntentId, redirectStatus]);
if (!order || !orderIdParam) {
return (
+590 -2
View File
@@ -1,6 +1,11 @@
@import "tailwindcss";
@theme {
/* Custom typefaces — Field Almanac */
--font-display: var(--font-fraunces, "Georgia"), Georgia, serif;
--font-sans: var(--font-manrope, system-ui), system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: var(--font-fragment-mono, "JetBrains Mono"), ui-monospace, SFMono-Regular, Menlo, monospace;
/* Apple-inspired dark palette — sophisticated depth */
--color-surface-50: #f5f5f7;
--color-surface-100: #e8e8ed;
@@ -85,7 +90,7 @@ html {
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-family: var(--font-sans);
background: #ffffff;
background-attachment: fixed;
color: #1a1a1a;
@@ -441,4 +446,587 @@ select:-webkit-autofill:focus {
.transition-glass {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
}
/* ─── Editorial Modal — "Atelier des Récoltes" ───────────────── */
/* Warm cream canvas for the modal — like aged paper */
.atelier-canvas {
background-color: #FAF7F0;
background-image:
radial-gradient(ellipse 70% 50% at 15% 0%, rgba(180, 155, 100, 0.10) 0%, transparent 55%),
radial-gradient(ellipse 60% 45% at 100% 100%, rgba(34, 78, 47, 0.06) 0%, transparent 55%);
position: relative;
}
/* Subtle grain texture for tactile feel */
.atelier-grain {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.35;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.18 0 0 0 0 0.15 0 0 0 0 0.10 0 0 0 0.045 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Botanical wreath / corner ornament */
.atelier-flourish {
background-image: url("data:image/svg+xml,%3Csvg width='80' height='80' viewBox='0 0 80 80' xmlns='http://www.w3.org/2000/svg' fill='none' stroke='%23224E2F' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 40 Q 25 20 40 30 Q 55 40 70 30'/%3E%3Cpath d='M22 32 Q 25 28 28 30'/%3E%3Cpath d='M52 38 Q 55 34 58 36'/%3E%3Cpath d='M14 45 Q 18 50 24 48'/%3E%3Ccircle cx='40' cy='30' r='2' fill='%23224E2F'/%3E%3C/svg%3E");
background-repeat: no-repeat;
}
/* The big editorial numeral — large, italic, gold */
.atelier-numeral {
font-family: var(--font-fraunces);
font-style: italic;
font-weight: 500;
font-variation-settings: "opsz" 144, "SOFT" 30;
line-height: 0.85;
letter-spacing: -0.04em;
background: linear-gradient(135deg, #CA8A04 0%, #854D0E 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Editorial title — Fraunces with optical sizing */
.atelier-title {
font-family: var(--font-fraunces);
font-weight: 500;
font-variation-settings: "opsz" 144, "SOFT" 50;
letter-spacing: -0.025em;
line-height: 0.95;
color: #1C1917;
}
.atelier-italic {
font-family: var(--font-fraunces);
font-style: italic;
font-variation-settings: "opsz" 14, "SOFT" 100;
color: #786B53;
}
/* Section label — monospace, small caps style */
.atelier-section-label {
font-family: var(--font-fragment-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #786B53;
}
/* Section number badge */
.atelier-section-num {
font-family: var(--font-fragment-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.05em;
color: #CA8A04;
display: inline-flex;
align-items: center;
gap: 6px;
}
.atelier-section-num::before {
content: '';
display: inline-block;
width: 18px;
height: 1px;
background: linear-gradient(to right, #CA8A04, transparent);
}
/* Editorial hairline rule with fade at edges */
.atelier-rule {
height: 1px;
background: linear-gradient(to right, transparent, rgba(180, 155, 100, 0.4) 20%, rgba(34, 78, 47, 0.3) 50%, rgba(180, 155, 100, 0.4) 80%, transparent);
border: 0;
}
/* Input fields — bottom border only, editorial */
.atelier-input {
font-family: var(--font-manrope);
font-size: 15px;
font-weight: 400;
color: #1C1917;
background: transparent;
border: 0;
border-bottom: 1.5px solid rgba(28, 25, 23, 0.12);
border-radius: 0;
padding: 10px 0 10px 0;
width: 100%;
outline: none;
transition: border-color 220ms cubic-bezier(0.4, 0, 0.2, 1), padding 220ms cubic-bezier(0.4, 0, 0.2, 1);
}
.atelier-input::placeholder {
color: #A8A29E;
font-style: italic;
font-family: var(--font-fraunces);
font-variation-settings: "opsz" 14, "SOFT" 100;
}
.atelier-input:hover {
border-bottom-color: rgba(28, 25, 23, 0.28);
}
.atelier-input:focus {
border-bottom-color: #224E2F;
border-bottom-width: 2px;
padding-bottom: 9.5px;
}
.atelier-input.is-error {
border-bottom-color: #B91C1C;
}
/* Large display input — name field */
.atelier-input--display {
font-family: var(--font-fraunces);
font-size: 28px;
font-weight: 500;
font-variation-settings: "opsz" 144, "SOFT" 50;
letter-spacing: -0.02em;
padding: 8px 0 14px 0;
}
.atelier-input--display::placeholder {
color: #D6D3D1;
font-style: italic;
font-variation-settings: "opsz" 144, "SOFT" 100;
}
.atelier-input--display:focus {
border-bottom-width: 2.5px;
padding-bottom: 13px;
}
/* Price input — large currency treatment */
.atelier-price {
font-family: var(--font-fraunces);
font-variation-settings: "opsz" 144, "SOFT" 50;
font-weight: 500;
font-size: 38px;
letter-spacing: -0.03em;
color: #1C1917;
background: transparent;
border: 0;
border-bottom: 2px solid #224E2F;
border-radius: 0;
padding: 4px 0 12px 36px;
width: 100%;
outline: none;
transition: border-color 200ms ease;
-moz-appearance: textfield;
}
.atelier-price::-webkit-outer-spin-button,
.atelier-price::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.atelier-price:focus {
border-bottom-color: #14532D;
border-bottom-width: 2.5px;
}
.atelier-price-sigil {
font-family: var(--font-fraunces);
font-style: italic;
font-weight: 400;
font-size: 38px;
color: #CA8A04;
position: absolute;
left: 0;
top: 4px;
pointer-events: none;
line-height: 1;
}
/* Type selector — visual cards */
.atelier-type-card {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 14px 12px;
background: rgba(255, 255, 255, 0.6);
border: 1.5px solid rgba(28, 25, 23, 0.10);
border-radius: 10px;
cursor: pointer;
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
text-align: left;
width: 100%;
min-height: 92px;
overflow: hidden;
}
.atelier-type-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(34, 78, 47, 0.02) 0%, transparent 60%);
opacity: 0;
transition: opacity 220ms ease;
pointer-events: none;
}
.atelier-type-card:hover {
border-color: rgba(34, 78, 47, 0.25);
background: rgba(255, 255, 255, 0.85);
transform: translateY(-1px);
}
.atelier-type-card:hover::before {
opacity: 1;
}
.atelier-type-card:active {
transform: translateY(0) scale(0.985);
}
.atelier-type-card.is-selected {
background: linear-gradient(135deg, #224E2F 0%, #14532D 100%);
border-color: #14532D;
box-shadow: 0 8px 24px -8px rgba(20, 83, 45, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.10);
}
.atelier-type-card.is-selected::before {
opacity: 0;
}
.atelier-type-card .atelier-type-icon {
color: #786B53;
transition: color 220ms ease, transform 220ms ease;
}
.atelier-type-card.is-selected .atelier-type-icon {
color: #FCD34D;
transform: scale(1.06);
}
.atelier-type-card .atelier-type-name {
font-family: var(--font-fraunces);
font-size: 14px;
font-weight: 500;
font-variation-settings: "opsz" 14, "SOFT" 30;
letter-spacing: -0.01em;
color: #1C1917;
transition: color 220ms ease;
}
.atelier-type-card.is-selected .atelier-type-name {
color: #FFFFFF;
}
.atelier-type-card .atelier-type-check {
opacity: 0;
color: #FCD34D;
transform: scale(0.6);
transition: opacity 200ms ease, transform 200ms ease;
}
.atelier-type-card.is-selected .atelier-type-check {
opacity: 1;
transform: scale(1);
}
/* Pill toggle — Active / Taxable */
.atelier-toggle {
display: inline-flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.atelier-toggle-track {
position: relative;
width: 38px;
height: 22px;
background: rgba(28, 25, 23, 0.12);
border-radius: 999px;
transition: background 220ms ease;
flex-shrink: 0;
}
.atelier-toggle.is-on .atelier-toggle-track {
background: linear-gradient(135deg, #224E2F 0%, #16A34A 100%);
box-shadow: 0 2px 8px -2px rgba(34, 78, 47, 0.4);
}
.atelier-toggle.is-on.is-gold .atelier-toggle-track {
background: linear-gradient(135deg, #CA8A04 0%, #EAB308 100%);
box-shadow: 0 2px 8px -2px rgba(202, 138, 4, 0.4);
}
.atelier-toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: #FFFFFF;
border-radius: 999px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.atelier-toggle.is-on .atelier-toggle-thumb {
transform: translateX(16px);
}
.atelier-toggle-label {
font-family: var(--font-fragment-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #786B53;
transition: color 220ms ease;
}
.atelier-toggle.is-on .atelier-toggle-label {
color: #1C1917;
}
/* Image drop zone — large square with botanical treatment */
.atelier-drop {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
aspect-ratio: 1 / 1;
background: rgba(255, 255, 255, 0.5);
border: 1.5px dashed rgba(34, 78, 47, 0.25);
border-radius: 14px;
cursor: pointer;
transition: all 280ms cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.atelier-drop::before {
content: '';
position: absolute;
inset: 12px;
border: 1px solid rgba(180, 155, 100, 0.18);
border-radius: 10px;
pointer-events: none;
}
.atelier-drop::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at center, rgba(34, 78, 47, 0.02) 0%, transparent 70%);
pointer-events: none;
}
.atelier-drop:hover {
border-color: rgba(34, 78, 47, 0.45);
background: rgba(255, 255, 255, 0.75);
transform: scale(1.005);
}
.atelier-drop.is-drag {
border-color: #224E2F;
border-style: solid;
background: rgba(34, 78, 47, 0.04);
transform: scale(1.01);
}
.atelier-drop.is-error {
border-color: #B91C1C;
background: rgba(185, 28, 28, 0.03);
}
.atelier-drop.has-image {
border-style: solid;
border-color: rgba(34, 78, 47, 0.3);
background: #FFFFFF;
}
.atelier-drop-eyebrow {
font-family: var(--font-fragment-mono);
font-size: 9px;
font-weight: 500;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #A8A29E;
margin-top: -4px;
}
.atelier-drop-title {
font-family: var(--font-fraunces);
font-style: italic;
font-size: 18px;
font-variation-settings: "opsz" 14, "SOFT" 100;
color: #57534E;
text-align: center;
line-height: 1.2;
}
.atelier-drop-hint {
font-family: var(--font-manrope);
font-size: 11px;
color: #A8A29E;
letter-spacing: 0.01em;
}
/* Status pill in corner of image */
.atelier-status-pill {
position: absolute;
top: 14px;
right: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px 5px 8px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(28, 25, 23, 0.08);
border-radius: 999px;
font-family: var(--font-fragment-mono);
font-size: 9px;
font-weight: 500;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #1C1917;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
z-index: 2;
}
.atelier-status-pill .atelier-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: #16A34A;
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
}
.atelier-status-pill.is-empty .atelier-dot {
background: #D6D3D1;
box-shadow: 0 0 0 2px rgba(214, 211, 209, 0.2);
}
.atelier-status-pill.is-empty {
color: #A8A29E;
}
/* Modal enter animation */
@keyframes atelier-enter {
from { opacity: 0; transform: translateY(20px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes atelier-backdrop {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes atelier-stagger {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.atelier-enter {
animation: atelier-enter 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.atelier-backdrop {
animation: atelier-backdrop 300ms ease both;
}
.atelier-stagger > * {
animation: atelier-stagger 480ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.atelier-stagger > *:nth-child(1) { animation-delay: 80ms; }
.atelier-stagger > *:nth-child(2) { animation-delay: 140ms; }
.atelier-stagger > *:nth-child(3) { animation-delay: 200ms; }
.atelier-stagger > *:nth-child(4) { animation-delay: 260ms; }
.atelier-stagger > *:nth-child(5) { animation-delay: 320ms; }
.atelier-stagger > *:nth-child(6) { animation-delay: 380ms; }
.atelier-stagger > *:nth-child(7) { animation-delay: 440ms; }
/* Primary CTA — gradient with subtle inner highlight */
.atelier-cta {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px 22px;
font-family: var(--font-manrope);
font-size: 14px;
font-weight: 600;
letter-spacing: -0.005em;
color: #FFFFFF;
background: linear-gradient(135deg, #14532D 0%, #1F6B3E 50%, #14532D 100%);
background-size: 200% 100%;
background-position: 0% 50%;
border: 0;
border-radius: 10px;
cursor: pointer;
box-shadow:
0 6px 18px -6px rgba(20, 83, 45, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.14),
inset 0 -1px 0 rgba(0, 0, 0, 0.10);
transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.atelier-cta:hover {
background-position: 100% 50%;
box-shadow:
0 10px 28px -8px rgba(20, 83, 45, 0.65),
inset 0 1px 0 rgba(255, 255, 255, 0.20),
inset 0 -1px 0 rgba(0, 0, 0, 0.10);
transform: translateY(-1px);
}
.atelier-cta:active {
transform: translateY(0) scale(0.985);
}
.atelier-cta:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
background: #57534E;
box-shadow: none;
}
.atelier-cta .atelier-cta-shimmer {
position: absolute;
top: 0;
left: -100%;
width: 60%;
height: 100%;
background: linear-gradient(105deg, transparent 30%, rgba(255, 255, 255, 0.18) 50%, transparent 70%);
pointer-events: none;
transition: left 700ms cubic-bezier(0.4, 0, 0.2, 1);
}
.atelier-cta:hover .atelier-cta-shimmer {
left: 130%;
}
/* Discard button */
.atelier-discard {
font-family: var(--font-fraunces);
font-style: italic;
font-size: 14px;
font-variation-settings: "opsz" 14, "SOFT" 100;
color: #786B53;
background: transparent;
border: 0;
padding: 8px 6px;
cursor: pointer;
transition: color 180ms ease;
}
.atelier-discard:hover {
color: #1C1917;
}
/* Brand selector */
.atelier-select {
font-family: var(--font-manrope);
font-size: 15px;
font-weight: 500;
color: #1C1917;
background: transparent;
border: 0;
border-bottom: 1.5px solid rgba(28, 25, 23, 0.12);
border-radius: 0;
padding: 10px 24px 10px 0;
width: 100%;
outline: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23786B53' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right center;
transition: border-color 220ms ease;
}
.atelier-select:hover { border-bottom-color: rgba(28, 25, 23, 0.28); }
.atelier-select:focus { border-bottom-color: #224E2F; border-bottom-width: 2px; padding-bottom: 9.5px; }
/* Hint / help text */
.atelier-hint {
font-family: var(--font-fraunces);
font-style: italic;
font-size: 12px;
font-variation-settings: "opsz" 14, "SOFT" 100;
color: #A8A29E;
line-height: 1.4;
margin-top: 6px;
}
/* Error banner */
.atelier-error {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
background: linear-gradient(135deg, rgba(185, 28, 28, 0.06) 0%, rgba(185, 28, 28, 0.03) 100%);
border: 1px solid rgba(185, 28, 28, 0.25);
border-radius: 10px;
font-family: var(--font-manrope);
font-size: 13px;
color: #991B1B;
}
.atelier-error svg { flex-shrink: 0; margin-top: 1px; }
+23 -2
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Fraunces, Manrope, Fragment_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/Providers";
import ToastNotificationContainer from "@/components/notifications/ToastNotification";
@@ -6,6 +7,26 @@ import CookieConsentBanner from "@/components/legal/CookieConsentBanner";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
const fraunces = Fraunces({
subsets: ["latin"],
display: "swap",
variable: "--font-fraunces",
axes: ["SOFT", "WONK", "opsz"],
});
const manrope = Manrope({
subsets: ["latin"],
display: "swap",
variable: "--font-manrope",
});
const fragmentMono = Fragment_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-fragment-mono",
weight: "400",
});
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
@@ -50,8 +71,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<html lang="en" suppressHydrationWarning className={`${fraunces.variable} ${manrope.variable} ${fragmentMono.variable}`}>
<body className="font-sans">
<Providers>{children}</Providers>
<ToastNotificationContainer />
<CookieConsentBanner />
@@ -0,0 +1,116 @@
import type { Metadata } from "next";
import { Fraunces, JetBrains_Mono } from "next/font/google";
import { supabase } from "@/lib/supabase";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
import SweetCornProductPage from "@/components/storefront/SweetCornProductPage";
const fraunces = Fraunces({
subsets: ["latin"],
variable: "--font-display",
display: "swap",
style: ["normal", "italic"],
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
weight: ["400", "500", "700"],
});
export const metadata: Metadata = {
// The Tuxedo layout's `template: "%s | Tuxedo Corn"` already appends the
// brand — keep the bare title here so the rendered <title> is correct.
title: "Fresh Sweet Corn Box 12 Ears",
description:
"Our 12-Ear Corn Box is packed with peak-season, super-sweet Olathe Sweet corn picked that morning and rushed to your door. Hand-selected, non-GMO, and 100% sweetness guaranteed.",
keywords: [
"sweet corn",
"Olathe Sweet",
"fresh corn box",
"12 ears of corn",
"farm fresh corn",
"Tuxedo Corn",
],
openGraph: {
title: "Fresh Sweet Corn Box 12 Ears",
description:
"Peak-season, super-sweet Olathe Sweet corn — picked that morning, rushed to your door. 100% sweetness guaranteed.",
type: "website",
images: [
{
url: "https://images.unsplash.com/photo-1551754655-cd27e38d2076?auto=format&fit=crop&w=1200&q=80",
width: 1200,
height: 630,
alt: "Fresh sweet corn box — 12 ears of Olathe Sweet corn",
},
],
},
twitter: {
card: "summary_large_image",
title: "Fresh Sweet Corn Box 12 Ears",
description:
"Peak-season, super-sweet Olathe Sweet corn. Hand-selected, non-GMO, 100% sweetness guaranteed.",
},
};
const BRAND_SLUG = "tuxedo";
type Brand = {
id: string;
name: string;
};
type Settings = {
logo_url?: string | null;
logo_url_dark?: string | null;
};
export default async function SweetCornBoxPage() {
// Fetch the brand record so we have a real brand_id to thread through
// the cart system. Falls back to a placeholder if Supabase is unreachable
// (so the page still renders in dev / disconnected previews).
let brandId = "00000000-0000-0000-0000-000000000000";
let brandName = "Tuxedo Corn";
let logoUrl: string | null = null;
let logoUrlDark: string | null = null;
try {
const [brandRes, settingsRes] = await Promise.all([
supabase.from("brands").select("id, name").eq("slug", BRAND_SLUG).single(),
getBrandSettingsPublic(BRAND_SLUG),
]);
if (brandRes.data) {
const b = brandRes.data as Brand;
brandId = b.id;
brandName = b.name;
}
if (settingsRes.success && settingsRes.settings) {
const s = settingsRes.settings as Settings;
logoUrl = s.logo_url ?? null;
logoUrlDark = s.logo_url_dark ?? null;
}
} catch {
// ignore — fall through with defaults
}
return (
<div
className={`${fraunces.variable} ${jetbrainsMono.variable} font-sans`}
style={{
// The product page uses editorial typography: a display serif
// (Fraunces) for headlines and a mono (JetBrains Mono) for data
// stamps. Body copy falls back to the global SF Pro stack.
["--font-display" as never]: fraunces.style.fontFamily,
["--font-mono" as never]: jetbrainsMono.style.fontFamily,
}}
>
<SweetCornProductPage
brandId={brandId}
brandName={brandName}
logoUrl={logoUrl}
logoUrlDark={logoUrlDark}
/>
</div>
);
}
+288
View File
@@ -0,0 +1,288 @@
"use client";
import { useState, useCallback } from "react";
import { createLocation } from "@/actions/locations";
import GlassModal from "@/components/admin/GlassModal";
type Props = {
isOpen: boolean;
onClose: () => void;
brandId: string;
onSuccess?: (locationId: string) => void;
};
export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState("");
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [stateVal, setStateVal] = useState("");
const [zip, setZip] = useState("");
const [phone, setPhone] = useState("");
const [contactName, setContactName] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [notes, setNotes] = useState("");
const reset = useCallback(() => {
setName("");
setAddress("");
setCity("");
setStateVal("");
setZip("");
setPhone("");
setContactName("");
setContactEmail("");
setNotes("");
setError(null);
}, []);
const handleClose = useCallback(() => {
if (loading) return;
reset();
onClose();
}, [loading, reset, onClose]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("Venue name is required.");
return;
}
setLoading(true);
try {
const result = await createLocation(brandId, {
name: name.trim(),
address: address.trim() || null,
city: city.trim() || null,
state: stateVal.trim().toUpperCase() || null,
zip: zip.trim() || null,
phone: phone.trim() || null,
contact_name: contactName.trim() || null,
contact_email: contactEmail.trim() || null,
notes: notes.trim() || null,
active: true,
});
if (result.success) {
onSuccess?.(result.id);
reset();
onClose();
} else {
setError(result.error ?? "Failed to create location");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
},
[brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, reset, onClose]
);
const inputStyle = {
background: "rgba(0, 0, 0, 0.02)",
border: "1px solid rgba(0, 0, 0, 0.06)",
outline: "none",
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.target.style.background = "rgba(16, 185, 129, 0.04)";
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.target.style.background = "rgba(0, 0, 0, 0.02)";
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
e.target.style.boxShadow = "none";
};
if (!isOpen) return null;
return (
<GlassModal
title="Add Venue"
subtitle="Reusable pick-up location. Once saved, you can attach stops to it from the Stops tab."
onClose={handleClose}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
>
{error}
</div>
)}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tractor Supply"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="13778 E I-25 Frontage Rd"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-3 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Wellington"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<input
type="text"
value={stateVal}
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
placeholder="CO"
maxLength={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<input
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80549"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="(970) 555-1234"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
<input
type="text"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
placeholder="Store manager"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
<input
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="manager@example.com"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Park on west side. Use side entrance after 9am."
rows={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all resize-none"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
<button
type="button"
onClick={handleClose}
disabled={loading}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: loading
? "rgba(16, 185, 129, 0.4)"
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
boxShadow: loading
? "none"
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
opacity: loading ? 0.7 : 1,
}}
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Creating
</span>
) : (
"Create Venue"
)}
</button>
</div>
</form>
</GlassModal>
);
}
+249 -183
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { createStop } from "@/actions/stops/create-stop";
import GlassModal from "@/components/admin/GlassModal";
@@ -21,12 +21,20 @@ type Props = {
onSuccess?: (stopId: string) => void;
};
/* Pin icon for the modal header */
const PinIcon = () => (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom, onSuccess }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [city, setCity] = useState("");
const [state, setState] = useState("");
const [stateField, setStateField] = useState("");
const [location, setLocation] = useState("");
const [date, setDate] = useState("");
const [time, setTime] = useState("");
@@ -36,11 +44,13 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
const [cutoffTime, setCutoffTime] = useState("");
const [status, setStatus] = useState<"draft" | "active">("draft");
const cityRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
// Reset form when modal opens with optional duplicate source
setCity(duplicateFrom?.city ?? "");
setState(duplicateFrom?.state ?? "");
setStateField(duplicateFrom?.state ?? "");
setLocation(duplicateFrom?.location ?? "");
setDate(duplicateFrom?.date ?? "");
setTime(duplicateFrom?.time ?? "");
@@ -49,272 +59,328 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
setStatus("draft");
setError(null);
// Auto-focus city field for fast data entry
requestAnimationFrame(() => cityRef.current?.focus());
}
/* eslint-enable react-hooks/set-state-in-effect */
}, [isOpen, duplicateFrom]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!city.trim() || !state.trim() || !location.trim() || !date) {
setError("City, state, location, and date are required.");
return;
}
setLoading(true);
try {
const result = await createStop(brandId, {
city: city.trim(),
state: state.trim(),
location: location.trim(),
date,
time: time || "08:00",
address: address || undefined,
zip: zip || undefined,
cutoff_time: cutoffTime || undefined,
active: status === "active",
});
if (result.success) {
onSuccess?.(result.id);
onClose();
} else {
setError(result.error ?? "Failed to create stop");
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
setError("City, state, location, and date are required.");
return;
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
}, [brandId, city, state, location, date, time, address, zip, cutoffTime, status, onSuccess, onClose]);
setLoading(true);
try {
const result = await createStop(brandId, {
city: city.trim(),
state: stateField.trim(),
location: location.trim(),
date,
time: time || "08:00",
address: address || undefined,
zip: zip || undefined,
cutoff_time: cutoffTime || undefined,
active: status === "active",
});
if (result.success) {
onSuccess?.(result.id);
onClose();
} else {
setError(result.error ?? "Failed to create stop");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
},
[
brandId,
city,
stateField,
location,
date,
time,
address,
zip,
cutoffTime,
status,
onSuccess,
onClose,
]
);
const isDuplicate = Boolean(duplicateFrom);
const title = isDuplicate ? "Duplicate Stop" : "Add New Stop";
const subtitle = isDuplicate && duplicateFrom
const title = isDuplicate ? "Duplicate Stop" : "Add Stop";
const eyebrow = isDuplicate && duplicateFrom
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
: "Create a new tour stop for your route.";
const inputStyle = {
background: 'rgba(0, 0, 0, 0.02)',
border: '1px solid rgba(0, 0, 0, 0.06)',
outline: 'none',
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
e.target.style.background = 'rgba(16, 185, 129, 0.04)';
e.target.style.border = '1px solid rgba(16, 185, 129, 0.5)';
e.target.style.boxShadow = '0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)';
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
e.target.style.background = 'rgba(0, 0, 0, 0.02)';
e.target.style.border = '1px solid rgba(0, 0, 0, 0.06)';
e.target.style.boxShadow = 'none';
};
: "New stop on the route";
const submitLabel = loading
? isDuplicate ? "Duplicating…" : "Creating…"
: isDuplicate
? "Duplicate Stop"
: status === "active"
? "Create & Publish"
: "Save as Draft";
if (!isOpen) return null;
return (
<GlassModal title={title} subtitle={subtitle} onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-4">
<GlassModal
title={title}
eyebrow={eyebrow}
onClose={onClose}
maxWidth="max-w-xl"
compact
>
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
{error && (
<div className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
{error}
<div
role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{ background: "rgba(220, 38, 38, 0.06)", border: "1px solid rgba(220, 38, 38, 0.15)" }}
>
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
</svg>
<span>{error}</span>
</div>
)}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
{/* Row 1 — Where: City + State */}
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
<div className="ha-field">
<label htmlFor="stop-city" className="ha-field-label">
<PinIcon />
<span>City</span>
<span className="ha-field-label-required">*</span>
</label>
<input
ref={cityRef}
id="stop-city"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Denver"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
className="ha-field-input"
required
autoComplete="address-level2"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<div className="ha-field">
<label htmlFor="stop-state" className="ha-field-label">
<span>State</span>
<span className="ha-field-label-required">*</span>
</label>
<input
id="stop-state"
type="text"
value={state}
onChange={(e) => setState(e.target.value.toUpperCase())}
value={stateField}
onChange={(e) => setStateField(e.target.value.toUpperCase())}
placeholder="CO"
maxLength={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
className="ha-field-input ha-field-input-mono text-center"
style={{ textTransform: "uppercase" }}
required
autoComplete="address-level1"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Location</label>
{/* Row 2 — Where at: Location (full) */}
<div className="ha-field">
<label htmlFor="stop-location" className="ha-field-label">
<span>Location / Venue</span>
<span className="ha-field-label-required">*</span>
</label>
<input
id="stop-location"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Whole Foods Market"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="Whole Foods Market — Highlands"
className="ha-field-input"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Date</label>
{/* Row 3 — When: Date + Time (with small clock icon) */}
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div className="ha-field">
<label htmlFor="stop-date" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
</svg>
<span>Pickup Date</span>
<span className="ha-field-label-required">*</span>
</label>
<input
id="stop-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
className="ha-field-input ha-field-input-mono"
required
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Pickup Time</label>
<div className="ha-field">
<label htmlFor="stop-time" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" strokeLinecap="round" />
</svg>
<span>Pickup Time</span>
</label>
<input
id="stop-time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
className="ha-field-input ha-field-input-mono"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Address</label>
{/* Row 4 — Address + ZIP + Cutoff in 3 columns */}
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
<div className="ha-field">
<label htmlFor="stop-address" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
<path d="M9 22V12h6v10" strokeLinejoin="round" />
</svg>
<span>Street Address</span>
</label>
<input
id="stop-address"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
className="ha-field-input"
autoComplete="street-address"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<div className="ha-field">
<label htmlFor="stop-zip" className="ha-field-label">
<span>ZIP</span>
</label>
<input
id="stop-zip"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80202"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
maxLength={10}
className="ha-field-input ha-field-input-mono"
autoComplete="postal-code"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-cutoff" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" strokeLinecap="round" />
</svg>
<span>Cutoff</span>
</label>
<input
id="stop-cutoff"
type="time"
value={cutoffTime}
onChange={(e) => setCutoffTime(e.target.value)}
className="ha-field-input ha-field-input-mono"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Cutoff Time</label>
<input
type="time"
value={cutoffTime}
onChange={(e) => setCutoffTime(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<p className="mt-1 text-xs text-stone-400 ml-1">Orders close this time the day before pickup</p>
</div>
{/* Status toggle */}
<div className="space-y-2">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Status</label>
<div className="flex gap-3">
{/* Row 5 — Status: segmented control (compact, replaces two giant buttons) */}
<div className="ha-field pt-0.5">
<div className="flex items-center justify-between">
<label className="ha-field-label">
<span>Visibility</span>
</label>
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
Draft is hidden from customers
</span>
</div>
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
<button
type="button"
role="radio"
aria-checked={status === "draft"}
onClick={() => setStatus("draft")}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
style={{
border: status === "draft" ? '1px solid rgba(245, 158, 11, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
background: status === "draft" ? 'rgba(245, 158, 11, 0.08)' : 'rgba(0, 0, 0, 0.02)',
}}
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
>
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
background: status === "draft" ? 'rgba(245, 158, 11, 0.2)' : 'rgba(0, 0, 0, 0.05)',
color: status === "draft" ? '#b45309' : 'rgba(0, 0, 0, 0.4)',
}}>Draft</span>
<span className="text-sm font-medium" style={{ color: status === "draft" ? '#92400e' : 'rgba(0, 0, 0, 0.4)' }}>Save as draft</span>
<span className="ha-segment-dot" />
<span>Save as Draft</span>
</button>
<button
type="button"
role="radio"
aria-checked={status === "active"}
onClick={() => setStatus("active")}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
style={{
border: status === "active" ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
background: status === "active" ? 'rgba(16, 185, 129, 0.08)' : 'rgba(0, 0, 0, 0.02)',
}}
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
>
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
background: status === "active" ? 'rgba(16, 185, 129, 0.2)' : 'rgba(0, 0, 0, 0.05)',
color: status === "active" ? '#047857' : 'rgba(0, 0, 0, 0.4)',
}}>Active</span>
<span className="text-sm font-medium" style={{ color: status === "active" ? '#065f46' : 'rgba(0, 0, 0, 0.4)' }}>Publish now</span>
<span className="ha-segment-dot" />
<span>Publish Now</span>
</button>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}>
<button
type="button"
onClick={onClose}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: loading
? 'rgba(16, 185, 129, 0.4)'
: 'linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)',
boxShadow: loading
? 'none'
: '0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Creating...
</span>
) : (
isDuplicate ? "Duplicate Stop" : "Create Stop"
)}
</button>
<div className="ha-modal-footer">
<span className="ha-modal-footer-hint">
<kbd>Esc</kbd>
to close
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="ha-btn-ghost"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="ha-btn-primary"
>
{loading ? (
<>
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
<span>{submitLabel}</span>
</>
) : (
<>
{status === "active" ? (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
<span>{submitLabel}</span>
</>
)}
</button>
</div>
</div>
</form>
</GlassModal>
@@ -338,4 +404,4 @@ export function useAddStopModal(brandId: string, duplicateFrom?: Props["duplicat
), [isOpen, close, brandId, duplicateFrom]);
return { open, close, Modal };
}
}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { setActiveBrand } from "@/actions/set-active-brand";
export type BrandSelectorItem = {
id: string;
name: string;
slug: string;
logo_url: string | null;
};
type BrandSelectorProps = {
brands: BrandSelectorItem[];
/** Currently-active brand id. `null` means "All brands" (platform_admin only). */
activeBrandId: string | null;
/** When true, shows the "All brands" pseudo-option at the top. */
showAllBrandsOption: boolean;
/** Whether the user has multi-brand access. Controls the "Multi-brand manager" badge. */
isMultiBrandAdmin: boolean;
};
/**
* Brand selector dropdown.
*
* Renders a compact pill showing the active brand (or "All brands" for
* platform_admin) and opens a list of accessible brands on click.
*
* On select: calls `setActiveBrand` server action then `router.refresh()`
* so all server components re-read the new cookie and reload their data.
* The URL is NOT changed — the cookie is the source of truth.
*/
export default function BrandSelector({
brands,
activeBrandId,
showAllBrandsOption,
isMultiBrandAdmin,
}: BrandSelectorProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Close on escape
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open]);
// No brands + no "All brands" option = don't render
if (!showAllBrandsOption && brands.length === 0) return null;
if (!showAllBrandsOption && brands.length < 2) return null;
const activeBrand = brands.find((b) => b.id === activeBrandId);
const label =
showAllBrandsOption && !activeBrand
? "All brands"
: activeBrand?.name ?? "Select brand";
async function selectBrand(brandId: string | null) {
setOpen(false);
if (brandId === activeBrandId) return;
setPending(true);
try {
const res = await setActiveBrand(brandId);
if (res.success) {
router.refresh();
} else {
// Keep UI usable if the server rejected; log for debugging
console.error("setActiveBrand failed:", res.error);
}
} finally {
setPending(false);
}
}
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={pending}
aria-haspopup="listbox"
aria-expanded={open}
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg border transition-colors text-left disabled:opacity-60"
style={{
backgroundColor: "rgba(208, 203, 180, 0.1)",
borderColor: "rgba(208, 203, 180, 0.25)",
color: "var(--admin-sidebar-text)",
}}
>
<span
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
aria-hidden="true"
>
{showAllBrandsOption && !activeBrand ? "*" : label.charAt(0).toUpperCase()}
</span>
<span className="flex-1 min-w-0 text-xs font-medium truncate">
{label}
</span>
{isMultiBrandAdmin && (
<span
className="text-[9px] font-medium px-1.5 py-0.5 rounded-md flex-shrink-0"
style={{
backgroundColor: "rgba(202, 117, 67, 0.18)",
color: "var(--admin-accent)",
}}
title="Manages multiple brands"
>
multi
</span>
)}
<svg
className={`w-3 h-3 flex-shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
{open && (
<div
role="listbox"
className="absolute top-full left-0 right-0 mt-1.5 rounded-xl border shadow-2xl z-50 overflow-hidden"
style={{
backgroundColor: "var(--admin-sidebar-bg)",
borderColor: "rgba(208, 203, 180, 0.25)",
}}
>
{showAllBrandsOption && (
<button
type="button"
role="option"
aria-selected={!activeBrand}
onClick={() => selectBrand(null)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
style={{
color: !activeBrand ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
backgroundColor: !activeBrand ? "rgba(202, 117, 67, 0.10)" : "transparent",
}}
>
<span
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
aria-hidden="true"
>
*
</span>
<span className="flex-1">All brands</span>
{!activeBrand && (
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: "var(--admin-accent)" }}
aria-hidden="true"
/>
)}
</button>
)}
{brands.map((b) => {
const isActive = b.id === activeBrandId;
return (
<button
key={b.id}
type="button"
role="option"
aria-selected={isActive}
onClick={() => selectBrand(b.id)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
style={{
color: isActive ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
backgroundColor: isActive ? "rgba(202, 117, 67, 0.10)" : "transparent",
}}
>
<span
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
aria-hidden="true"
>
{b.name.charAt(0).toUpperCase()}
</span>
<span className="flex-1 truncate">{b.name}</span>
{isActive && (
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: "var(--admin-accent)" }}
aria-hidden="true"
/>
)}
</button>
);
})}
</div>
)}
</div>
);
}
+298
View File
@@ -0,0 +1,298 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { updateLocation } from "@/actions/locations";
import GlassModal from "@/components/admin/GlassModal";
export type LocationForEdit = {
id: string;
brand_id: string;
name: string;
address: string | null;
city: string | null;
state: string | null;
zip: string | null;
phone: string | null;
contact_name: string | null;
contact_email: string | null;
notes: string | null;
active: boolean;
};
type Props = {
isOpen: boolean;
onClose: () => void;
location: LocationForEdit | null;
brandId: string;
onSuccess?: () => void;
};
export default function EditLocationModal({ isOpen, onClose, location, brandId, onSuccess }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState("");
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [stateVal, setStateVal] = useState("");
const [zip, setZip] = useState("");
const [phone, setPhone] = useState("");
const [contactName, setContactName] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [notes, setNotes] = useState("");
useEffect(() => {
/* eslint-disable react-hooks/set-state-in-effect */
if (location && isOpen) {
setName(location.name ?? "");
setAddress(location.address ?? "");
setCity(location.city ?? "");
setStateVal(location.state ?? "");
setZip(location.zip ?? "");
setPhone(location.phone ?? "");
setContactName(location.contact_name ?? "");
setContactEmail(location.contact_email ?? "");
setNotes(location.notes ?? "");
setError(null);
}
/* eslint-enable react-hooks/set-state-in-effect */
}, [location, isOpen]);
const handleClose = useCallback(() => {
if (loading) return;
setError(null);
onClose();
}, [loading, onClose]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!location) return;
setError(null);
if (!name.trim()) {
setError("Venue name is required.");
return;
}
setLoading(true);
try {
const result = await updateLocation(location.id, brandId, {
name: name.trim(),
address: address.trim() || null,
city: city.trim() || null,
state: stateVal.trim().toUpperCase() || null,
zip: zip.trim() || null,
phone: phone.trim() || null,
contact_name: contactName.trim() || null,
contact_email: contactEmail.trim() || null,
notes: notes.trim() || null,
active: location.active,
});
if (result.success) {
onSuccess?.();
onClose();
} else {
setError(result.error ?? "Failed to update location");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
},
[location, brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, onClose]
);
const inputStyle = {
background: "rgba(0, 0, 0, 0.02)",
border: "1px solid rgba(0, 0, 0, 0.06)",
outline: "none",
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.target.style.background = "rgba(16, 185, 129, 0.04)";
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.target.style.background = "rgba(0, 0, 0, 0.02)";
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
e.target.style.boxShadow = "none";
};
if (!isOpen || !location) return null;
return (
<GlassModal
title="Edit Venue"
subtitle="Changes apply to every stop currently linked to this venue."
onClose={handleClose}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div
className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
style={{ background: "rgba(239, 68, 68, 0.1)", border: "1px solid rgba(239, 68, 68, 0.2)" }}
>
{error}
</div>
)}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-3 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-1 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<input
type="text"
value={stateVal}
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
maxLength={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<input
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
<input
type="text"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
<input
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all resize-none"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: "1px solid rgba(0, 0, 0, 0.04)" }}>
<button
type="button"
onClick={handleClose}
disabled={loading}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: loading
? "rgba(16, 185, 129, 0.4)"
: "linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)",
boxShadow: loading
? "none"
: "0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)",
opacity: loading ? 0.7 : 1,
}}
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Saving
</span>
) : (
"Save Changes"
)}
</button>
</div>
</form>
</GlassModal>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useEffect } from "react";
type Props = {
title: string;
titleIcon?: React.ReactNode;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
maxWidth?: string;
variant?: "default" | "elegant";
};
export default function ElegantModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg", variant = "default" }: Props) {
useEffect(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [onClose]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
const isElegant = variant === "elegant";
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
onClick={handleBackdropClick}
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
<div
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col ${isElegant ? "border border-stone-200/60" : ""}`}
>
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-600 to-amber-500 rounded-t-2xl" />
<div className={`flex items-center justify-between px-6 sm:px-8 py-5 shrink-0 ${isElegant ? "border-b border-stone-100" : ""}`}>
<div className="flex items-center gap-3 min-w-0">
{titleIcon && (
<div className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-lg shrink-0 bg-amber-50">
{titleIcon}
</div>
)}
<div className="min-w-0">
{isElegant ? (
<h2 className="text-sm font-normal tracking-wide text-stone-500 uppercase" style={{ fontFamily: "Georgia, serif" }}>
{title}
</h2>
) : (
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 truncate" style={{ letterSpacing: "-0.02em" }}>
{title}
</h2>
)}
{subtitle && (
<p className={`mt-0.5 text-sm text-stone-400 hidden sm:block ${isElegant ? "font-normal" : ""}`}>{subtitle}</p>
)}
</div>
</div>
<button
onClick={onClose}
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2 hover:bg-stone-100 text-stone-500"
>
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="relative px-6 sm:px-8 py-6 overflow-y-auto flex-1">
{children}
</div>
</div>
</div>
);
}
+66 -20
View File
@@ -9,9 +9,22 @@ type Props = {
onClose: () => void;
children: React.ReactNode;
maxWidth?: string;
/** Compact mode: tighter padding, smaller title, no accent bar — for dense forms. */
compact?: boolean;
/** Optional eyebrow text rendered above the title in compact mode. */
eyebrow?: string;
};
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg" }: Props) {
export default function GlassModal({
title,
titleIcon,
subtitle,
onClose,
children,
maxWidth = "max-w-lg",
compact = false,
eyebrow,
}: Props) {
// Lock body scroll
useEffect(() => {
document.body.style.overflow = "hidden";
@@ -33,25 +46,36 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 md:p-6"
onClick={handleBackdropClick}
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
style={{
backgroundColor: "rgba(60, 56, 37, 0.45)",
backdropFilter: "blur(2px)",
WebkitBackdropFilter: "blur(2px)",
}}
>
{/* Modal card - solid white with shadow for high contrast */}
<div
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col`}
className={`relative w-full ${maxWidth} ${
compact
? "max-h-[min(640px,calc(100vh-2rem))]"
: "max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)]"
} rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.18),0_8px_16px_-4px_rgba(0,0,0,0.05)] flex flex-col overflow-hidden`}
>
{/* Subtle top border accent */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
{/* Accent bar — only for non-compact (compact forms have their own internal accent) */}
{!compact && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
)}
{/* Header */}
<div
className="flex items-center justify-between px-4 sm:px-6 md:px-8 py-4 sm:py-6 shrink-0"
<div
className={`flex items-center justify-between shrink-0 ${
compact ? "px-5 pt-4 pb-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6"
}`}
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
>
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
{titleIcon && (
<div
{titleIcon && !compact && (
<div
className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-xl shrink-0"
style={{ backgroundColor: "var(--admin-accent-light)" }}
>
@@ -59,21 +83,39 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
</div>
)}
<div className="min-w-0">
<h2
className="text-lg sm:text-xl font-semibold text-[var(--admin-text-primary)] truncate"
style={{ letterSpacing: "-0.02em" }}
{compact && eyebrow && (
<p className="ha-eyebrow mb-0.5 truncate">{eyebrow}</p>
)}
<h2
className={`${
compact
? "ha-display text-lg truncate"
: "text-lg sm:text-xl font-semibold truncate"
} text-[var(--admin-text-primary)]`}
style={compact ? undefined : { letterSpacing: "-0.02em" }}
>
{title}
</h2>
{subtitle && (
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">{subtitle}</p>
{subtitle && !compact && (
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">
{subtitle}
</p>
)}
</div>
</div>
<button
onClick={onClose}
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2"
style={{ backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }}
className={`flex shrink-0 items-center justify-center rounded-full transition-all duration-150 ml-2 ${
compact
? "h-7 w-7 text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)]"
: "h-8 w-8 sm:h-9 sm:w-9"
}`}
style={
compact
? undefined
: { backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }
}
aria-label="Close modal"
>
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -81,8 +123,12 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
</button>
</div>
{/* Content - scrollable */}
<div className="relative px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8 overflow-y-auto flex-1">
{/* Content */}
<div
className={`relative overflow-y-auto flex-1 ${
compact ? "px-5 pb-5 pt-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8"
}`}
>
{children}
</div>
</div>
+387
View File
@@ -0,0 +1,387 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useTransition, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { deleteLocation } from "@/actions/locations";
import AddLocationModal from "@/components/admin/AddLocationModal";
import EditLocationModal, { type LocationForEdit } from "@/components/admin/EditLocationModal";
import {
AdminSearchInput,
AdminFilterTabs,
AdminButton,
AdminIconButton,
useToast,
Skeleton,
} from "@/components/admin/design-system";
export type AdminLocation = {
id: string;
brand_id: string;
name: string;
address: string | null;
city: string | null;
state: string | null;
zip: string | null;
phone: string | null;
contact_name: string | null;
contact_email: string | null;
notes: string | null;
active: boolean;
deleted_at: string | null;
created_at: string;
updated_at: string;
slug: string | null;
stop_count: number;
};
type Props = {
locations: AdminLocation[];
brandId: string;
};
type StatusFilter = "all" | "active" | "inactive";
export default function LocationsTab({ locations, brandId }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [editing, setEditing] = useState<LocationForEdit | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const PAGE_SIZE = 50;
useEffect(() => {
setIsLoading(true);
const t = setTimeout(() => setIsLoading(false), 250);
return () => clearTimeout(t);
}, [page, statusFilter, search]);
const counts = useMemo(
() => ({
all: locations.length,
active: locations.filter((l) => l.active).length,
inactive: locations.filter((l) => !l.active).length,
}),
[locations]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return locations.filter((l) => {
const matchesSearch =
!q ||
l.name.toLowerCase().includes(q) ||
(l.address ?? "").toLowerCase().includes(q) ||
(l.city ?? "").toLowerCase().includes(q) ||
(l.contact_name ?? "").toLowerCase().includes(q);
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && l.active) ||
(statusFilter === "inactive" && !l.active);
return matchesSearch && matchesStatus;
});
}, [locations, search, statusFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const tabs = [
{ value: "all", label: "All", count: counts.all },
{ value: "active", label: "Active", count: counts.active },
{ value: "inactive", label: "Inactive", count: counts.inactive },
];
function handleAdded() {
setShowAdd(false);
showSuccess("Venue created");
startTransition(() => router.refresh());
}
function handleEdited() {
setEditing(null);
showSuccess("Venue updated");
startTransition(() => router.refresh());
}
async function handleDelete(loc: AdminLocation) {
if (!confirm(`Delete venue "${loc.name}"? This cannot be undone.`)) return;
setPendingDeleteId(loc.id);
setDeleteError(null);
const res = await deleteLocation(loc.id, brandId);
setPendingDeleteId(null);
if (res.success) {
showSuccess("Venue deleted");
startTransition(() => router.refresh());
} else {
setDeleteError(res.error ?? "Delete failed");
showError("Delete failed", res.error ?? "Unknown error");
}
}
return (
<>
{/* Filter bar */}
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
<AdminSearchInput
placeholder="Search venues by name, city, or contact…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
onClear={() => {
setSearch("");
setPage(0);
}}
showClear
className="flex-1 min-w-48 max-w-72"
/>
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => {
setStatusFilter(value as StatusFilter);
setPage(0);
}}
tabs={tabs}
size="sm"
showCounts
/>
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">
{filtered.length} venue{filtered.length !== 1 ? "s" : ""}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<AdminIconButton
variant="secondary"
size="sm"
label="Previous page"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</AdminIconButton>
<span className="text-xs text-[var(--admin-text-muted)] px-1">
{page + 1}/{totalPages}
</span>
<AdminIconButton
variant="secondary"
size="sm"
label="Next page"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</AdminIconButton>
</div>
)}
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAdd(true)}
icon={
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
>
Add Venue
</AdminButton>
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-4 font-semibold">Venue</th>
<th className="px-5 py-4 font-semibold">Address</th>
<th className="px-5 py-4 font-semibold">Contact</th>
<th className="px-5 py-4 font-semibold text-center">Stops</th>
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{isLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<tr key={i}>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-48 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4 text-center"><Skeleton variant="rect" className="w-8 h-5 mx-auto rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
</tr>
))
) : filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-12 text-center">
<div className="flex flex-col items-center gap-2 text-[var(--admin-text-muted)]">
<svg className="h-10 w-10 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="text-sm">
{search || statusFilter !== "all"
? "No venues match your filters."
: "No venues yet. Add your first venue to get started."}
</p>
</div>
</td>
</tr>
) : (
paginated.map((loc) => (
<tr key={loc.id} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4">
<div className="font-semibold text-[var(--admin-text-primary)]">{loc.name}</div>
{(loc.city || loc.state) && (
<div className="text-xs text-[var(--admin-text-muted)]">
{[loc.city, loc.state].filter(Boolean).join(", ")}
</div>
)}
</td>
<td className="px-5 py-4">
{loc.address ? (
<div>
<div className="text-[var(--admin-text-secondary)]">{loc.address}</div>
{loc.zip && (
<div className="text-xs text-[var(--admin-text-muted)]">{loc.zip}</div>
)}
</div>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)}
</td>
<td className="px-5 py-4">
{loc.contact_name || loc.phone || loc.contact_email ? (
<div className="space-y-0.5">
{loc.contact_name && (
<div className="text-[var(--admin-text-secondary)]">{loc.contact_name}</div>
)}
{loc.phone && (
<div className="text-xs text-[var(--admin-text-muted)]">{loc.phone}</div>
)}
{loc.contact_email && (
<div className="text-xs text-[var(--admin-text-muted)] truncate max-w-[200px]">
{loc.contact_email}
</div>
)}
</div>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)}
</td>
<td className="px-5 py-4 text-center">
{loc.stop_count > 0 ? (
<span
className="inline-flex items-center justify-center min-w-[28px] px-2 py-0.5 rounded-full text-xs font-semibold"
style={{
background: "rgba(16, 185, 129, 0.12)",
color: "#047857",
}}
>
{loc.stop_count}
</span>
) : (
<span className="text-[var(--admin-text-muted)] text-xs">0</span>
)}
</td>
<td className="px-5 py-4">
{loc.active ? (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: "rgba(16, 185, 129, 0.12)", color: "#047857" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#10b981" }} />
Active
</span>
) : (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: "rgba(120, 113, 108, 0.12)", color: "#57534e" }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "#a8a29e" }} />
Inactive
</span>
)}
</td>
<td className="px-5 py-4 text-right">
<div className="flex justify-end gap-1">
<button
onClick={() => setEditing(loc as LocationForEdit)}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
title="Edit venue"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(loc)}
disabled={pendingDeleteId === loc.id}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50"
title="Delete venue"
>
{pendingDeleteId === loc.id ? (
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a2 2 0 012-2h2a2 2 0 012 2v3" />
</svg>
)}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
{showAdd && (
<AddLocationModal
isOpen={showAdd}
onClose={() => setShowAdd(false)}
brandId={brandId}
onSuccess={handleAdded}
/>
)}
{editing && (
<EditLocationModal
isOpen={!!editing}
onClose={() => setEditing(null)}
location={editing}
brandId={brandId}
onSuccess={handleEdited}
/>
)}
</>
);
}
+718
View File
@@ -0,0 +1,718 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import { createPortal } from "react-dom";
export type ProductFormModalProps = {
open: boolean;
mode: "add" | "edit";
onClose: () => void;
onSubmit: (values: ProductFormValues) => Promise<{ success: boolean; error?: string }>;
/** Defaults / initial values (edit mode passes the current product) */
initial?: Partial<ProductFormValues>;
/** List of selectable brands (only shown to platform admins) */
brands?: { id: string; name: string }[];
/** When true the brand picker is hidden and the admin's brand is used */
lockBrand?: boolean;
/** Hard-locked brand id (for brand_admin / store_employee) */
lockedBrandId?: string;
/** Pre-uploaded image URL (edit mode) */
initialImageUrl?: string | null;
/** Server action: upload a file and return the public URL */
onUploadImage: (file: File) => Promise<{ success: boolean; imageUrl?: string; error?: string }>;
};
export type ProductFormValues = {
name: string;
description: string;
price: string;
type: "pickup" | "wholesale" | "subscription";
brand_id: string;
active: boolean;
is_taxable: boolean;
image_url: string | null;
available_from?: string | null;
available_until?: string | null;
};
const TYPE_OPTIONS: Array<{
value: ProductFormValues["type"];
label: string;
italic: string;
icon: React.ReactNode;
}> = [
{
value: "pickup",
label: "Pickup",
italic: "Farm & stops",
icon: (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l1.5-4h15L21 9" />
<path d="M3 9v11a1 1 0 001 1h16a1 1 0 001-1V9" />
<path d="M3 9h18" />
<path d="M9 14h6" />
</svg>
),
},
{
value: "wholesale",
label: "Wholesale",
italic: "B2B portal",
icon: (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7h18l-2 12H5L3 7z" />
<path d="M8 7V5a4 4 0 018 0v2" />
</svg>
),
},
{
value: "subscription",
label: "Subscription",
italic: "Recurring",
icon: (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 11-3-6.7" />
<path d="M21 4v5h-5" />
</svg>
),
},
];
export default function ProductFormModal({
open,
mode,
onClose,
onSubmit,
initial,
brands = [],
lockBrand = false,
lockedBrandId = "",
initialImageUrl = null,
onUploadImage,
}: ProductFormModalProps) {
// Form state
const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [price, setPrice] = useState(initial?.price ?? "");
const [type, setType] = useState<ProductFormValues["type"]>(initial?.type ?? "pickup");
const [brandId, setBrandId] = useState(
initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? ""
);
const [active, setActive] = useState(initial?.active ?? true);
const [isTaxable, setIsTaxable] = useState(initial?.is_taxable ?? true);
const [availableFrom, setAvailableFrom] = useState(initial?.available_from ? initial.available_from.split('T')[0] : "");
const [availableUntil, setAvailableUntil] = useState(initial?.available_until ? initial.available_until.split('T')[0] : "");
// Image state
const [imagePreview, setImagePreview] = useState<string | null>(initialImageUrl);
const [imageUrl, setImageUrl] = useState<string | null>(initialImageUrl);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isDrag, setIsDrag] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Submit state
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Reset state when opening with new initial values
useEffect(() => {
if (!open) return;
setName(initial?.name ?? "");
setDescription(initial?.description ?? "");
setPrice(initial?.price ?? "");
setType((initial?.type as ProductFormValues["type"]) ?? "pickup");
setBrandId(initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? "");
setActive(initial?.active ?? true);
setIsTaxable(initial?.is_taxable ?? true);
setAvailableFrom(initial?.available_from ? initial.available_from.split('T')[0] : "");
setAvailableUntil(initial?.available_until ? initial.available_until.split('T')[0] : "");
setImagePreview(initialImageUrl);
setImageUrl(initialImageUrl);
setUploading(false);
setUploadError(null);
setError(null);
setSaving(false);
setIsDrag(false);
}, [open, initial, initialImageUrl, lockedBrandId, brands]);
// Body scroll lock + escape key
useEffect(() => {
if (!open) return;
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = original;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose]);
const handleFile = useCallback(
async (file: File) => {
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
setUploadError("PNG, JPEG, or WebP only.");
return;
}
if (file.size > 5 * 1024 * 1024) {
setUploadError("File must be under 5MB.");
return;
}
setUploadError(null);
setUploading(true);
// Local preview first
const localUrl = URL.createObjectURL(file);
setImagePreview(localUrl);
const result = await onUploadImage(file);
setUploading(false);
if (result.success && result.imageUrl) {
setImageUrl(result.imageUrl);
setImagePreview(result.imageUrl);
URL.revokeObjectURL(localUrl);
} else {
setUploadError(result.error ?? "Upload failed.");
setImagePreview(imageUrl); // revert
}
},
[onUploadImage, imageUrl]
);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("Product name is required.");
return;
}
if (!price || isNaN(parseFloat(price)) || parseFloat(price) < 0) {
setError("Valid price is required.");
return;
}
if (!lockBrand && !brandId) {
setError("Please select a brand.");
return;
}
setSaving(true);
const res = await onSubmit({
name: name.trim(),
description: description.trim(),
price,
type,
brand_id: lockBrand ? lockedBrandId : brandId,
active,
is_taxable: isTaxable,
image_url: imageUrl,
available_from: availableFrom ? new Date(availableFrom).toISOString() : null,
available_until: availableUntil ? new Date(availableUntil).toISOString() : null,
});
setSaving(false);
if (!res.success) {
setError(res.error ?? "Something went wrong.");
return;
}
onClose();
}
if (!mounted || !open) return null;
const showBrandPicker = !lockBrand;
const lockedBrandName = brands.find((b) => b.id === lockedBrandId)?.name ?? lockedBrandId;
return createPortal(
<div
className="fixed inset-0 z-[80] flex items-center justify-center p-3 sm:p-6 atelier-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{
backgroundColor: "rgba(28, 25, 23, 0.55)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
}}
>
<div
role="dialog"
aria-modal="true"
aria-label={mode === "add" ? "Add product" : "Edit product"}
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl shadow-[0_30px_80px_-20px_rgba(28,25,23,0.45)] flex flex-col overflow-hidden border border-stone-200/60"
>
{/* grain overlay */}
<div className="atelier-grain" aria-hidden />
{/* HEADER */}
<div className="relative shrink-0 px-6 sm:px-10 pt-6 sm:pt-8 pb-5 sm:pb-6">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2.5 mb-3">
<span className="atelier-section-num">
{mode === "add" ? "Nouveau" : "Mise à jour"} · MMXXVI
</span>
</div>
<h2 className="atelier-title text-3xl sm:text-4xl md:text-5xl">
{mode === "add" ? (
<>
Add a <span className="atelier-italic">product</span>
</>
) : (
<>
Edit <span className="atelier-italic">{initial?.name || "product"}</span>
</>
)}
</h2>
<p className="atelier-italic mt-2 text-sm sm:text-[15px] max-w-md leading-relaxed">
{mode === "add"
? "Compose a new entry for the harvest catalog — every detail, like a recipe."
: "Refine the details of this entry. The catalog is a living record."}
</p>
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 flex h-9 w-9 items-center justify-center rounded-full transition-all duration-200 hover:bg-stone-900 hover:text-white text-stone-500"
aria-label="Close"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="relative shrink-0 px-6 sm:px-10">
<hr className="atelier-rule" />
</div>
{/* BODY — two columns */}
<form
onSubmit={handleSubmit}
className="relative flex-1 overflow-y-auto"
onDragEnter={(e) => {
e.preventDefault();
setIsDrag(true);
}}
>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] gap-0">
{/* LEFT — Image hero */}
<div className="relative px-6 sm:px-10 pt-6 sm:pt-8 pb-6 lg:border-r border-stone-200/60 lg:pr-8">
<div className="atelier-section-num mb-4">01 · Media</div>
<div
onDragOver={(e) => {
e.preventDefault();
setIsDrag(true);
}}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setIsDrag(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDrag(false);
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
}}
onClick={() => !uploading && fileInputRef.current?.click()}
className={[
"atelier-drop",
isDrag ? "is-drag" : "",
uploadError ? "is-error" : "",
imagePreview ? "has-image" : "",
].join(" ")}
>
{/* status pill */}
<div
className={[
"atelier-status-pill",
imagePreview ? "" : "is-empty",
].join(" ")}
>
<span className="atelier-dot" />
{imagePreview
? uploading
? "Uploading"
: "Image set"
: "No image yet"}
</div>
{uploading ? (
<div className="flex flex-col items-center gap-3">
<div className="h-6 w-6 rounded-full border-2 border-stone-300 border-t-[#224E2F] animate-spin" />
<p className="atelier-drop-title">Pouring into the cellar</p>
</div>
) : imagePreview ? (
<div className="absolute inset-0 p-3 sm:p-4">
<div className="relative h-full w-full">
<Image
src={imagePreview}
alt="Product preview"
fill
style={{ objectFit: "contain" }}
className="rounded-md"
/>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setImagePreview(null);
setImageUrl(null);
}}
className="absolute bottom-4 left-1/2 -translate-x-1/2 inline-flex items-center gap-1.5 rounded-full bg-stone-900/85 backdrop-blur-sm px-3 py-1.5 text-[11px] font-mono uppercase tracking-wider text-white hover:bg-stone-900 transition-colors"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
Remove
</button>
</div>
) : (
<>
{/* ornamental drop zone content */}
<svg
className="h-10 w-10 text-[#224E2F]/70"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 3v13" />
<path d="M7 8l5-5 5 5" />
<path d="M4 16v3a2 2 0 002 2h12a2 2 0 002-2v-3" />
<circle cx="12" cy="20" r="0.8" fill="currentColor" />
</svg>
<p className="atelier-drop-eyebrow">Drag &amp; drop</p>
<p className="atelier-drop-title">
or click to choose<br />a harvest portrait
</p>
<p className="atelier-drop-hint">PNG · JPEG · WebP · 5MB max</p>
</>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
e.target.value = "";
}}
/>
</div>
{uploadError && (
<p className="mt-3 atelier-hint" style={{ color: "#B91C1C" }}>
{uploadError}
</p>
)}
</div>
{/* RIGHT — Form fields */}
<div className="px-6 sm:px-10 pt-6 sm:pt-8 pb-6 lg:pl-10 space-y-7 atelier-stagger">
{error && (
<div className="atelier-error">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{error}</span>
</div>
)}
{/* 02 IDENTITY */}
<section>
<div className="flex items-baseline justify-between mb-4">
<div className="atelier-section-num">02 · Identity</div>
</div>
<div>
<label
htmlFor="atelier-name"
className="atelier-section-label block mb-1"
>
Product name
</label>
<input
id="atelier-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Dozen Sweet Corn"
className="atelier-input atelier-input--display"
autoComplete="off"
/>
</div>
<div className="mt-5">
<label
htmlFor="atelier-desc"
className="atelier-section-label block mb-1"
>
Description
</label>
<textarea
id="atelier-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="A note from the field — origin, variety, picking notes…"
rows={2}
className="atelier-input resize-none"
style={{ minHeight: 56 }}
/>
</div>
</section>
{/* 03 COMMERCE */}
<section>
<div className="atelier-section-num mb-4">03 · Commerce</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label
htmlFor="atelier-price"
className="atelier-section-label block mb-1"
>
Price
</label>
<div className="relative">
<span className="atelier-price-sigil">$</span>
<input
id="atelier-price"
type="number"
step="0.01"
min="0"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="0.00"
className="atelier-price"
/>
</div>
</div>
{showBrandPicker ? (
<div>
<label
htmlFor="atelier-brand"
className="atelier-section-label block mb-1"
>
Brand
</label>
<select
id="atelier-brand"
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
className="atelier-select"
>
{brands.length === 0 && <option value="">Loading brands</option>}
{brands.length > 0 && <option value="">Select a brand</option>}
{brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
<p className="atelier-hint">
You administer multiple brands choose the one this product belongs to.
</p>
</div>
) : (
<div>
<span className="atelier-section-label block mb-1">Brand</span>
<div className="py-2.5 border-b border-stone-300/40">
<span className="font-[family-name:var(--font-fraunces)] text-[15px] text-stone-700">
{lockedBrandName || "—"}
</span>
</div>
</div>
)}
</div>
<div className="mt-6">
<span className="atelier-section-label block mb-2">Type</span>
<div className="grid grid-cols-3 gap-2.5">
{TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setType(opt.value)}
className={[
"atelier-type-card",
type === opt.value ? "is-selected" : "",
].join(" ")}
aria-pressed={type === opt.value}
>
<div className="flex items-center justify-between w-full">
<span className="atelier-type-icon">{opt.icon}</span>
<svg
className="atelier-type-check h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<div className="atelier-type-name">{opt.label}</div>
<div
className={[
"text-[10px] font-[family-name:var(--font-fragment-mono)] uppercase tracking-wider mt-0.5",
type === opt.value ? "text-white/60" : "text-stone-500",
].join(" ")}
>
{opt.italic}
</div>
</div>
</button>
))}
</div>
</div>
</section>
{/* 04 SETTINGS */}
<section>
<div className="atelier-section-num mb-4">04 · Settings</div>
<div className="flex flex-wrap items-center gap-6 sm:gap-10">
<button
type="button"
onClick={() => setActive((v) => !v)}
className={["atelier-toggle", active ? "is-on" : ""].join(" ")}
aria-pressed={active}
>
<span className="atelier-toggle-track">
<span className="atelier-toggle-thumb" />
</span>
<span className="atelier-toggle-label">Active</span>
</button>
<button
type="button"
onClick={() => setIsTaxable((v) => !v)}
className={["atelier-toggle is-gold", isTaxable ? "is-on" : ""].join(" ")}
aria-pressed={isTaxable}
>
<span className="atelier-toggle-track">
<span className="atelier-toggle-thumb" />
</span>
<span className="atelier-toggle-label">Taxable</span>
</button>
<p className="atelier-hint flex-1 min-w-[200px]">
Active products appear in the storefront. Taxable items add sales tax at checkout.
</p>
</div>
{/* Seasonal Availability */}
<div className="mt-6 pt-6 border-t border-stone-200/40">
<span className="atelier-section-label block mb-3">Seasonal Availability</span>
<p className="atelier-hint mb-4">Set when this product is available (e.g., mid-July through mid-September)</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="atelier-available-from" className="block text-[11px] font-mono uppercase tracking-wider text-stone-500 mb-1.5">Start Date</label>
<input
id="atelier-available-from"
type="date"
value={availableFrom}
onChange={(e) => setAvailableFrom(e.target.value)}
className="atelier-input"
/>
</div>
<div>
<label htmlFor="atelier-available-until" className="block text-[11px] font-mono uppercase tracking-wider text-stone-500 mb-1.5">End Date</label>
<input
id="atelier-available-until"
type="date"
value={availableUntil}
onChange={(e) => setAvailableUntil(e.target.value)}
className="atelier-input"
/>
</div>
</div>
{(availableFrom || availableUntil) && (
<button
type="button"
onClick={() => { setAvailableFrom(""); setAvailableUntil(""); }}
className="mt-2 text-[11px] font-mono uppercase tracking-wider text-stone-400 hover:text-stone-600"
>
Clear dates
</button>
)}
</div>
</section>
</div>
</div>
</form>
{/* FOOTER */}
<div className="relative shrink-0 px-6 sm:px-10 pt-4 pb-6 sm:pb-7 border-t border-stone-200/60 bg-[#FAF7F0]/80 backdrop-blur-sm">
<div className="flex items-center justify-between gap-4">
<button
type="button"
onClick={onClose}
className="atelier-discard"
>
discard changes
</button>
<div className="flex items-center gap-3">
{/* readiness indicator */}
<div className="hidden sm:flex items-center gap-2 text-[11px] font-mono uppercase tracking-wider text-stone-500">
<span
className={[
"h-1.5 w-1.5 rounded-full",
name.trim() && price
? "bg-emerald-600 shadow-[0_0_0_3px_rgba(22,163,74,0.15)]"
: "bg-stone-300",
].join(" ")}
/>
{name.trim() && price ? "Ready" : "Required fields pending"}
</div>
<button
type="submit"
form="atelier-product-form"
onClick={handleSubmit}
disabled={saving || uploading || !name.trim() || !price}
className="atelier-cta"
>
<span className="atelier-cta-shimmer" />
{saving ? (
<>
<span className="h-3.5 w-3.5 rounded-full border-2 border-white/40 border-t-white animate-spin" />
{mode === "add" ? "Composing…" : "Saving…"}
</>
) : (
<>
{mode === "add" ? "Add to catalog" : "Save changes"}
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
);
}
+209 -382
View File
@@ -1,13 +1,14 @@
"use client";
import { useState, useCallback, useRef, useTransition } from "react";
import { useState, useCallback, useTransition, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { createProduct } from "@/actions/products/create-product";
import { updateProduct } from "@/actions/products/update-product";
import { deleteProduct } from "@/actions/products";
import { uploadProductImage } from "@/actions/products/upload-image";
import GlassModal from "@/components/admin/GlassModal";
import ProductFormModal, { type ProductFormValues } from "@/components/admin/ProductFormModal";
import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, useToast, Skeleton } from "@/components/admin/design-system";
type Product = {
@@ -20,16 +21,8 @@ type Product = {
image_url?: string | null;
brand_id: string;
is_taxable: boolean;
};
type FormData = {
name: string;
description: string;
price: string;
type: string;
active: boolean;
image_url: string;
is_taxable: boolean;
available_from?: string | null;
available_until?: string | null;
};
type ViewMode = "table" | "cards";
@@ -74,7 +67,17 @@ const PackageIconHeader = () => (
</svg>
);
export default function ProductsClient({ products, brandId }: { products: Product[]; brandId?: string }) {
export default function ProductsClient({
products,
brandId,
brands = [],
isPlatformAdmin = false,
}: {
products: Product[];
brandId?: string;
brands?: { id: string; name: string }[];
isPlatformAdmin?: boolean;
}) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
@@ -83,28 +86,10 @@ export default function ProductsClient({ products, brandId }: { products: Produc
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [showModal, setShowModal] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [formData, setFormData] = useState<FormData>({
name: "",
description: "",
price: "",
type: "pickup",
active: true,
image_url: "",
is_taxable: true,
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Image upload states
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [pendingImageUrl, setPendingImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const filtered = products.filter((p) => {
const matchesSearch =
!search ||
@@ -147,155 +132,104 @@ export default function ProductsClient({ products, brandId }: { products: Produc
});
}
async function handleFileSelect(file: File) {
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
return;
}
if (file.size > 5 * 1024 * 1024) {
setUploadError("Image must be under 5MB.");
return;
}
const handleUploadImage = useCallback(
async (file: File): Promise<{ success: boolean; imageUrl?: string; error?: string }> => {
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
return { success: false, error: "Only PNG, JPEG, and WebP images are allowed." };
}
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: "Image must be under 5MB." };
}
setUploadError(null);
setUploading(true);
const resizedBuffer = await resizeImage(file, 1200);
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
const resizedBuffer = await resizeImage(file, 1200);
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
const result = await uploadProductImage(productIdForUpload, resizedFile);
setUploading(false);
if (result.success) {
setPendingImageUrl(result.imageUrl);
setImagePreview(result.imageUrl);
setFormData({ ...formData, image_url: result.imageUrl });
} else {
setUploadError(result.error ?? "Upload failed.");
}
}
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
const result = await uploadProductImage(productIdForUpload, resizedFile);
if (result.success) {
return { success: true, imageUrl: result.imageUrl };
}
return { success: false, error: result.error };
},
[editingProduct]
);
const openAddModal = useCallback(() => {
setEditingProduct(null);
setFormData({
name: "",
description: "",
price: "",
type: "pickup",
active: true,
image_url: "",
is_taxable: true,
});
setImagePreview(null);
setPendingImageUrl(null);
setUploadError(null);
setError(null);
setShowModal(true);
}, []);
const openEditModal = useCallback((product: Product) => {
setEditingProduct(product);
setFormData({
name: product.name,
description: product.description || "",
price: String(product.price),
type: product.type,
active: product.active,
image_url: product.image_url || "",
is_taxable: product.is_taxable,
});
setImagePreview(product.image_url || null);
setPendingImageUrl(null);
setUploadError(null);
setError(null);
setShowModal(true);
}, []);
const closeModal = useCallback(() => {
setShowModal(false);
setEditingProduct(null);
setImagePreview(null);
setPendingImageUrl(null);
setUploadError(null);
setError(null);
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSaving(true);
setIsLoading(true);
const handleModalSubmit = useCallback(
async (values: ProductFormValues): Promise<{ success: boolean; error?: string }> => {
setIsLoading(true);
try {
const price = parseFloat(values.price);
if (isNaN(price) || price < 0) {
return { success: false, error: "Please enter a valid price" };
}
if (!values.name.trim()) {
return { success: false, error: "Product name is required" };
}
const price = parseFloat(formData.price);
if (isNaN(price) || price < 0) {
setError("Please enter a valid price");
setSaving(false);
setIsLoading(false);
return;
}
// Platform admins pick a brand in the modal; everyone else is locked.
const effectiveBrandId = isPlatformAdmin ? values.brand_id : brandId;
if (!effectiveBrandId) {
return { success: false, error: "Please choose a brand for this product." };
}
if (!formData.name.trim()) {
setError("Product name is required");
setSaving(false);
setIsLoading(false);
return;
}
let result;
if (editingProduct) {
result = await updateProduct(editingProduct.id, effectiveBrandId, {
name: values.name.trim(),
description: values.description.trim(),
price,
type: values.type,
active: values.active,
image_url: values.image_url,
is_taxable: values.is_taxable,
});
} else {
result = await createProduct(effectiveBrandId, {
name: values.name.trim(),
description: values.description.trim(),
price,
type: values.type,
active: values.active,
image_url: values.image_url,
is_taxable: values.is_taxable,
});
}
try {
if (!brandId) {
setError("Brand ID is required");
setSaving(false);
if (!result.success) {
showError("Failed to save product", result.error ?? "Please try again");
return { success: false, error: result.error };
}
showSuccess(
editingProduct ? "Product updated" : "Product created",
`${values.name} has been saved`
);
startTransition(() => router.refresh());
return { success: true };
} catch {
return { success: false, error: "Network error. Please try again." };
} finally {
setIsLoading(false);
return;
}
let result;
const imageUrl = pendingImageUrl || formData.image_url || null;
if (editingProduct) {
result = await updateProduct(editingProduct.id, brandId, {
name: formData.name.trim(),
description: formData.description.trim(),
price,
type: formData.type,
active: formData.active,
image_url: imageUrl,
is_taxable: formData.is_taxable,
});
} else {
result = await createProduct(brandId, {
name: formData.name.trim(),
description: formData.description.trim(),
price,
type: formData.type,
active: formData.active,
image_url: imageUrl,
is_taxable: formData.is_taxable,
});
}
if (!result.success) {
setError(result.error ?? "Failed to save product");
showError("Failed to save product", result.error ?? "Please try again");
setSaving(false);
setIsLoading(false);
return;
}
showSuccess(editingProduct ? "Product updated" : "Product created", `${formData.name} has been saved`);
closeModal();
setIsLoading(false);
startTransition(() => router.refresh());
} catch {
setError("Network error. Please try again.");
showError("Network error", "Please check your connection and try again");
setSaving(false);
setIsLoading(false);
}
};
},
[editingProduct, brandId, isPlatformAdmin, router, showError, showSuccess]
);
const handleDelete = async (productId: string) => {
setDeletingId(productId);
@@ -399,203 +333,38 @@ export default function ProductsClient({ products, brandId }: { products: Produc
/>
)}
{/* Modal */}
{showModal && (
<GlassModal
title={editingProduct ? "Edit Product" : "Add Product"}
subtitle={editingProduct ? `Editing ${editingProduct.name}` : "Create a new product"}
onClose={closeModal}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600 flex items-start gap-3">
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
)}
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Product name"
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] transition-colors ${
error && !formData.name.trim()
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Product description"
rows={2}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
Price <span className="text-red-500">*</span>
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
<input
type="number"
step="0.01"
min="0"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
placeholder="0.00"
className={`w-full rounded-xl border pl-8 pr-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
error && (!formData.price || parseFloat(formData.price) < 0)
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
required
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Type</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
>
<option value="pickup">Pickup</option>
<option value="wholesale">Wholesale</option>
<option value="subscription">Subscription</option>
</select>
</div>
</div>
{/* Image Upload */}
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Product Image</label>
<p className="text-[10px] text-stone-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB</p>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}}
onClick={() => !uploading && fileInputRef.current?.click()}
className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${uploadError ? "border-red-400" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)] hover:bg-[var(--admin-accent)]/5"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
<span className="text-sm text-stone-500">Uploading...</span>
</>
) : imagePreview ? (
<div className="relative">
<div className="relative h-32 sm:h-40 w-auto">
<Image src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</div>
<span className="block text-center text-xs text-stone-500 mt-2">Click or drop to replace</span>
</div>
) : (
<>
{Icons.upload("h-8 w-8 text-stone-400")}
<span className="text-sm text-stone-500">Drag & drop or click to upload</span>
</>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
e.target.value = "";
}}
/>
</div>
{uploadError && (
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
)}
{(imagePreview || formData.image_url) && (
<button
type="button"
onClick={() => {
setImagePreview(null);
setPendingImageUrl(null);
setFormData({ ...formData, image_url: "" });
}}
className="mt-2 text-xs text-red-500 hover:underline"
>
Remove image
</button>
)}
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
/>
<span className="text-sm font-medium text-stone-700">Active</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.is_taxable}
onChange={(e) => setFormData({ ...formData, is_taxable: e.target.checked })}
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
/>
<span className="text-sm font-medium text-stone-700">Taxable</span>
</label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-stone-100">
<AdminButton
type="button"
variant="secondary"
onClick={closeModal}
>
Cancel
</AdminButton>
<AdminButton
type="submit"
variant="primary"
isLoading={saving}
disabled={!formData.name.trim() || !formData.price}
>
{editingProduct ? "Save Changes" : "Create Product"}
</AdminButton>
</div>
</form>
</GlassModal>
)}
{/* Modal — Atelier des Récoltes (editorial product form) */}
<ProductFormModal
open={showModal}
mode={editingProduct ? "edit" : "add"}
onClose={closeModal}
onSubmit={handleModalSubmit}
onUploadImage={handleUploadImage}
brands={brands}
lockBrand={!isPlatformAdmin}
lockedBrandId={brandId ?? ""}
initial={
editingProduct
? {
name: editingProduct.name,
description: editingProduct.description || "",
price: String(editingProduct.price),
type: (editingProduct.type as "pickup" | "wholesale" | "subscription") ?? "pickup",
brand_id: editingProduct.brand_id,
active: editingProduct.active,
is_taxable: editingProduct.is_taxable,
available_from: editingProduct.available_from ?? null,
available_until: editingProduct.available_until ?? null,
}
: undefined
}
initialImageUrl={editingProduct?.image_url ?? null}
/>
</div>
);
}
// ── Table View ─────────────────────────────────────────────────────────────────
function TableView({
@@ -615,8 +384,51 @@ function TableView({
onDeleteCancel: () => void;
deletingId: string | null;
}) {
// Track the position of the open row's three-dots button so the popup can be
// rendered via portal at body level (escapes any overflow:hidden ancestors
// and any table-row stacking-context quirks).
const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null);
const [mounted, setMounted] = useState(false);
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!deleteConfirm) {
setMenuPos(null);
return;
}
const btn = buttonRefs.current[deleteConfirm];
if (btn) {
const rect = btn.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
}
}, [deleteConfirm]);
// Reposition on scroll/resize so the popup stays anchored to its button.
useEffect(() => {
if (!deleteConfirm) return;
const updatePos = () => {
const btn = buttonRefs.current[deleteConfirm];
if (btn) {
const rect = btn.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
}
};
window.addEventListener("scroll", updatePos, true);
window.addEventListener("resize", updatePos);
return () => {
window.removeEventListener("scroll", updatePos, true);
window.removeEventListener("resize", updatePos);
};
}, [deleteConfirm]);
const openProduct = deleteConfirm ? products.find((p) => p.id === deleteConfirm) : null;
return (
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-white">
<div className="overflow-visible rounded-xl border border-[var(--admin-border)] bg-white">
<table className="w-full text-sm">
<thead className="bg-stone-50">
<tr>
@@ -700,44 +512,15 @@ function TableView({
Edit
</AdminButton>
<button
ref={(el) => { buttonRefs.current[product.id] = el; }}
onClick={() => onDelete(product.id)}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
aria-label="Product actions"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
{deleteConfirm === product.id && (
<>
<div className="fixed inset-0 z-30" onClick={onDeleteCancel} />
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-1 text-xs text-stone-500">
This will remove the product. If attached to orders, it will be hidden.
</p>
<div className="mt-3 flex gap-2">
<button
onClick={onDeleteCancel}
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<AdminButton
onClick={() => onDeleteConfirm(product.id)}
disabled={deletingId === product.id}
variant="danger"
size="sm"
className="flex-1"
>
{deletingId === product.id ? "..." : "Delete"}
</AdminButton>
</div>
</div>
</>
)}
</div>
</td>
</tr>
@@ -745,6 +528,50 @@ function TableView({
)}
</tbody>
</table>
{/* Delete confirmation popup — rendered via portal at body level with
position: fixed so it sits above all table rows and escapes the
overflow-visible container. */}
{mounted && deleteConfirm && openProduct && menuPos &&
createPortal(
<>
<div
className="fixed inset-0 z-[60]"
onClick={onDeleteCancel}
/>
<div
role="dialog"
aria-label="Confirm delete"
className="fixed z-[70] w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4"
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
>
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{openProduct.name}&quot;?
</p>
<p className="mt-1 text-xs text-stone-500">
This will remove the product. If attached to orders, it will be hidden.
</p>
<div className="mt-3 flex gap-2">
<button
onClick={onDeleteCancel}
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<AdminButton
onClick={() => onDeleteConfirm(openProduct.id)}
disabled={deletingId === openProduct.id}
variant="danger"
size="sm"
className="flex-1"
>
{deletingId === openProduct.id ? "..." : "Delete"}
</AdminButton>
</div>
</div>
</>,
document.body
)}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
type Stat = {
label: string;
value: number | string;
emphasis?: boolean;
};
type Props = {
stats: Stat[];
/** Right-aligned slot (e.g. an inline "Next stop" pill or refresh action) */
right?: React.ReactNode;
};
export default function StatsStrip({ stats, right }: Props) {
return (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
{stats.map((s, i) => (
<span key={i} className="flex items-baseline gap-1.5">
<span
className={`font-bold tabular-nums ${s.emphasis ? "text-emerald-700" : "text-[var(--admin-text-primary)]"}`}
>
{s.value}
</span>
<span className="text-[var(--admin-text-muted)]">{s.label}</span>
</span>
))}
</div>
{right && <div className="ml-auto">{right}</div>}
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import GlassModal from "@/components/admin/GlassModal";
import StopEditForm from "@/components/admin/StopEditForm";
import StopProductAssignment from "@/components/admin/StopProductAssignment";
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
import { getStopDetails } from "@/actions/stops/get-stop-details";
import { useToast } from "@/components/admin/design-system";
import type { StopDetail, AssignedProduct } from "@/actions/stops/get-stop-details";
type Tab = "details" | "products" | "message";
type Props = {
stopId: string;
/** Optional: when the user clicks Duplicate from inside the modal. */
onDuplicate?: (stopId: string) => void;
onClose: () => void;
};
export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props) {
const router = useRouter();
const { success: showSuccess } = useToast();
const [, startTransition] = useTransition();
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [stop, setStop] = useState<StopDetail | null>(null);
const [allProducts, setAllProducts] = useState<{ id: string; name: string; type: string; price: number }[]>([]);
const [assignedProducts, setAssignedProducts] = useState<AssignedProduct[]>([]);
const [brands, setBrands] = useState<{ id: string; name: string; slug: string }[]>([]);
const [callerUid, setCallerUid] = useState<string>("");
const [tab, setTab] = useState<Tab>("details");
useEffect(() => {
let cancelled = false;
setLoading(true);
setLoadError(null);
getStopDetails(stopId)
.then((res) => {
if (cancelled) return;
if (!res.success) {
setLoadError(res.error);
setLoading(false);
return;
}
setStop(res.stop);
setAllProducts(res.allProducts);
setAssignedProducts(res.assignedProducts);
setBrands(res.brands);
setCallerUid(res.callerUid);
setLoading(false);
})
.catch((err) => {
if (cancelled) return;
setLoadError(err?.message ?? "Failed to load stop");
setLoading(false);
});
return () => {
cancelled = true;
};
}, [stopId]);
function refresh() {
getStopDetails(stopId).then((res) => {
if (!res.success) return;
setStop(res.stop);
setAllProducts(res.allProducts);
setAssignedProducts(res.assignedProducts);
setBrands(res.brands);
setCallerUid(res.callerUid);
});
}
function handleEditSaved() {
refresh();
showSuccess("Stop updated", "Changes have been saved");
startTransition(() => router.refresh());
}
const subtitle = stop?.brands
? (Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands.name) ?? undefined
: undefined;
return (
<GlassModal
title={loading ? "Loading…" : stop ? `${stop.city}, ${stop.state}` : "Stop"}
subtitle={subtitle ?? "Stop details"}
onClose={onClose}
maxWidth="max-w-3xl"
>
{loading ? (
<div className="space-y-3">
<div className="h-4 w-1/3 animate-pulse rounded bg-stone-200" />
<div className="h-4 w-2/3 animate-pulse rounded bg-stone-200" />
<div className="h-4 w-1/2 animate-pulse rounded bg-stone-200" />
</div>
) : loadError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{loadError}
</div>
) : stop ? (
<div className="space-y-5">
{/* Tabs */}
<div
className="flex items-center gap-1 rounded-xl border border-[var(--admin-border)] bg-stone-50 p-1"
role="tablist"
>
<TabButton active={tab === "details"} onClick={() => setTab("details")}>
Details
</TabButton>
<TabButton active={tab === "products"} onClick={() => setTab("products")}>
<span className="inline-flex items-center gap-1.5">
Products
{assignedProducts.length > 0 && (
<span className="rounded-full bg-[var(--admin-accent)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--admin-accent)]">
{assignedProducts.length}
</span>
)}
</span>
</TabButton>
<TabButton active={tab === "message"} onClick={() => setTab("message")}>
Message
</TabButton>
</div>
{tab === "details" && (
<DetailsPanel
stop={stop}
brands={brands}
onDuplicate={onDuplicate}
onSaved={handleEditSaved}
/>
)}
{tab === "products" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
Assigned Products
</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Manage which products are available at this stop.
</p>
<div className="mt-4">
<StopProductAssignment
stopId={stop.id}
allProducts={allProducts}
assignedProducts={assignedProducts}
callerUid={callerUid}
/>
</div>
</div>
)}
{tab === "message" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">
Message Customers
</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Send updates to customers with pending pickups at this stop.
</p>
<div className="mt-4">
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
</div>
</div>
)}
</div>
) : null}
</GlassModal>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all ${
active
? "bg-white text-[var(--admin-accent)] shadow-sm border border-[var(--admin-border)]"
: "text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)]"
}`}
>
{children}
</button>
);
}
function DetailsPanel({
stop,
brands,
onDuplicate,
onSaved,
}: {
stop: StopDetail;
brands: { id: string; name: string; slug: string }[];
onDuplicate?: (stopId: string) => void;
onSaved: () => void;
}) {
return (
<div className="space-y-5">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">
{stop.city}, {stop.state}
</h3>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">{stop.location}</p>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs font-medium ${
stop.active
? "bg-emerald-100 text-emerald-700"
: "bg-stone-200 text-stone-500"
}`}
>
{stop.active ? "Active" : "Inactive"}
</span>
</div>
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Date</dt>
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.date}</dd>
</div>
<div>
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Time</dt>
<dd className="mt-0.5 font-mono text-[var(--admin-text-primary)]">{stop.time}</dd>
</div>
{stop.address && (
<div className="col-span-2">
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Address</dt>
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
{stop.address}
{stop.zip ? `, ${stop.zip}` : ""}
</dd>
</div>
)}
{stop.cutoff_time && (
<div className="col-span-2">
<dt className="text-xs font-medium text-[var(--admin-text-muted)]">Cutoff</dt>
<dd className="mt-0.5 text-[var(--admin-text-primary)]">
{new Date(stop.cutoff_time).toLocaleString()}
</dd>
</div>
)}
</dl>
</div>
<div className="flex justify-end">
{onDuplicate ? (
<button
type="button"
onClick={() => onDuplicate(stop.id)}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
>
Duplicate Stop
</button>
) : (
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
className="rounded-xl border border-[var(--admin-border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
>
Duplicate Stop
</a>
)}
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Edit Stop</h3>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Update stop details, location, and availability.
</p>
<div className="mt-4">
<StopEditForm
stop={{
id: stop.id,
city: stop.city,
state: stop.state,
date: stop.date,
time: stop.time,
location: stop.location,
slug: stop.slug,
active: stop.active,
brand_id: stop.brand_id,
address: stop.address,
zip: stop.zip,
cutoff_time: stop.cutoff_time,
}}
brands={brands}
onSaved={onSaved}
/>
</div>
</div>
</div>
);
}
+4 -1
View File
@@ -27,9 +27,11 @@ type StopEditFormProps = {
cutoff_time?: string | null;
};
brands: Brand[];
/** Optional callback fired after a successful save. */
onSaved?: () => void;
};
export default function StopEditForm({ stop, brands }: StopEditFormProps) {
export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProps) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [saving, setSaving] = useState(false);
@@ -99,6 +101,7 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) {
showSuccess("Stop updated", `${city}, ${state} has been saved`);
setSaved(true);
setSaving(false);
onSaved?.();
router.refresh();
}
+330 -159
View File
@@ -1,11 +1,21 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import React, { useState, useTransition, useEffect } from "react";
import React, { useState, useTransition, useEffect, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop } from "@/actions/stops";
import { AdminSearchInput, AdminFilterTabs, AdminButton, AdminIconButton, useToast, Skeleton } from "@/components/admin/design-system";
import {
AdminSearchInput,
AdminFilterTabs,
AdminButton,
AdminIconButton,
useToast,
Skeleton,
} from "@/components/admin/design-system";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal";
import StopDetailModal from "@/components/admin/StopDetailModal";
type Stop = {
id: string;
@@ -18,14 +28,27 @@ type Stop = {
deleted_at?: string | null;
brand_id: string;
status?: string;
brands: { name: string } | { name: string }[];
address?: string | null;
brands: { name: string } | { name: string }[] | null;
};
type Props = {
stops: Stop[];
/**
* Hide the internal search/filter bar at the top of the table. Use when
* the parent component already renders shared filters (e.g. StopsViewClient).
*/
hideInternalFilterBar?: boolean;
};
export default function StopTableClient({ stops }: Props) {
const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = {
all: "all",
active: "active",
inactive: "inactive",
draft: "draft",
};
export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
@@ -36,57 +59,66 @@ export default function StopTableClient({ stops }: Props) {
const [isLoading, setIsLoading] = useState(false);
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
const [bulkPublishing, setBulkPublishing] = useState(false);
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [openStopId, setOpenStopId] = useState<string | null>(null);
const PAGE_SIZE = 50;
// Simulate loading when filters change
useEffect(() => {
setIsLoading(true);
const timer = setTimeout(() => setIsLoading(false), 300);
const timer = setTimeout(() => setIsLoading(false), 220);
return () => clearTimeout(timer);
}, [page, statusFilter, search]);
const filtered = stops.filter((s) => {
const matchesSearch =
!search ||
s.city.toLowerCase().includes(search.toLowerCase()) ||
s.location.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && s.status !== "draft") ||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
(statusFilter === "draft" && s.status === "draft");
return matchesSearch && matchesStatus;
});
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return stops.filter((s) => {
const matchesSearch =
!q ||
s.city.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q);
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && s.status !== "draft") ||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
(statusFilter === "draft" && s.status === "draft");
return matchesSearch && matchesStatus;
});
}, [stops, search, statusFilter]);
const statusCounts = {
all: stops.length,
active: stops.filter(s => s.active && s.status !== "draft").length,
inactive: stops.filter(s => !s.active && s.status !== "draft").length,
draft: stops.filter(s => s.status === "draft").length,
};
const statusCounts = useMemo(
() => ({
all: stops.length,
active: stops.filter((s) => s.active && s.status !== "draft").length,
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
draft: stops.filter((s) => s.status === "draft").length,
}),
[stops]
);
const tabs = [
{ value: "all", label: "All", count: statusCounts.all },
{ value: "active", label: "Active", count: statusCounts.active },
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
{ value: "draft", label: "Draft", count: statusCounts.draft },
];
const tabs = useMemo(
() => [
{ value: "all", label: "All", count: statusCounts.all },
{ value: "active", label: "Active", count: statusCounts.active },
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
{ value: "draft", label: "Draft", count: statusCounts.draft },
],
[statusCounts]
);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const draftStops = stops.filter(s => s.status === "draft" && s.active);
// Bulk selection
const toggleSelectAll = () => {
if (selectedStops.size === paginatedStops.length) {
setSelectedStops(new Set());
} else {
setSelectedStops(new Set(paginatedStops.map(s => s.id)));
setSelectedStops(new Set(paginatedStops.map((s) => s.id)));
}
};
const toggleStopSelection = (stopId: string) => {
setSelectedStops(prev => {
setSelectedStops((prev) => {
const next = new Set(prev);
if (next.has(stopId)) {
next.delete(stopId);
@@ -99,13 +131,11 @@ export default function StopTableClient({ stops }: Props) {
async function handleBulkPublish() {
if (selectedStops.size === 0) return;
setBulkPublishing(true);
let successCount = 0;
let failCount = 0;
for (const stopId of selectedStops) {
const stop = stops.find(s => s.id === stopId);
const stop = stops.find((s) => s.id === stopId);
if (stop && stop.status === "draft") {
const result = await publishStop(stopId, stop.brand_id);
if (result.success) {
@@ -115,83 +145,125 @@ export default function StopTableClient({ stops }: Props) {
}
}
}
setBulkPublishing(false);
setSelectedStops(new Set());
if (failCount === 0) {
showSuccess(`${successCount} stop${successCount !== 1 ? 's' : ''} published`);
showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
} else {
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
}
startTransition(() => router.refresh());
}
function handleDeleted() {
setDeleteError(null);
startTransition(() => {
router.refresh();
});
startTransition(() => router.refresh());
}
function refresh() {
startTransition(() => router.refresh());
}
return (
<>
{/* Filter bar */}
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
{!hideInternalFilterBar && (
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3 border-b border-[var(--admin-border)]">
<AdminSearchInput
placeholder="Search stops..."
placeholder="Search by city or venue…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
onClear={() => { setSearch(""); setPage(0); }}
showClear={true}
className="flex-1 min-w-48 max-w-64"
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
onClear={() => {
setSearch("");
setPage(0);
}}
showClear
className="flex-1 min-w-[180px] max-w-[280px]"
/>
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(0); }}
onTabChange={(value) => {
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
setPage(0);
}}
tabs={tabs}
size="sm"
showCounts={true}
showCounts
/>
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">{filtered.length} stops</span>
<span className="text-xs text-[var(--admin-text-muted)] ml-auto tabular-nums">
{filtered.length} stop{filtered.length !== 1 ? "s" : ""}
</span>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<AdminIconButton
variant="secondary"
size="sm"
label="Previous page"
onClick={() => setPage(p => Math.max(0, p - 1))}
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</AdminIconButton>
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
<span className="text-xs text-[var(--admin-text-muted)] px-1 tabular-nums">
{page + 1}/{totalPages}
</span>
<AdminIconButton
variant="secondary"
size="sm"
label="Next page"
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1 || isLoading}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</AdminIconButton>
</div>
)}
<AdminButton
variant="secondary"
size="sm"
onClick={() => setShowImport(true)}
icon={
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
}
>
Upload Schedule
</AdminButton>
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAdd(true)}
icon={
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
}
>
Add Stop
</AdminButton>
</div>
)}
{/* Bulk actions bar */}
{selectedStops.size > 0 && (
<div className="mx-5 my-3 flex items-center justify-between rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-4 py-3">
<div className="mx-4 my-3 flex items-center justify-between rounded-xl border border-emerald-200 bg-emerald-50/60 px-4 py-2.5">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-[var(--admin-accent-text)]">
{selectedStops.size} stop{selectedStops.size !== 1 ? 's' : ''} selected
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
</span>
<button
onClick={() => setSelectedStops(new Set())}
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]"
className="text-xs text-stone-500 hover:text-stone-700"
>
Clear
</button>
@@ -209,7 +281,7 @@ export default function StopTableClient({ stops }: Props) {
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
<div className="mx-4 my-3 rounded-lg border border-red-500/30 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
@@ -219,45 +291,59 @@ export default function StopTableClient({ stops }: Props) {
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<thead className="bg-[var(--admin-bg-subtle)]/40 text-[var(--admin-text-muted)] text-xs uppercase tracking-wide">
<tr>
<th className="w-10 px-5 py-4">
<th className="w-10 px-4 py-3">
<input
type="checkbox"
checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0}
onChange={toggleSelectAll}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
aria-label="Select all"
/>
</th>
<th className="px-5 py-4 font-semibold">City</th>
<th className="px-5 py-4 font-semibold">Location</th>
<th className="px-5 py-4 font-semibold">Date</th>
<th className="px-5 py-4 font-semibold">Time</th>
<th className="px-5 py-4 font-semibold">Brand</th>
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold" />
<th className="px-4 py-3 font-semibold">When</th>
<th className="px-4 py-3 font-semibold">Where</th>
<th className="px-4 py-3 font-semibold">Venue</th>
<th className="px-4 py-3 font-semibold">Status</th>
<th className="w-12 px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4"><Skeleton variant="rect" className="h-5 w-5" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-24 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="text" className="w-20 h-4" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
<tr key={i}>
<td className="px-4 py-3.5"><Skeleton variant="rect" className="h-4 w-4" /></td>
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-24 h-4" /></td>
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-32 h-4" /></td>
<td className="px-4 py-3.5"><Skeleton variant="text" className="w-40 h-4" /></td>
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-16 h-5 rounded-full" /></td>
<td className="px-4 py-3.5"><Skeleton variant="rect" className="w-6 h-6" /></td>
</tr>
))
) : filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
{search || statusFilter !== "all"
? "No stops match your search."
: "No stops found. Create one to get started."}
<td colSpan={6} className="px-4 py-14 text-center">
<div className="flex flex-col items-center gap-3 text-[var(--admin-text-muted)]">
<div className="h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="text-sm font-semibold text-stone-700">
{search || statusFilter !== "all"
? "No stops match your filters"
: "No stops yet"}
</p>
<p className="mt-0.5 text-xs text-stone-500">
{search || statusFilter !== "all"
? "Try a different search or clear the filters above."
: "Use the buttons above to upload a schedule or add your first stop."}
</p>
</div>
</div>
</td>
</tr>
) : (
@@ -268,52 +354,101 @@ export default function StopTableClient({ stops }: Props) {
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
isSelected={selectedStops.has(stop.id)}
onToggleSelect={() => toggleStopSelection(stop.id)}
onToggleSelect={(e) => {
e.stopPropagation();
toggleStopSelection(stop.id);
}}
onOpen={() => setOpenStopId(stop.id)}
/>
))
)}
</tbody>
</table>
{showImport && (
<ScheduleImportModal
brandId={stops[0]?.brand_id ?? ""}
onClose={() => setShowImport(false)}
onComplete={refresh}
/>
)}
<AddStopModal
isOpen={showAdd}
onClose={() => setShowAdd(false)}
brandId={stops[0]?.brand_id ?? ""}
onSuccess={refresh}
/>
{openStopId && (
<StopDetailModal
stopId={openStopId}
onClose={() => setOpenStopId(null)}
/>
)}
</>
);
}
function formatDate(iso: string): { date: string; weekday: string } {
// Stop date is stored as YYYY-MM-DD; parse without TZ shift
const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10));
if (!y || !m || !d) return { date: iso, weekday: "" };
const dt = new Date(y, m - 1, d);
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
const month = dt.toLocaleDateString("en-US", { month: "short" });
return { date: `${month} ${d}`, weekday };
}
function formatTime(t: string): string {
// Stored as HH:MM (24h). Display as 10:00 AM.
if (!t) return "";
const [hStr, mStr] = t.split(":");
const h = parseInt(hStr, 10);
if (Number.isNaN(h)) return t;
const period = h >= 12 ? "PM" : "AM";
const hh = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${hh}:${mStr} ${period}`;
}
function StopRowBase({
stop,
onDeleted,
onDeleteError,
isSelected,
onToggleSelect,
onOpen,
}: {
stop: Stop;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
isSelected: boolean;
onToggleSelect: () => void;
onToggleSelect: (e: React.MouseEvent) => void;
onOpen: () => void;
}) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
async function handlePublish(stopId: string) {
setPublishingId(stopId);
setOpenMenu(null);
const result = await publishStop(stopId, stop.brand_id);
const { date, weekday } = formatDate(stop.date);
const time = formatTime(stop.time);
async function handlePublish() {
setPublishingId(stop.id);
const result = await publishStop(stop.id, stop.brand_id);
setPublishingId(null);
if (result.success) {
showSuccess("Stop published", "The stop is now visible on the storefront");
startTransition(() => router.refresh());
onDeleted();
} else {
showError("Failed to publish", result.error ?? "Please try again");
}
}
async function handleDelete(stopId: string) {
setDeletingId(stopId);
async function handleDelete() {
setDeletingId(stop.id);
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/delete_stop`,
{
@@ -322,7 +457,7 @@ function StopRowBase({
apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: stop.brand_id }),
body: JSON.stringify({ p_stop_id: stop.id, p_brand_id: stop.brand_id }),
}
);
const data = await res.json();
@@ -338,69 +473,88 @@ function StopRowBase({
}
return (
<tr className={`hover:bg-[var(--admin-bg-subtle)] transition-colors relative ${isSelected ? 'bg-[var(--admin-accent-light)]/30' : ''}`}>
<td className="px-5 py-4">
<tr
onClick={onOpen}
className={`group cursor-pointer transition-colors ${isSelected ? "bg-emerald-50/60" : "hover:bg-[var(--admin-bg-subtle)]/60"}`}
>
<td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={onToggleSelect}
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
onChange={() => {}}
onClick={onToggleSelect}
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
aria-label={`Select stop in ${stop.city}`}
/>
</td>
<td className="px-5 py-4">
<Link
href={`/admin/stops/${stop.id}`}
className="font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] transition-colors"
>
{stop.city}, {stop.state}
</Link>
<td className="px-4 py-3.5">
<div className="flex flex-col leading-tight">
<span className="font-semibold text-[var(--admin-text-primary)] tabular-nums">{date}</span>
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">
{weekday} {time && `· ${time}`}
</span>
</div>
</td>
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">{stop.location}</td>
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.date}</td>
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.time}</td>
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">
{Array.isArray(stop.brands)
? stop.brands[0]?.name
: stop.brands?.name}
<td className="px-4 py-3.5">
<div className="flex flex-col leading-tight">
<span className="font-semibold text-[var(--admin-text-primary)]">{stop.city}</span>
<span className="text-[11px] text-[var(--admin-text-muted)] tabular-nums">{stop.state}</span>
</div>
</td>
<td className="px-5 py-4">
<td className="px-4 py-3.5">
<div className="flex flex-col leading-tight">
<span className="text-[var(--admin-text-secondary)]">{stop.location}</span>
{stop.address && (
<span className="text-[11px] text-[var(--admin-text-muted)] truncate max-w-[260px]">
{stop.address}
</span>
)}
</div>
</td>
<td className="px-4 py-3.5">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ${
stop.status === "draft"
? "bg-amber-100 text-amber-700 border border-amber-200"
? "bg-amber-50 text-amber-700 border border-amber-200"
: stop.active
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-500 border border-stone-200"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
stop.status === "draft"
? "bg-amber-500"
: stop.active
? "bg-emerald-500"
: "bg-stone-400"
}`}
/>
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/stops/${stop.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
Edit
</Link>
<td className="px-4 py-3.5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="relative inline-flex items-center justify-end gap-1">
<AdminIconButton
variant="ghost"
size="sm"
label="More options"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpenMenu(openMenu === stop.id ? null : stop.id);
}}
className="opacity-60 group-hover:opacity-100"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
<circle cx="10" cy="4" r="1.5" />
<circle cx="10" cy="10" r="1.5" />
<circle cx="10" cy="16" r="1.5" />
</svg>
</AdminIconButton>
@@ -408,36 +562,42 @@ function StopRowBase({
<>
<div
className="fixed inset-0 z-10"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
onClick={(e) => {
e.stopPropagation();
setOpenMenu(null);
setConfirmDelete(null);
}}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
{stop.status === "draft" && (
<AdminButton
variant="ghost"
size="sm"
fullWidth
onClick={() => handlePublish(stop.id)}
<button
onClick={(e) => {
e.stopPropagation();
handlePublish();
}}
disabled={publishingId === stop.id}
className="!justify-start !text-[var(--admin-accent)] hover:!bg-[var(--admin-accent-light)]"
className="w-full text-left px-4 py-2.5 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors disabled:opacity-50"
>
{publishingId === stop.id ? "Publishing..." : "Publish"}
</AdminButton>
{publishingId === stop.id ? "Publishing" : "Publish"}
</button>
)}
<a
<Link
href={`/admin/stops/new?duplicate=${stop.id}`}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
onClick={(e) => e.stopPropagation()}
className="flex items-center px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
Duplicate
</a>
<AdminButton
variant="ghost"
size="sm"
fullWidth
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
className="!justify-start !text-[var(--admin-danger)] hover:!bg-[var(--admin-danger)]/10"
</Link>
<button
onClick={(e) => {
e.stopPropagation();
setOpenMenu(null);
setConfirmDelete(stop.id);
}}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</AdminButton>
</button>
</div>
</>
)}
@@ -446,7 +606,11 @@ function StopRowBase({
<>
<div
className="fixed inset-0 z-30"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(null);
setOpenMenu(null);
}}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
@@ -455,22 +619,29 @@ function StopRowBase({
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">
This will remove the stop. If it has active orders, you must resolve those first.
</p>
<div className="mt-4 flex gap-2">
<div className="mt-4 flex gap-2 justify-end">
<AdminButton
variant="secondary"
size="sm"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(null);
setOpenMenu(null);
}}
>
Cancel
</AdminButton>
<AdminButton
variant="danger"
size="sm"
onClick={() => handleDelete(stop.id)}
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
disabled={deletingId === stop.id}
isLoading={deletingId === stop.id}
>
{deletingId === stop.id ? "..." : "Delete"}
{deletingId === stop.id ? "Deleting…" : "Delete"}
</AdminButton>
</div>
</div>
@@ -482,4 +653,4 @@ function StopRowBase({
);
}
const StopRow = React.memo(StopRowBase);
const StopRow = React.memo(StopRowBase);
@@ -0,0 +1,754 @@
"use client";
import { useState, useMemo, useEffect, useRef, useCallback, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop } from "@/actions/stops";
import { useToast } from "@/components/admin/design-system";
import type { StopForView } from "./StopsViewClient";
type Props = {
stops: StopForView[];
};
/* ================================================================== */
/* Date helpers — work with YYYY-MM-DD strings to avoid TZ drift */
/* ================================================================== */
function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function parseYmd(s: string): Date | null {
// Treat YYYY-MM-DD as a local date (no UTC shift)
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return null;
const [, y, mo, d] = m;
return new Date(Number(y), Number(mo) - 1, Number(d));
}
function todayYmd(): string {
return ymd(new Date());
}
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const MONTH_NAMES_ITALIC = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function buildMonthGrid(month: Date): Date[] {
// Start at the Sunday on or before the 1st of the month
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const startDayOfWeek = first.getDay(); // 0 = Sun
const start = new Date(first);
start.setDate(1 - startDayOfWeek);
// 6 weeks = 42 cells
const cells: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
cells.push(d);
}
return cells;
}
function statusOf(stop: StopForView): "active" | "draft" | "inactive" {
if (stop.status === "draft") return "draft";
if (stop.active) return "active";
return "inactive";
}
function statusLabel(s: "active" | "draft" | "inactive"): string {
return s === "draft" ? "Draft" : s === "active" ? "Active" : "Inactive";
}
function brandName(s: StopForView): string {
return Array.isArray(s.brands) ? s.brands[0]?.name ?? "—" : s.brands?.name ?? "—";
}
function formatTime12(t: string | null | undefined): string {
if (!t) return "—";
// t is "HH:MM" or "HH:MM:SS"
const [hStr = "0", mStr = "00"] = t.split(":");
const h = Number(hStr);
const ampm = h >= 12 ? "pm" : "am";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${mStr} ${ampm}`;
}
function formatTimeCompact(t: string | null | undefined): string {
if (!t) return "—";
const [hStr = "0", mStr = "00"] = t.split(":");
const h = Number(hStr);
const ampm = h >= 12 ? "p" : "a";
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${h12}:${mStr}${ampm}`;
}
function formatDateLong(d: Date): string {
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
const month = MONTH_NAMES[d.getMonth()];
return `${weekday}, ${month} ${d.getDate()}`;
}
function formatDateLongSpelled(d: Date): string {
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()];
const month = MONTH_NAMES[d.getMonth()];
return `${weekday}, ${month} ${d.getDate()}, ${d.getFullYear()}`;
}
/* ================================================================== */
/* Component */
/* ================================================================== */
const MAX_EVENTS_PER_CELL = 3;
export default function StopsCalendarClient({ stops }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [viewMonth, setViewMonth] = useState(() => {
const today = new Date();
return new Date(today.getFullYear(), today.getMonth(), 1);
});
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [activePopover, setActivePopover] = useState<{
stopId: string;
cellRect: DOMRect;
} | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
// Bucket stops by YYYY-MM-DD
const stopsByDate = useMemo(() => {
const map = new Map<string, StopForView[]>();
for (const s of stops) {
if (!s.date) continue;
const list = map.get(s.date) ?? [];
list.push(s);
map.set(s.date, list);
}
// Sort each day by time
for (const list of map.values()) {
list.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
}
return map;
}, [stops]);
const todayStr = useMemo(() => todayYmd(), []);
const monthCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
// Earliest/latest stop dates — used to enable/disable nav
const dateRange = useMemo(() => {
if (stops.length === 0) return null;
const dates = stops.map((s) => s.date).filter(Boolean).sort();
return { min: dates[0], max: dates[dates.length - 1] };
}, [stops]);
const isFirstMonth = useMemo(() => {
if (!dateRange?.min) return false;
const minDate = parseYmd(dateRange.min);
if (!minDate) return false;
return viewMonth.getFullYear() === minDate.getFullYear() && viewMonth.getMonth() === minDate.getMonth();
}, [dateRange, viewMonth]);
const isLastMonth = useMemo(() => {
if (!dateRange?.max) return false;
const maxDate = parseYmd(dateRange.max);
if (!maxDate) return false;
return viewMonth.getFullYear() === maxDate.getFullYear() && viewMonth.getMonth() === maxDate.getMonth();
}, [dateRange, viewMonth]);
function goPrevMonth() {
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1));
}
function goNextMonth() {
setViewMonth(new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1));
}
function goToday() {
const today = new Date();
setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1));
setSelectedDate(todayYmd());
}
// Close popover on outside click / Escape
useEffect(() => {
if (!activePopover) return;
function onDown(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target.closest("[data-event-popover]") || target.closest("[data-event-chip]")) return;
setActivePopover(null);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setActivePopover(null);
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [activePopover]);
// Close drawer on Escape
useEffect(() => {
if (!selectedDate) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setSelectedDate(null);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [selectedDate]);
// Lock body scroll when drawer is open
useEffect(() => {
if (selectedDate) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}
}, [selectedDate]);
const openStop = useCallback((stop: StopForView, anchorRect: DOMRect) => {
setActivePopover({ stopId: stop.id, cellRect: anchorRect });
}, []);
const handlePublish = useCallback(
async (stop: StopForView) => {
setActivePopover(null);
setPublishingId(stop.id);
const result = await publishStop(stop.id, stop.brand_id);
setPublishingId(null);
if (result.success) {
showSuccess("Stop published", `${stop.city}, ${stop.state} is now visible to customers`);
startTransition(() => router.refresh());
} else {
showError("Failed to publish", result.error ?? "Please try again");
}
},
[router, showSuccess, showError]
);
const activeStop = useMemo(
() => (activePopover ? stops.find((s) => s.id === activePopover.stopId) ?? null : null),
[activePopover, stops]
);
const selectedDayStops = useMemo(() => {
if (!selectedDate) return [];
return stopsByDate.get(selectedDate) ?? [];
}, [selectedDate, stopsByDate]);
const selectedDayDate = useMemo(() => (selectedDate ? parseYmd(selectedDate) : null), [selectedDate]);
// Compute popover position with viewport edge detection
const popoverStyle = useMemo<React.CSSProperties | null>(() => {
if (!activePopover) return null;
const POPOVER_W = 288; // 18rem
const MARGIN = 8;
const rect = activePopover.cellRect;
const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
// Default: place below the chip, left-aligned
let left = rect.left + rect.width / 2 - POPOVER_W / 2;
const top = rect.bottom + MARGIN;
if (left + POPOVER_W > vw - 8) left = vw - POPOVER_W - 8;
if (left < 8) left = 8;
return { left, top };
}, [activePopover]);
const popoverArrowStyle = useMemo<React.CSSProperties | null>(() => {
if (!activePopover || !popoverStyle) return null;
const rect = activePopover.cellRect;
const arrowLeft = rect.left + rect.width / 2 - (popoverStyle.left as number) - 5;
return { left: Math.max(8, Math.min(arrowLeft, 270)) };
}, [activePopover, popoverStyle]);
// === Render ===
return (
<>
<div className="ha-calendar">
{/* Header */}
<div className="ha-calendar-header">
<div className="ha-calendar-title-block">
<span className="ha-calendar-eyebrow">
Tour Almanac · {stops.length} {stops.length === 1 ? "stop" : "stops"} on file
</span>
<h2 className="ha-calendar-title">
<span className="ha-calendar-title-italic">{MONTH_NAMES_ITALIC[viewMonth.getMonth()]}</span>
<span className="ha-calendar-title-year">{viewMonth.getFullYear()}</span>
</h2>
</div>
<div className="ha-calendar-nav">
<button
type="button"
className="ha-calendar-nav-btn"
onClick={goPrevMonth}
disabled={isFirstMonth}
aria-label="Previous month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button type="button" className="ha-calendar-today" onClick={goToday}>
<span className="ha-calendar-today-dot" style={{ background: "currentColor" }} />
Today
</button>
<button
type="button"
className="ha-calendar-nav-btn"
onClick={goNextMonth}
disabled={isLastMonth}
aria-label="Next month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{/* DOW header */}
<div className="ha-calendar-dow" role="row">
{DOW_LABELS.map((label) => (
<div key={label} className="ha-calendar-dow-cell" role="columnheader">
{label}
</div>
))}
</div>
{/* Grid */}
<div className="ha-calendar-grid" role="grid">
{monthCells.map((d) => {
const key = ymd(d);
const dayStops = stopsByDate.get(key) ?? [];
const isOut = d.getMonth() !== viewMonth.getMonth();
const isToday = key === todayStr;
const hasStops = dayStops.length > 0;
const dow = d.getDay();
const isWeekend = dow === 0 || dow === 6;
const isSelected = selectedDate === key;
return (
<div
key={key}
role="gridcell"
tabIndex={hasStops ? 0 : -1}
onClick={() => hasStops && setSelectedDate(key)}
onKeyDown={(e) => {
if (hasStops && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setSelectedDate(key);
}
}}
className={[
"ha-calendar-cell",
isOut ? "ha-calendar-cell--out" : "",
isWeekend ? "ha-calendar-cell--weekend" : "",
hasStops ? "ha-calendar-cell--has-stops" : "",
isToday ? "ha-calendar-cell--today" : "",
isSelected ? "ha-calendar-cell--selected" : "",
]
.filter(Boolean)
.join(" ")}
aria-label={hasStops ? `${formatDateLong(d)}${dayStops.length} stop${dayStops.length !== 1 ? "s" : ""}` : formatDateLong(d)}
>
<div className="ha-calendar-daynum">
<span>{d.getDate()}</span>
{isToday && <span className="ha-calendar-today-dot" />}
</div>
<div className="ha-calendar-events">
{dayStops.slice(0, MAX_EVENTS_PER_CELL).map((stop) => (
<EventChip
key={stop.id}
stop={stop}
isPublishing={publishingId === stop.id}
onOpen={(rect) => openStop(stop, rect)}
isActive={activePopover?.stopId === stop.id}
/>
))}
{dayStops.length > MAX_EVENTS_PER_CELL && (
<button
type="button"
className="ha-calendar-event-more"
onClick={(e) => {
e.stopPropagation();
setSelectedDate(key);
}}
>
+{dayStops.length - MAX_EVENTS_PER_CELL} more
</button>
)}
</div>
</div>
);
})}
</div>
{/* Legend */}
<div className="ha-calendar-legend">
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-accent)" }} />
Active
</span>
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "#f59e0b" }} />
Draft
</span>
<span className="ha-calendar-legend-item">
<span className="ha-calendar-legend-swatch" style={{ background: "var(--admin-text-muted)" }} />
Inactive
</span>
<span style={{ marginLeft: "auto" }} className="hidden sm:inline">
Tip click any day with stops to see the full route · click an event for details
</span>
</div>
</div>
{/* Event popover */}
{activeStop && activePopover && popoverStyle && (
<EventPopover
stop={activeStop}
isPublishing={publishingId === activeStop.id}
onPublish={() => handlePublish(activeStop)}
onOpenRoute={() => {
setActivePopover(null);
setSelectedDate(activeStop.date);
}}
style={popoverStyle}
arrowStyle={popoverArrowStyle}
/>
)}
{/* Day-route drawer */}
{selectedDate && selectedDayDate && (
<DayRouteDrawer
date={selectedDayDate}
dateStr={selectedDate}
stops={selectedDayStops}
onClose={() => setSelectedDate(null)}
onPublish={handlePublish}
/>
)}
</>
);
}
/* ================================================================== */
/* EventChip — single stop on a day cell */
/* ================================================================== */
function EventChip({
stop,
onOpen,
isActive,
isPublishing,
}: {
stop: StopForView;
onOpen: (rect: DOMRect) => void;
isActive: boolean;
isPublishing: boolean;
}) {
const ref = useRef<HTMLButtonElement>(null);
const status = statusOf(stop);
return (
<button
ref={ref}
type="button"
data-event-chip
onClick={(e) => {
e.stopPropagation();
if (!isPublishing && ref.current) onOpen(ref.current.getBoundingClientRect());
}}
className={`ha-calendar-event ha-calendar-event--${status} ${isActive ? "!bg-[var(--admin-accent)]/20" : ""}`}
title={`${stop.city}, ${stop.state}${stop.location}`}
>
<span className="ha-calendar-event-time">{formatTimeCompact(stop.time)}</span>
<span className="ha-calendar-event-text">{stop.city}, {stop.state}</span>
</button>
);
}
/* ================================================================== */
/* EventPopover — floating detail card */
/* ================================================================== */
function EventPopover({
stop,
isPublishing,
onPublish,
onOpenRoute,
style,
arrowStyle,
}: {
stop: StopForView;
isPublishing: boolean;
onPublish: () => void;
onOpenRoute: () => void;
style: React.CSSProperties;
arrowStyle: React.CSSProperties | null;
}) {
const status = statusOf(stop);
return (
<div
data-event-popover
className="ha-event-popover"
style={style}
onClick={(e) => e.stopPropagation()}
>
{arrowStyle && <div className="ha-event-popover-arrow" style={{ ...arrowStyle, top: -5 }} />}
<div className="ha-event-popover-eyebrow">
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{
background:
status === "active" ? "var(--admin-accent)" : status === "draft" ? "#f59e0b" : "var(--admin-text-muted)",
}}
/>
{statusLabel(status)} · {brandName(stop)}
</div>
<h3 className="ha-event-popover-title">
{stop.city}, {stop.state}
</h3>
<p className="ha-event-popover-location">{stop.location}</p>
<div className="ha-event-popover-grid">
<div className="ha-event-popover-field">
<span className="ha-event-popover-field-label">Pickup</span>
<span className="ha-event-popover-field-value">{formatTime12(stop.time)}</span>
</div>
<div className="ha-event-popover-field">
<span className="ha-event-popover-field-label">Cutoff</span>
<span className="ha-event-popover-field-value">{stop.cutoff_time ? formatTime12(stop.cutoff_time) : "—"}</span>
</div>
{stop.address && (
<div className="ha-event-popover-field" style={{ gridColumn: "1 / -1" }}>
<span className="ha-event-popover-field-label">Address</span>
<span className="ha-event-popover-field-value" style={{ fontFamily: "var(--font-geist), sans-serif", fontSize: "0.75rem" }}>
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
</span>
</div>
)}
</div>
<div className="ha-event-popover-actions">
{status === "draft" && (
<button
type="button"
onClick={onPublish}
disabled={isPublishing}
className="ha-event-popover-btn ha-event-popover-btn--primary"
>
{isPublishing ? "Publishing…" : "Publish"}
</button>
)}
<button
type="button"
onClick={onOpenRoute}
className="ha-event-popover-btn"
>
View route
</button>
<Link
href={`/admin/stops/${stop.id}`}
className="ha-event-popover-btn ha-event-popover-btn--primary"
>
Edit
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
);
}
/* ================================================================== */
/* DayRouteDrawer — full day's route on the side */
/* ================================================================== */
function DayRouteDrawer({
date,
dateStr,
stops,
onClose,
onPublish,
}: {
date: Date;
dateStr: string;
stops: StopForView[];
onClose: () => void;
onPublish: (stop: StopForView) => void;
}) {
const sorted = useMemo(() => [...stops].sort((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
const counts = useMemo(() => {
const active = sorted.filter((s) => s.status !== "draft" && s.active).length;
const draft = sorted.filter((s) => s.status === "draft").length;
const total = sorted.length;
return { active, draft, total };
}, [sorted]);
const brands = useMemo(() => Array.from(new Set(sorted.map(brandName))), [sorted]);
const routeNumber = useMemo(() => {
// Editorial "Route 03" — count of stops with status badge
return String(sorted.length).padStart(2, "0");
}, [sorted.length]);
const earliest = sorted[0]?.time;
const latest = sorted[sorted.length - 1]?.time;
return (
<>
<div className="ha-drawer-backdrop" onClick={onClose} aria-hidden="true" />
<aside className="ha-drawer" role="dialog" aria-label={`Route for ${formatDateLongSpelled(date)}`}>
<header className="ha-drawer-header">
<div>
<div className="ha-drawer-eyebrow">Route {routeNumber} · {dateStr}</div>
<h2 className="ha-drawer-title">{formatDateLongSpelled(date)}</h2>
<p className="ha-drawer-subtitle">
{sorted.length} {sorted.length === 1 ? "stop" : "stops"} on the route
{earliest && latest && sorted.length > 1 && (
<> · {formatTime12(earliest)} {formatTime12(latest)}</>
)}
</p>
</div>
<button type="button" onClick={onClose} className="ha-drawer-close" aria-label="Close">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<div className="ha-drawer-body">
{sorted.length === 0 ? (
<div className="ha-drawer-empty">
<p className="ha-drawer-empty-mark">No route on this day</p>
<p className="ha-drawer-empty-text">
No stops are scheduled for {formatDateLongSpelled(date)}.
</p>
</div>
) : (
<>
{/* Summary cards */}
<div className="ha-route-summary">
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{counts.total}</span>
<span className="ha-route-summary-label">Stops</span>
</div>
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{counts.active}</span>
<span className="ha-route-summary-label">Live</span>
</div>
<div className="ha-route-summary-cell">
<span className="ha-route-summary-num">{brands.length}</span>
<span className="ha-route-summary-label">Brands</span>
</div>
</div>
{/* Route spine */}
<div className="ha-route-list">
{sorted.map((stop, idx) => {
const status = statusOf(stop);
return (
<article key={stop.id} className="ha-route-stop">
<div className="ha-route-stop-spine">
<div className={`ha-route-stop-marker ha-route-stop-marker--${status}`}>
{String(idx + 1).padStart(2, "0")}
</div>
<div className="ha-route-stop-line" />
</div>
<div className="ha-route-stop-content">
<div className="ha-route-stop-time">{formatTime12(stop.time)}</div>
<h3 className="ha-route-stop-name">
{stop.city}, {stop.state}
</h3>
<p className="ha-route-stop-location">{stop.location}</p>
<div className="ha-route-stop-meta">
<span className="ha-route-stop-meta-pill">{brandName(stop)}</span>
<span
className="ha-route-stop-meta-pill"
style={{
background:
status === "active"
? "var(--admin-accent-light)"
: status === "draft"
? "rgba(245, 158, 11, 0.1)"
: "var(--admin-bg-subtle)",
color:
status === "active"
? "var(--admin-accent-text)"
: status === "draft"
? "#92400e"
: "var(--admin-text-secondary)",
borderColor:
status === "active"
? "var(--admin-accent)"
: status === "draft"
? "rgba(245, 158, 11, 0.3)"
: "var(--admin-border-light)",
}}
>
{statusLabel(status)}
</span>
{stop.cutoff_time && (
<span className="ha-route-stop-meta-pill">
Cutoff {formatTime12(stop.cutoff_time)}
</span>
)}
{stop.address && (
<span className="ha-route-stop-meta-pill">
{stop.address}{stop.zip ? `, ${stop.zip}` : ""}
</span>
)}
</div>
<div className="ha-route-stop-actions">
{status === "draft" && (
<button
type="button"
onClick={() => onPublish(stop)}
className="ha-route-stop-action ha-route-stop-action--primary"
>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Publish
</button>
)}
<Link
href={`/admin/stops/${stop.id}`}
className="ha-route-stop-action ha-route-stop-action--primary"
>
Edit
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
<Link
href={`/admin/stops/new?duplicate=${stop.id}`}
className="ha-route-stop-action"
>
Duplicate
</Link>
</div>
</div>
</article>
);
})}
</div>
</>
)}
</div>
</aside>
</>
);
}
+6 -2
View File
@@ -8,13 +8,17 @@ import { AdminButton } from "@/components/admin/design-system";
type Props = {
brandId: string;
/** Hide on the Locations tab — actions belong to the Stops tab only. */
tab?: "stops" | "locations";
};
export default function StopsHeaderActions({ brandId }: Props) {
export default function StopsHeaderActions({ brandId, tab = "stops" }: Props) {
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const router = useRouter();
if (tab !== "stops") return null;
function handleImportComplete(count: number) {
router.refresh();
}
@@ -66,4 +70,4 @@ export default function StopsHeaderActions({ brandId }: Props) {
/>
</>
);
}
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import Link from "next/link";
type Props = {
active: "stops" | "locations";
stopCount: number;
locationCount: number;
stopsSublabel?: string; // e.g. "269 · 7 cities"
locationsSublabel?: string; // e.g. "41 · 8 cities"
};
const StopIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
const PinIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export default function StopsLocationsTabs({
active,
stopCount,
locationCount,
stopsSublabel,
locationsSublabel,
}: Props) {
const tabs = [
{
value: "stops" as const,
label: "Stops",
count: stopCount,
sublabel: stopsSublabel,
icon: <StopIcon />,
href: "/admin/stops",
},
{
value: "locations" as const,
label: "Locations",
count: locationCount,
sublabel: locationsSublabel,
icon: <PinIcon />,
href: "/admin/stops?tab=locations",
},
];
return (
<div
className="flex flex-wrap items-stretch gap-1.5 px-3 py-2.5 border-b border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]"
role="tablist"
aria-label="Stops and Locations tabs"
>
{tabs.map((t) => {
const isActive = t.value === active;
return (
<Link
key={t.value}
href={t.href}
role="tab"
aria-selected={isActive}
className={`
group relative flex items-center gap-2.5 rounded-xl px-3.5 py-2
text-sm font-semibold transition-all duration-200
${isActive
? "bg-white text-emerald-700 border border-emerald-200 shadow-sm"
: "text-stone-600 border border-transparent hover:text-emerald-700 hover:bg-emerald-50/40"
}
`}
>
<span
className={`flex-shrink-0 transition-colors ${isActive ? "text-emerald-600" : "text-stone-400 group-hover:text-emerald-500"}`}
aria-hidden
>
{t.icon}
</span>
<span>{t.label}</span>
<span
className={`
inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5
text-[11px] font-bold tabular-nums
${isActive
? "bg-emerald-600 text-white"
: "bg-stone-200/70 text-stone-600 group-hover:bg-emerald-100 group-hover:text-emerald-700"
}
`}
>
{t.count}
</span>
{t.sublabel && (
<span
className={`hidden sm:inline text-[11px] font-medium tabular-nums ${isActive ? "text-stone-500" : "text-stone-400"}`}
>
· {t.sublabel}
</span>
)}
</Link>
);
})}
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { useState, useMemo } from "react";
import StopTableClient from "@/components/admin/StopTableClient";
import StopsCalendarClient from "@/components/admin/StopsCalendarClient";
import { AdminSearchInput, AdminFilterTabs } from "@/components/admin/design-system";
export type StopStatusFilter = "all" | "active" | "inactive" | "draft";
export type StopsViewMode = "calendar" | "table";
export type StopForView = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
deleted_at?: string | null;
brand_id: string;
status?: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
brands: { name: string } | { name: string }[];
};
type Props = {
stops: StopForView[];
};
export default function StopsViewClient({ stops }: Props) {
const [view, setView] = useState<StopsViewMode>("calendar");
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StopStatusFilter>("all");
// Apply shared filter so both views agree on what's visible
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return stops.filter((s) => {
const matchesSearch =
!q ||
s.city.toLowerCase().includes(q) ||
s.state.toLowerCase().includes(q) ||
s.location.toLowerCase().includes(q);
const isDraft = s.status === "draft";
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && !isDraft) ||
(statusFilter === "inactive" && !s.active && !isDraft) ||
(statusFilter === "draft" && isDraft);
return matchesSearch && matchesStatus;
});
}, [stops, search, statusFilter]);
const counts = useMemo(
() => ({
all: stops.length,
active: stops.filter((s) => s.active && s.status !== "draft").length,
inactive: stops.filter((s) => !s.active && s.status !== "draft").length,
draft: stops.filter((s) => s.status === "draft").length,
}),
[stops]
);
const tabs = [
{ value: "all", label: "All", count: counts.all },
{ value: "active", label: "Active", count: counts.active },
{ value: "inactive", label: "Inactive", count: counts.inactive },
{ value: "draft", label: "Draft", count: counts.draft },
];
return (
<>
{/* Filter / search / view-toggle bar — shared across both views */}
<div className="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center">
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(v) => setStatusFilter(v as StopStatusFilter)}
tabs={tabs}
size="sm"
showCounts
/>
<AdminSearchInput
placeholder="Search city, state, or venue…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch("")}
showClear
className="flex-1 min-w-48 max-w-72"
/>
<span className="text-xs text-[var(--admin-text-muted)]">
{filtered.length} {filtered.length === 1 ? "stop" : "stops"}
</span>
<div className="sm:ml-auto">
<div className="ha-viewtoggle" role="tablist" aria-label="Stops view mode">
<button
type="button"
role="tab"
aria-selected={view === "calendar"}
onClick={() => setView("calendar")}
className={`ha-viewtoggle-btn ${view === "calendar" ? "ha-viewtoggle-btn--active" : ""}`}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
</svg>
Calendar
</button>
<button
type="button"
role="tab"
aria-selected={view === "table"}
onClick={() => setView("table")}
className={`ha-viewtoggle-btn ${view === "table" ? "ha-viewtoggle-btn--active" : ""}`}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
</svg>
Table
</button>
</div>
</div>
</div>
{view === "calendar" ? (
<StopsCalendarClient stops={filtered} />
) : (
<StopTableClient stops={filtered} hideInternalFilterBar />
)}
</>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
type Props = {
tabKey: string;
children: React.ReactNode;
};
/**
* Fades the content of a tabbed panel when the active tab changes.
* The tab key drives the animation, so switching tabs triggers a quick
* fade-in of the new content. No motion when the tab key is stable
* (i.e. on first render of a given tab).
*/
export function TabSwitcher({ tabKey, children }: Props) {
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={tabKey}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
>
{children}
</motion.div>
</AnimatePresence>
);
}
@@ -0,0 +1,432 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
type Stop,
type StopStatus,
formatMonthYear,
formatTime12,
getStopDate,
getStopStatus,
isSameDay,
} from "./types";
type Props = {
stops: Stop[];
};
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const STATUS_STYLES: Record<StopStatus, { bg: string; text: string; dot: string; border: string }> = {
active: {
bg: "bg-[var(--admin-accent-light)]",
text: "text-[var(--admin-accent-text)]",
dot: "bg-[var(--admin-accent-dot)]",
border: "border-[var(--admin-accent)]/40",
},
draft: {
bg: "bg-amber-50",
text: "text-amber-800",
dot: "bg-amber-500",
border: "border-amber-300",
},
inactive: {
bg: "bg-stone-100",
text: "text-stone-500",
dot: "bg-stone-400",
border: "border-stone-300",
},
};
function buildMonthGrid(viewYear: number, viewMonth: number) {
// Sunday-first 6-row grid
const first = new Date(viewYear, viewMonth, 1);
const startWeekday = first.getDay();
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const cells: { date: Date; inMonth: boolean }[] = [];
// Leading days from previous month
for (let i = startWeekday - 1; i >= 0; i--) {
const d = new Date(viewYear, viewMonth, -i);
cells.push({ date: d, inMonth: false });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ date: new Date(viewYear, viewMonth, d), inMonth: true });
}
// Trailing days to fill 6 rows = 42 cells
while (cells.length < 42) {
const last = cells[cells.length - 1].date;
const next = new Date(last);
next.setDate(last.getDate() + 1);
cells.push({ date: next, inMonth: false });
}
return cells;
}
function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export default function StopsCalendar({ stops }: Props) {
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
const [selectedDate, setSelectedDate] = useState<string | null>(ymd(today));
const cells = useMemo(() => buildMonthGrid(view.year, view.month), [view]);
const stopsByDate = useMemo(() => {
const map = new Map<string, Stop[]>();
for (const s of stops) {
const d = getStopDate(s);
if (!d) continue;
const k = ymd(d);
const arr = map.get(k) ?? [];
arr.push(s);
map.set(k, arr);
}
// sort each bucket by time
for (const arr of map.values()) {
arr.sort((a, b) => (a.time || "").localeCompare(b.time || ""));
}
return map;
}, [stops]);
const visibleStopsByDate = useMemo(() => {
const map = new Map<string, Stop[]>();
for (const [k, arr] of stopsByDate.entries()) {
map.set(
k,
arr.filter((s) => getStopStatus(s) !== "inactive" || arr.length <= 6)
);
}
return map;
}, [stopsByDate]);
const monthStopsCount = useMemo(() => {
let count = 0;
for (const cell of cells) {
if (!cell.inMonth) continue;
count += stopsByDate.get(ymd(cell.date))?.length ?? 0;
}
return count;
}, [cells, stopsByDate]);
const upcomingWeek = useMemo(() => {
const end = new Date(today);
end.setDate(end.getDate() + 7);
let count = 0;
for (const s of stops) {
const d = getStopDate(s);
if (!d) continue;
if (d >= today && d < end && getStopStatus(s) !== "inactive") count++;
}
return count;
}, [stops, today]);
const goPrev = () => {
setView((v) => {
const m = v.month - 1;
if (m < 0) return { year: v.year - 1, month: 11 };
return { ...v, month: m };
});
setSelectedDate(null);
};
const goNext = () => {
setView((v) => {
const m = v.month + 1;
if (m > 11) return { year: v.year + 1, month: 0 };
return { ...v, month: m };
});
setSelectedDate(null);
};
const goToday = () => {
setView({ year: today.getFullYear(), month: today.getMonth() });
setSelectedDate(ymd(today));
};
const monthLabel = formatMonthYear(new Date(view.year, view.month, 1));
const isCurrentMonth = view.year === today.getFullYear() && view.month === today.getMonth();
const selectedDayStops = selectedDate ? stopsByDate.get(selectedDate) ?? [] : [];
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-6">
{/* Calendar card */}
<section
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm"
aria-label="Stops calendar"
>
{/* Decorative paper texture */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.4]"
style={{
backgroundImage:
"radial-gradient(circle at 20% 10%, rgba(34,197,94,0.04) 0%, transparent 40%), radial-gradient(circle at 80% 90%, rgba(217,119,6,0.04) 0%, transparent 40%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.035]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.5 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
}}
/>
{/* Header */}
<header className="relative flex items-center justify-between border-b border-[var(--admin-border)] px-6 py-5">
<div className="flex items-baseline gap-4">
<h2 className="font-display text-3xl font-medium text-[var(--admin-text-primary)] tracking-tight">
{monthLabel.split(" ")[0]}
<span className="text-[var(--admin-accent)] italic font-light">
{" "}
{monthLabel.split(" ")[1]}
</span>
</h2>
<span className="hidden sm:inline-block font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
{monthStopsCount} stop{monthStopsCount === 1 ? "" : "s"} scheduled
</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={goPrev}
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
aria-label="Previous month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<button
onClick={goToday}
disabled={isCurrentMonth}
className="h-8 px-3 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Today
</button>
<button
onClick={goNext}
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
aria-label="Next month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
</header>
{/* Weekday header */}
<div className="relative grid grid-cols-7 border-b border-[var(--admin-border)]">
{WEEKDAY_LABELS.map((d, i) => (
<div
key={d}
className={`px-2 py-2.5 text-center font-mono text-[10px] uppercase tracking-[0.2em] ${
i === 0 || i === 6 ? "text-[var(--admin-accent)]" : "text-[var(--admin-text-muted)]"
}`}
>
{d}
</div>
))}
</div>
{/* Grid */}
<div className="relative grid grid-cols-7 grid-rows-6">
{cells.map((cell, idx) => {
const k = ymd(cell.date);
const dayStops = stopsByDate.get(k) ?? [];
const visibleStops = visibleStopsByDate.get(k) ?? [];
const isToday = isSameDay(cell.date, today);
const isSelected = k === selectedDate;
const isWeekend = cell.date.getDay() === 0 || cell.date.getDay() === 6;
const overflow = dayStops.length - visibleStops.length;
return (
<button
key={idx}
type="button"
onClick={() => setSelectedDate(k)}
className={`
group relative flex min-h-[110px] flex-col items-stretch gap-1.5 border-b border-r border-[var(--admin-border-light)] p-2 text-left transition-colors
${!cell.inMonth ? "bg-[var(--admin-bg-subtle)]/40" : "bg-white"}
${isWeekend && cell.inMonth ? "bg-[var(--admin-bg-subtle)]/30" : ""}
${isSelected ? "!bg-[var(--admin-accent-light)] ring-2 ring-inset ring-[var(--admin-accent)]" : "hover:bg-[var(--admin-accent-light)]/40"}
`}
>
{/* Date number row */}
<div className="flex items-center justify-between">
<span
className={`
inline-flex h-6 min-w-[1.5rem] items-center justify-center rounded-md px-1.5 font-mono text-[11px] font-semibold
${isToday
? "bg-gradient-to-br from-amber-300 to-orange-400 text-white shadow-sm"
: !cell.inMonth
? "text-[var(--admin-text-muted)]/50"
: isSelected
? "text-[var(--admin-accent-text)]"
: "text-[var(--admin-text-primary)]"
}
`}
>
{cell.date.getDate()}
</span>
{dayStops.length > 0 && cell.inMonth && (
<span className="font-mono text-[9px] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
{dayStops.length}
</span>
)}
</div>
{/* Stop chips */}
<div className="flex flex-col gap-0.5 overflow-hidden">
{visibleStops.slice(0, 3).map((s) => {
const status = getStopStatus(s);
const st = STATUS_STYLES[status];
return (
<div
key={s.id}
className={`flex items-center gap-1 rounded ${st.bg} ${st.border} border px-1.5 py-0.5 text-[10px] font-medium leading-tight ${st.text}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${st.dot}`} aria-hidden />
<span className="truncate">
{s.time && (
<span className="font-mono opacity-70 mr-0.5">{formatTime12(s.time).replace(" ", "").toLowerCase()}</span>
)}
{s.city}
</span>
</div>
);
})}
{overflow > 0 && (
<div className="px-1.5 text-[10px] font-mono font-semibold text-[var(--admin-accent-text)]">
+{overflow} more
</div>
)}
</div>
</button>
);
})}
</div>
{/* Legend */}
<footer className="relative flex flex-wrap items-center gap-4 border-t border-[var(--admin-border)] px-6 py-3">
{(["active", "draft", "inactive"] as StopStatus[]).map((s) => {
const st = STATUS_STYLES[s];
return (
<div key={s} className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
<span className={`h-2 w-2 rounded-full ${st.dot}`} aria-hidden />
<span>{s}</span>
</div>
);
})}
<div className="ml-auto flex items-center gap-3 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
<span>
<span className="font-display text-base font-semibold text-[var(--admin-accent)]">
{upcomingWeek}
</span>{" "}
upcoming in 7d
</span>
</div>
</footer>
</section>
{/* Day detail panel */}
<aside className="rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
<div className="border-b border-[var(--admin-border)] px-5 py-4">
<div className="flex items-baseline gap-2">
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
{selectedDate
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", { weekday: "long" })
: "Pick a day"}
</h3>
</div>
<p className="mt-0.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{selectedDate
? new Date(selectedDate + "T00:00:00").toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
: "Select a date on the calendar"}
</p>
</div>
<div className="max-h-[640px] overflow-y-auto px-2 py-2">
{!selectedDate ? (
<div className="px-4 py-10 text-center font-mono text-[11px] uppercase tracking-wider text-[var(--admin-text-muted)]">
No day selected
</div>
) : selectedDayStops.length === 0 ? (
<div className="px-4 py-10 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops</p>
<p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
Free day on the route
</p>
</div>
) : (
<ul className="space-y-1">
{selectedDayStops.map((s) => {
const status = getStopStatus(s);
const st = STATUS_STYLES[status];
return (
<li key={s.id}>
<Link
href={`/admin/stops/${s.id}`}
className="group block rounded-xl border border-transparent px-3 py-3 transition-all hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
>
<div className="flex items-start gap-3">
<div
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${st.bg} ${st.border} border`}
>
<span className="font-mono text-[10px] font-bold text-[var(--admin-text-primary)]">
{formatTime12(s.time).split(" ")[0]}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate font-display text-base font-medium text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors">
{s.city}, {s.state}
</p>
<span className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{formatTime12(s.time).split(" ")[1]}
</span>
</div>
<p className="truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</p>
<div className="mt-1.5 flex items-center gap-2">
<span
className={`inline-flex items-center gap-1 rounded-full ${st.bg} ${st.text} px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider ${st.border} border`}
>
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} aria-hidden />
{status}
</span>
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name ? (
<span className="truncate font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name}
</span>
) : null}
</div>
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</aside>
</div>
);
}
@@ -0,0 +1,184 @@
"use client";
import { useMemo, useState } from "react";
import { type Stop, type StopView, getStopStatus } from "./types";
import StopsCalendar from "./StopsCalendar";
import StopsLocations from "./StopsLocations";
import StopsList from "./StopsList";
type Props = {
stops: Stop[];
};
const TABS: { value: StopView; label: string; hint: string }[] = [
{ value: "calendar", label: "Calendar", hint: "Month at a glance" },
{ value: "locations", label: "Locations", hint: "Stops grouped by city" },
{ value: "list", label: "List", hint: "All stops in order" },
];
export default function StopsDashboardClient({ stops }: Props) {
const [view, setView] = useState<StopView>("calendar");
const stats = useMemo(() => {
const total = stops.length;
const active = stops.filter((s) => getStopStatus(s) === "active").length;
const draft = stops.filter((s) => getStopStatus(s) === "draft").length;
const cities = new Set(stops.map((s) => s.city.trim().toLowerCase())).size;
const venues = new Set(
stops.map((s) => `${s.city}|${s.state}|${s.location}`.toLowerCase())
).size;
const today = new Date();
today.setHours(0, 0, 0, 0);
const in7 = new Date(today);
in7.setDate(today.getDate() + 7);
const upcoming = stops.filter((s) => {
if (!s.date) return false;
if (getStopStatus(s) === "inactive") return false;
const d = new Date(s.date + "T00:00:00");
return d >= today && d < in7;
}).length;
return { total, active, draft, cities, venues, upcoming };
}, [stops]);
return (
<div className="space-y-6">
{/* Top stats strip — almanac style */}
<section
aria-label="Stops overview"
className="relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-5 shadow-sm"
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 0.6 0'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>\")",
}}
/>
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[var(--admin-text-muted)]">
Harvest Dispatch · Almanac
</p>
<h2 className="mt-2 font-display text-3xl sm:text-4xl font-medium tracking-tight text-[var(--admin-text-primary)]">
{stats.total === 0
? "No stops scheduled"
: `${stats.total} stop${stats.total === 1 ? "" : "s"} on the route`}
<span className="ml-2 text-[var(--admin-accent)] italic font-light">
{stats.cities} cit{stats.cities === 1 ? "y" : "ies"}
</span>
</h2>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
{stats.active} active · {stats.draft} drafts · {stats.venues} distinct venues
</p>
</div>
{/* Tab nav — binder-style with notched corners */}
<div className="flex items-end gap-1">
{TABS.map((t) => {
const isActive = t.value === view;
return (
<button
key={t.value}
type="button"
onClick={() => setView(t.value)}
aria-pressed={isActive}
className={`
group relative -mb-px inline-flex flex-col items-start gap-0.5 rounded-t-xl border border-b-0 px-4 py-2.5 transition-all
${isActive
? "z-10 border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] shadow-[0_-4px_8px_-4px_rgba(60,56,37,0.08)]"
: "border-transparent bg-transparent text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-white/40"
}
`}
>
<span className={`font-display text-base font-medium ${isActive ? "text-[var(--admin-accent-text)]" : ""}`}>
{t.label}
</span>
<span className="font-mono text-[9px] uppercase tracking-[0.18em] opacity-80">
{t.hint}
</span>
{isActive && (
<span
aria-hidden
className="absolute left-3 right-3 -bottom-px h-0.5 bg-[var(--admin-accent)]"
/>
)}
</button>
);
})}
</div>
</div>
{/* Stat cells */}
<div className="relative mt-5 grid grid-cols-2 gap-3 border-t border-[var(--admin-border-light)] pt-5 sm:grid-cols-4">
<AlmanacStat
numeral="I"
label="Active"
value={stats.active}
accent
/>
<AlmanacStat
numeral="II"
label="Upcoming 7d"
value={stats.upcoming}
accent={stats.upcoming > 0}
/>
<AlmanacStat
numeral="III"
label="Cities"
value={stats.cities}
/>
<AlmanacStat
numeral="IV"
label="Venues"
value={stats.venues}
/>
</div>
</section>
{/* Active view */}
<section>
{view === "calendar" && <StopsCalendar stops={stops} />}
{view === "locations" && <StopsLocations stops={stops} />}
{view === "list" && <StopsList stops={stops} />}
</section>
</div>
);
}
function AlmanacStat({
numeral,
label,
value,
accent,
}: {
numeral: string;
label: string;
value: number;
accent?: boolean;
}) {
return (
<div className="flex items-center gap-3">
<span
className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md font-display text-base font-medium ${
accent ? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]" : "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]"
}`}
aria-hidden
>
{numeral}
</span>
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{label}
</p>
<p
className={`font-display text-2xl font-medium leading-none tabular-nums ${
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
}`}
>
{value}
</p>
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useMemo } from "react";
import { type Stop, getStopStatus } from "./types";
type Props = {
stops: Stop[];
};
// Minimal list view used inside the StopsDashboard tab nav.
// It is a calmer, read-only list; for bulk publish/edit use the
// dedicated /admin/stops page.
export default function StopsList({ stops }: Props) {
const sorted = useMemo(() => {
return [...stops].sort((a, b) => (a.date || "").localeCompare(b.date || ""));
}, [stops]);
if (sorted.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
<p className="font-display text-lg text-[var(--admin-text-primary)]">No stops yet</p>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
Switch to a different tab to add stops.
</p>
</div>
);
}
return (
<ul className="divide-y divide-[var(--admin-border-light)] overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
{sorted.map((s) => {
const status = getStopStatus(s);
const dot =
status === "active"
? "bg-[var(--admin-accent-dot)]"
: status === "draft"
? "bg-amber-500"
: "bg-stone-400";
const brand = Array.isArray(s.brands) ? s.brands[0]?.name : s.brands?.name;
return (
<li key={s.id} className="group flex items-center gap-4 px-5 py-3 transition-colors hover:bg-[var(--admin-bg-subtle)]">
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)] w-20">
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" }) : "—"}
</span>
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
{s.time ? s.time.slice(0, 5) : "—"}
</span>
<span className="flex-1 min-w-0">
<span className="truncate font-display text-sm font-medium text-[var(--admin-text-primary)]">
{s.city}, {s.state}
</span>
<span className="ml-2 truncate text-xs text-[var(--admin-text-secondary)]">{s.location}</span>
</span>
{brand && (
<span className="hidden md:inline-block font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{brand}
</span>
)}
<span
className={`font-mono text-[9px] uppercase tracking-wider ${
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
}`}
>
{status}
</span>
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,403 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
type LocationGroup,
type Stop,
formatTime12,
getStopStatus,
} from "./types";
type Props = {
stops: Stop[];
};
function groupStopsByLocation(stops: Stop[]): LocationGroup[] {
const map = new Map<string, LocationGroup>();
for (const s of stops) {
const key = `${(s.city || "Unknown").trim().toUpperCase()}|${(s.state || "").trim().toUpperCase()}`;
let group = map.get(key);
if (!group) {
group = {
key,
city: s.city || "Unknown",
state: s.state || "",
venueCount: 0,
total: 0,
active: 0,
draft: 0,
inactive: 0,
upcoming: 0,
nextDate: null,
firstDate: null,
lastDate: null,
sampleVenue: s.location,
stops: [],
};
map.set(key, group);
}
group.stops.push(s);
group.total += 1;
const status = getStopStatus(s);
if (status === "active") group.active += 1;
else if (status === "draft") group.draft += 1;
else group.inactive += 1;
if (s.date) {
if (!group.firstDate || s.date < group.firstDate) group.firstDate = s.date;
if (!group.lastDate || s.date > group.lastDate) group.lastDate = s.date;
}
}
// post-process: count distinct venues, upcoming stops
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
for (const g of map.values()) {
const venues = new Set(g.stops.map((s) => (s.location || "").trim().toLowerCase()).filter(Boolean));
g.venueCount = Math.max(1, venues.size);
g.upcoming = g.stops.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive").length;
const future = g.stops
.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive")
.map((s) => s.date)
.sort();
g.nextDate = future[0] ?? null;
g.stops.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
}
return Array.from(map.values()).sort((a, b) => {
// active locations with upcoming stops first, sorted by next date; then by city
if (a.nextDate && !b.nextDate) return -1;
if (!a.nextDate && b.nextDate) return 1;
if (a.nextDate && b.nextDate) return a.nextDate.localeCompare(b.nextDate);
return a.city.localeCompare(b.city);
});
}
function formatDateLabel(ymd: string | null): string {
if (!ymd) return "—";
const d = new Date(ymd + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function formatRange(a: string | null, b: string | null): string {
if (!a) return "—";
if (!b || a === b) return formatDateLabel(a);
return `${formatDateLabel(a)}${formatDateLabel(b)}`;
}
export default function StopsLocations({ stops }: Props) {
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [showOnlyActive, setShowOnlyActive] = useState(false);
const groups = useMemo(() => groupStopsByLocation(stops), [stops]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return groups.filter((g) => {
if (q) {
const hay = `${g.city} ${g.state} ${g.sampleVenue}`.toLowerCase();
if (!hay.includes(q)) return false;
}
if (showOnlyActive && g.active === 0) return false;
return true;
});
}, [groups, query, showOnlyActive]);
// Aggregated stats
const stats = useMemo(() => {
const totalLocations = groups.length;
const totalStops = stops.length;
const totalActive = groups.reduce((sum, g) => sum + g.active, 0);
const totalUpcoming = groups.reduce((sum, g) => sum + g.upcoming, 0);
const totalDrafts = groups.reduce((sum, g) => sum + g.draft, 0);
const totalCities = new Set(groups.map((g) => g.city.toLowerCase())).size;
const totalVenues = groups.reduce((sum, g) => sum + g.venueCount, 0);
return { totalLocations, totalStops, totalActive, totalUpcoming, totalDrafts, totalCities, totalVenues };
}, [groups, stops]);
if (stops.length === 0) {
return (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-12 text-center shadow-sm">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
<svg className="h-7 w-7 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
</div>
<h3 className="font-display text-2xl font-medium text-[var(--admin-text-primary)]">
No locations yet
</h3>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">
Create your first stop to see pickup locations here.
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Stats strip — almanac style */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{[
{ numeral: "I", label: "Locations", value: stats.totalLocations, hint: `${stats.totalVenues} venues` },
{ numeral: "II", label: "Cities", value: stats.totalCities, hint: "covered" },
{ numeral: "III", label: "Stops", value: stats.totalStops, hint: "all-time" },
{ numeral: "IV", label: "Active", value: stats.totalActive, hint: "live" },
{ numeral: "V", label: "Upcoming", value: stats.totalUpcoming, hint: "in queue" },
{ numeral: "VI", label: "Drafts", value: stats.totalDrafts, hint: "unpublished" },
].map((s) => (
<div
key={s.numeral}
className="group relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-sm transition-all hover:shadow-md hover:border-[var(--admin-accent)]/30"
>
<div className="flex items-start justify-between">
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
{s.numeral} · {s.label}
</p>
<p className="mt-2 font-display text-3xl font-medium leading-none text-[var(--admin-text-primary)] tabular-nums">
{s.value}
</p>
<p className="mt-1.5 font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{s.hint}
</p>
</div>
<div
aria-hidden
className="h-8 w-8 -rotate-3 font-display text-2xl text-[var(--admin-accent)]/15 group-hover:text-[var(--admin-accent)]/30 transition-colors"
>
{s.numeral}
</div>
</div>
<div className="mt-3 h-px w-full bg-gradient-to-r from-[var(--admin-accent)]/20 via-[var(--admin-accent)]/40 to-[var(--admin-accent)]/20" />
</div>
))}
</section>
{/* Filter bar */}
<section className="flex flex-wrap items-center gap-3 rounded-2xl border border-[var(--admin-border)] bg-white px-4 py-3 shadow-sm">
<div className="relative flex-1 min-w-[200px] max-w-md">
<svg className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search city, state, venue..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white pl-9 pr-3 py-2 text-sm text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:border-[var(--admin-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/15"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={showOnlyActive}
onClick={() => setShowOnlyActive((v) => !v)}
className={`relative h-5 w-9 rounded-full transition-colors ${showOnlyActive ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${showOnlyActive ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-secondary)]">
Active only
</span>
</label>
<span className="ml-auto font-mono text-[10px] uppercase tracking-wider text-[var(--admin-text-muted)]">
{filtered.length} of {groups.length} locations
</span>
</section>
{/* Location grid */}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--admin-border)] bg-white p-10 text-center">
<p className="font-display text-lg text-[var(--admin-text-primary)]">No locations match.</p>
<p className="mt-1 text-sm text-[var(--admin-text-secondary)]">Try a different search.</p>
</div>
) : (
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((g, idx) => {
const isOpen = expandedKey === g.key;
return (
<article
key={g.key}
className={`
group relative overflow-hidden rounded-2xl border bg-white shadow-sm transition-all
${isOpen ? "border-[var(--admin-accent)] shadow-md ring-1 ring-[var(--admin-accent)]/20" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)]/40 hover:shadow-md"}
`}
>
{/* Decorative route number */}
<div
aria-hidden
className="pointer-events-none absolute -right-2 -top-3 select-none font-display text-[80px] leading-none font-medium text-[var(--admin-accent)]/[0.06] group-hover:text-[var(--admin-accent)]/[0.1] transition-colors"
>
{String(idx + 1).padStart(2, "0")}
</div>
<button
type="button"
onClick={() => setExpandedKey(isOpen ? null : g.key)}
className="block w-full text-left p-5"
aria-expanded={isOpen}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{/* Pin marker */}
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)]/30">
<svg className="h-3.5 w-3.5 text-[var(--admin-accent-text)]" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z" />
</svg>
</span>
<div className="min-w-0">
<h3 className="truncate font-display text-xl font-medium text-[var(--admin-text-primary)]">
{g.city}
{g.state && (
<span className="ml-1.5 font-mono text-xs font-normal text-[var(--admin-text-muted)]">
{g.state}
</span>
)}
</h3>
<p className="truncate text-xs text-[var(--admin-text-secondary)]">
{g.sampleVenue}
</p>
</div>
</div>
</div>
<div
className={`mt-1 h-7 w-7 shrink-0 inline-flex items-center justify-center rounded-full border transition-transform ${
isOpen ? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] rotate-45" : "border-[var(--admin-border)] bg-white text-[var(--admin-text-muted)]"
}`}
aria-hidden
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
</svg>
</div>
</div>
{/* Stat grid */}
<div className="mt-5 grid grid-cols-3 gap-2">
<Stat label="Stops" value={g.total} />
<Stat label="Active" value={g.active} accent={g.active > 0} />
<Stat label="Upcoming" value={g.upcoming} accent={g.upcoming > 0} />
</div>
{/* Date range + drafts */}
<div className="mt-4 flex items-center justify-between border-t border-[var(--admin-border-light)] pt-3 font-mono text-[10px] uppercase tracking-wider">
<div className="text-[var(--admin-text-muted)]">
{g.firstDate ? (
<>
<span className="text-[var(--admin-text-secondary)]">{formatRange(g.firstDate, g.lastDate)}</span>
{" · "}
{g.venueCount > 1 ? `${g.venueCount} venues` : `${g.total} stop${g.total === 1 ? "" : "s"}`}
</>
) : (
"No dates"
)}
</div>
{g.draft > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden />
{g.draft} draft
</span>
)}
</div>
{/* Next up ribbon */}
{g.nextDate && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-[var(--admin-accent)]/20 bg-gradient-to-r from-[var(--admin-accent-light)] to-transparent px-3 py-2">
<span className="font-mono text-[9px] uppercase tracking-[0.18em] text-[var(--admin-accent-text)]">
Next
</span>
<span className="font-display text-sm font-medium text-[var(--admin-text-primary)]">
{formatDateLabel(g.nextDate)}
</span>
</div>
)}
</button>
{/* Expanded stop list */}
{isOpen && (
<div className="border-t border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/40 px-5 py-4">
<div className="mb-2 flex items-center justify-between">
<h4 className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-muted)]">
All stops · {g.stops.length}
</h4>
<Link
href={`/admin/stops/new?city=${encodeURIComponent(g.city)}&state=${encodeURIComponent(g.state)}`}
className="font-mono text-[10px] uppercase tracking-wider text-[var(--admin-accent-text)] hover:underline"
>
+ Add at this city
</Link>
</div>
<ul className="space-y-1">
{g.stops.map((s) => {
const status = getStopStatus(s);
const dot =
status === "active"
? "bg-[var(--admin-accent-dot)]"
: status === "draft"
? "bg-amber-500"
: "bg-stone-400";
return (
<li key={s.id}>
<Link
href={`/admin/stops/${s.id}`}
className="flex items-center gap-3 rounded-lg border border-transparent bg-white px-3 py-2 transition-colors hover:border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)]"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden />
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-16">
{s.date ? new Date(s.date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }) : "—"}
</span>
<span className="font-mono text-[10px] tabular-nums text-[var(--admin-text-muted)] w-14">
{formatTime12(s.time)}
</span>
<span className="flex-1 truncate text-xs text-[var(--admin-text-primary)]">
{s.location}
</span>
<span
className={`font-mono text-[9px] uppercase tracking-wider ${
status === "active" ? "text-[var(--admin-accent-text)]" : status === "draft" ? "text-amber-700" : "text-[var(--admin-text-muted)]"
}`}
>
{status}
</span>
</Link>
</li>
);
})}
</ul>
</div>
)}
</article>
);
})}
</section>
)}
</div>
);
}
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
return (
<div
className={`rounded-lg border px-2.5 py-2 ${
accent ? "border-[var(--admin-accent)]/30 bg-[var(--admin-accent-light)]" : "border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/50"
}`}
>
<p className="font-mono text-[9px] uppercase tracking-wider text-[var(--admin-text-muted)]">{label}</p>
<p
className={`mt-0.5 font-display text-xl font-medium tabular-nums leading-none ${
accent ? "text-[var(--admin-accent-text)]" : "text-[var(--admin-text-primary)]"
}`}
>
{value}
</p>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
export type StopStatus = "draft" | "active" | "inactive";
export type Stop = {
id: string;
city: string;
state: string;
date: string; // YYYY-MM-DD
time: string; // HH:MM
location: string;
active: boolean;
deleted_at?: string | null;
brand_id: string;
status?: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
brands: { name: string } | { name: string }[];
};
export type StopView = "calendar" | "locations" | "list";
export type LocationGroup = {
key: string;
city: string;
state: string;
venueCount: number; // distinct location strings at this city
total: number;
active: number;
draft: number;
inactive: number;
upcoming: number;
nextDate: string | null; // YYYY-MM-DD
firstDate: string | null;
lastDate: string | null;
sampleVenue: string;
stops: Stop[];
};
export function getStopStatus(s: Stop): StopStatus {
if (s.status === "draft") return "draft";
return s.active ? "active" : "inactive";
}
export function getStopDate(s: Stop): Date | null {
if (!s.date) return null;
const d = new Date(s.date + "T00:00:00");
return isNaN(d.getTime()) ? null : d;
}
export function formatTime12(time: string): string {
if (!time) return "—";
const [h, m] = time.split(":");
const hour = parseInt(h, 10);
if (isNaN(hour)) return time;
const ampm = hour >= 12 ? "PM" : "AM";
const hour12 = hour % 12 || 12;
return `${hour12}:${m} ${ampm}`;
}
export function formatMonthYear(date: Date): string {
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
}
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
@@ -0,0 +1,494 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import { useCart } from "@/context/CartContext";
import { formatDate } from "@/lib/format-date";
type QuickCartSheetProps = {
open: boolean;
onClose: () => void;
/** Optional override for the product just added (used when re-opening). */
productName?: string;
/** Optional message override (e.g. "Added ✓" vs default). */
justAddedLabel?: string;
};
const EASE_OUT = [0.22, 0.61, 0.36, 1] as const;
/**
* Slide-up cart drawer that appears immediately after the buyer adds an item.
*
* Mobile: bottom sheet that slides up from the bottom of the screen,
* full width, ~85vh tall. Drag handle at top.
* Desktop: right-side drawer (420px wide) that slides in from the right.
*
* Contains:
* - "✓ Added" confirmation
* - The added item card (name, price, qty, fulfillment)
* - Express checkout row (Apple Pay / Google Pay / Shop Pay)
* - Primary "Checkout" CTA
* - Secondary "View full cart" link
* - "Continue shopping" dismiss link
*/
export default function QuickCartSheet({
open,
onClose,
productName,
justAddedLabel,
}: QuickCartSheetProps) {
const { cart, subtotal, selectedStop, justAdded, dismissToast } = useCart();
// Lock body scroll while open (mobile only — desktop keeps scroll)
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
const isDesktop = window.matchMedia("(min-width: 1024px)").matches;
if (!isDesktop) {
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = prev;
};
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
const displayName = productName ?? justAdded?.name ?? cart[0]?.name ?? "Your box";
const addedAt = new Date();
const headerLabel = justAddedLabel ?? "Just added";
function handleViewCart() {
dismissToast();
onClose();
}
function handleCheckout() {
dismissToast();
onClose();
}
return (
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.button
type="button"
aria-label="Close cart"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className="fixed inset-0 z-[60] bg-stone-950/55 backdrop-blur-[3px] cursor-default"
/>
{/* Mobile: bottom sheet */}
<motion.div
role="dialog"
aria-modal="true"
aria-label="Quick cart"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", stiffness: 380, damping: 38, mass: 0.9 }}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.5 }}
onDragEnd={(_, info) => {
if (info.offset.y > 90 || info.velocity.y > 480) onClose();
}}
className="lg:hidden fixed inset-x-0 bottom-0 z-[61] max-h-[88vh] rounded-t-[28px] bg-[#FAF6EA] shadow-[0_-24px_60px_-12px_rgba(26,77,46,0.35)] flex flex-col"
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-1 cursor-grab active:cursor-grabbing">
<div className="h-1.5 w-12 rounded-full bg-stone-300/70" />
</div>
<SheetContent
displayName={displayName}
addedAt={addedAt}
headerLabel={headerLabel}
cart={cart}
subtotal={subtotal}
selectedStop={selectedStop}
onViewCart={handleViewCart}
onCheckout={handleCheckout}
onContinue={onClose}
/>
</motion.div>
{/* Desktop: right-side drawer */}
<motion.aside
role="dialog"
aria-modal="true"
aria-label="Quick cart"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", stiffness: 320, damping: 36, mass: 0.9 }}
className="hidden lg:flex fixed inset-y-0 right-0 z-[61] w-full sm:w-[420px] xl:w-[460px] bg-[#FAF6EA] shadow-[-24px_0_60px_-12px_rgba(26,77,46,0.35)] flex-col"
>
<SheetContent
displayName={displayName}
addedAt={addedAt}
headerLabel={headerLabel}
cart={cart}
subtotal={subtotal}
selectedStop={selectedStop}
onViewCart={handleViewCart}
onCheckout={handleCheckout}
onContinue={onClose}
showCloseButton
/>
</motion.aside>
</>
)}
</AnimatePresence>
);
}
type CartRow = {
id: string;
name: string;
price: string;
quantity: number;
fulfillment?: "pickup" | "ship";
};
type StopInfo = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
type SheetContentProps = {
displayName: string;
addedAt: Date;
headerLabel: string;
cart: CartRow[];
subtotal: number;
selectedStop: StopInfo | null;
onViewCart: () => void;
onCheckout: () => void;
onContinue: () => void;
showCloseButton?: boolean;
};
function SheetContent({
displayName,
addedAt,
headerLabel,
cart,
subtotal,
selectedStop,
onViewCart,
onCheckout,
onContinue,
showCloseButton,
}: SheetContentProps) {
return (
<>
{/* Header */}
<div className="relative px-6 sm:px-8 pt-2 pb-5 border-b border-stone-900/10">
<div className="flex items-center gap-3">
<motion.div
initial={{ scale: 0, rotate: -45 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 18, delay: 0.05 }}
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#1A4D2E] text-white shadow-[0_4px_14px_rgba(26,77,46,0.4)]"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</motion.div>
<div className="flex-1 min-w-0">
<p className="text-[10px] font-bold uppercase tracking-[0.22em] text-[#1A4D2E]/70">
{headerLabel}
</p>
<h2 className="font-display text-[22px] sm:text-[24px] font-bold leading-[1.1] text-stone-950 truncate">
{displayName}
</h2>
</div>
{showCloseButton && (
<button
onClick={onContinue}
aria-label="Close"
className="flex h-9 w-9 items-center justify-center rounded-full text-stone-500 hover:bg-stone-900/5 hover:text-stone-900 transition-colors"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<p className="mt-3 font-mono text-[10px] uppercase tracking-widest text-stone-500">
{addedAt.toLocaleString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})} · Today
</p>
</div>
{/* Body — scrollable */}
<div className="flex-1 overflow-y-auto px-6 sm:px-8 py-5">
<ul className="space-y-3">
{cart.map((item) => (
<li
key={item.id}
className="flex items-start gap-4 rounded-2xl bg-white/70 ring-1 ring-stone-900/8 p-4"
>
{/* Tiny product swatch */}
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-gradient-to-br from-[#FFD86A] via-[#F0B81C] to-[#A47A0B] ring-1 ring-stone-900/10">
<CornGlyph className="absolute inset-0 m-auto h-9 w-9 text-[#1A4D2E]/85" />
<span className="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-[#1A4D2E] text-[10px] font-bold text-white shadow-sm">
{item.quantity}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-stone-950 text-[15px] leading-tight truncate">
{item.name}
</p>
<p className="mt-0.5 text-[11px] font-mono uppercase tracking-widest text-stone-500">
{item.fulfillment === "ship" ? "Ships to door" : "Pickup at stop"}
</p>
<p className="mt-1.5 text-[15px] font-bold text-stone-950">
{item.price}
</p>
</div>
</li>
))}
</ul>
{/* Selected stop banner (if any) */}
{selectedStop && (
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-dashed border-[#1A4D2E]/40 bg-[#1A4D2E]/5 p-3.5">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#1A4D2E] text-white">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a2 2 0 01-2.828 0l-4.243-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-mono uppercase tracking-widest text-[#1A4D2E]/70">Pickup at</p>
<p className="text-sm font-semibold text-stone-950 truncate">
{selectedStop.city}, {selectedStop.state}
</p>
<p className="text-[11px] text-stone-600">
{formatDate(selectedStop.date)} · {selectedStop.time}
</p>
</div>
</div>
)}
{/* Subtotal */}
<div className="mt-5 flex items-baseline justify-between border-t border-stone-900/10 pt-4">
<span className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
Subtotal
</span>
<span className="font-display text-2xl font-bold text-stone-950">
${subtotal.toFixed(2)}
</span>
</div>
<p className="mt-1.5 text-[11px] text-stone-500 text-right">
+ tax & shipping at checkout
</p>
</div>
{/* Footer — sticky CTA + express */}
<div className="border-t border-stone-900/10 bg-[#F5EFD9] px-6 sm:px-8 pt-5 pb-7 space-y-3">
{/* Express checkout row — the buttons are visual shortcuts.
/checkout auto-renders Stripe's <ExpressCheckoutElement> for
Apple Pay / Google Pay / Link + <PaymentElement> for card via
StripeExpressCheckout. The `?express=` query is decorative. */}
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.22em] text-stone-500 mb-2 text-center">
Express checkout
</p>
<div className="grid grid-cols-3 gap-2">
<ExpressButton
href="/checkout?express=apple_pay"
onClick={onCheckout}
label="Pay"
icon={<ApplePayMark />}
className="bg-black text-white hover:bg-stone-900"
/>
<ExpressButton
href="/checkout?express=google_pay"
onClick={onCheckout}
label="Pay"
icon={<GooglePayMark />}
className="bg-white text-stone-900 ring-1 ring-stone-900/10 hover:bg-stone-50"
/>
<ExpressButton
href="/checkout?express=shop_pay"
onClick={onCheckout}
label="Shop"
icon={<ShopPayMark />}
className="bg-[#5A31F4] text-white hover:bg-[#4A22E0]"
/>
</div>
</div>
{/* Primary checkout */}
<Link
href="/checkout"
onClick={onCheckout}
className="group flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-[#1A4D2E] text-white font-bold tracking-wide shadow-[0_10px_24px_-8px_rgba(26,77,46,0.5)] hover:bg-[#143C24] active:scale-[0.99] transition-all"
>
Checkout · ${subtotal.toFixed(2)}
<svg className="h-5 w-5 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</Link>
{/* Secondary actions */}
<div className="flex items-center justify-between pt-1 text-[13px]">
<Link
href="/cart"
onClick={onViewCart}
className="font-semibold text-stone-600 hover:text-stone-950 underline-offset-4 hover:underline transition-colors"
>
View full cart
</Link>
<button
onClick={onContinue}
className="font-semibold text-stone-600 hover:text-stone-950 underline-offset-4 hover:underline transition-colors"
>
Continue shopping
</button>
</div>
</div>
</>
);
}
function ExpressButton({
href,
onClick,
label,
icon,
className,
}: {
href: string;
onClick: () => void;
label: string;
icon: React.ReactNode;
className: string;
}) {
return (
<Link
href={href}
onClick={onClick}
className={`group flex h-12 items-center justify-center gap-1.5 rounded-xl font-bold text-[13px] tracking-tight transition-all active:scale-[0.97] ${className}`}
>
{icon}
<span>{label}</span>
</Link>
);
}
/* ── Express brand marks (stylized, not official) ─────────────────── */
function ApplePayMark() {
return (
<svg viewBox="0 0 28 14" className="h-4 w-auto" aria-hidden="true">
<text x="0" y="11" fontFamily="system-ui, -apple-system, sans-serif" fontWeight="700" fontSize="12" fill="currentColor" letterSpacing="-0.5">
Pay
</text>
<path
d="M5.5 3.2c.5-.6.8-1.4.7-2.2-.7 0-1.5.5-2 1.1-.4.5-.8 1.3-.7 2.1.8 0 1.6-.4 2-1zm.7.9c-1.1 0-2 .6-2.6.6-.6 0-1.4-.6-2.3-.6-1.2 0-2.3.7-2.9 1.8C-2.9 8.1-2 11 1 11c.8 0 1.4-.5 2.3-.5s1.4.5 2.3.5c1 0 1.6-.8 2.2-1.6.7-1 .9-1.9.9-2-.1 0-1.7-.6-1.7-2.4 0-1.5 1.2-2.2 1.3-2.2-.7-1.1-1.9-1.2-2.3-1.2z"
transform="translate(0, -3) scale(0.5)"
fill="currentColor"
/>
</svg>
);
}
function GooglePayMark() {
return (
<svg viewBox="0 0 36 16" className="h-3.5 w-auto" aria-hidden="true">
<text
x="0"
y="12"
fontFamily="system-ui, -apple-system, sans-serif"
fontWeight="600"
fontSize="11"
fill="currentColor"
letterSpacing="-0.3"
>
G
<tspan dx="0.5" fontWeight="400" fontStyle="italic">
Pay
</tspan>
</text>
</svg>
);
}
function ShopPayMark() {
return (
<svg viewBox="0 0 38 14" className="h-3.5 w-auto" aria-hidden="true">
<text
x="0"
y="11"
fontFamily="system-ui, -apple-system, sans-serif"
fontWeight="700"
fontSize="11"
fill="currentColor"
letterSpacing="-0.4"
>
shop
<tspan fontWeight="400">Pay</tspan>
</text>
</svg>
);
}
function CornGlyph({ className }: { className?: string }) {
return (
<svg viewBox="0 0 32 32" className={className} aria-hidden="true">
{/* Husk leaves */}
<path
d="M9 16c-2-4-2-9 0-12 1 3 3 5 5 6"
fill="currentColor"
opacity="0.4"
/>
<path
d="M23 16c2-4 2-9 0-12-1 3-3 5-5 6"
fill="currentColor"
opacity="0.4"
/>
{/* Cob */}
<ellipse cx="16" cy="17" rx="6" ry="9" fill="currentColor" />
{/* Kernels (dots) */}
<g fill="#FAF6EA" opacity="0.7">
<circle cx="14" cy="12" r="0.8" />
<circle cx="18" cy="12" r="0.8" />
<circle cx="13" cy="15" r="0.8" />
<circle cx="16" cy="14" r="0.8" />
<circle cx="19" cy="15" r="0.8" />
<circle cx="14" cy="18" r="0.8" />
<circle cx="18" cy="18" r="0.8" />
<circle cx="16" cy="20" r="0.8" />
<circle cx="14" cy="22" r="0.8" />
<circle cx="18" cy="22" r="0.8" />
</g>
</svg>
);
}
@@ -0,0 +1,504 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Elements, ExpressCheckoutElement, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import type { StripeExpressCheckoutElementConfirmEvent } from "@stripe/stripe-js";
import { getStripe, hasStripePublishableKey } from "@/lib/stripe-client";
import { createRetailPaymentIntent } from "@/actions/billing/retail-payment-intent";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time?: string;
location: string;
brand_id: string;
};
export type CheckoutItem = {
id: string;
name: string;
price: string; // dollars, with or without $ sign
quantity: number;
fulfillment?: "pickup" | "ship";
};
type Props = {
items: CheckoutItem[];
brandId: string | null;
customerName: string;
customerEmail: string;
customerPhone: string;
selectedStop: Stop | null;
/** Required shipping address fields (only used when items include ship fulfillment). */
shippingState?: string;
shippingPostal?: string;
shippingCity?: string;
/** Called after the user submits via the form submit button (hosted checkout fallback). */
onHostedCheckout?: () => void;
/** Stable id used to group intents for the same logical checkout (UUID per page load). */
checkoutSessionKey?: string;
};
/**
* Renders the embedded Stripe Elements (Express Checkout + Payment Element)
* on top of the existing hosted Stripe Checkout fallback.
*
* Express Checkout Element is the primary CTA — it surfaces Apple Pay /
* Google Pay / Link / PayPal buttons automatically based on the user's
* browser and wallet availability. Tapping one opens the wallet's
* payment sheet, confirms the PaymentIntent in-page, and we navigate
* to /checkout/success to create the order.
*
* If Stripe.js can't load (no publishable key, network error, etc.),
* the component renders a graceful error and the parent falls back to
* the hosted Stripe Checkout button.
*/
export default function StripeExpressCheckout(props: Props) {
const router = useRouter();
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [paymentIntentId, setPaymentIntentId] = useState<string | null>(null);
const [intentAmount, setIntentAmount] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [available, setAvailable] = useState(false);
const hasShip = props.items.some((i) => i.fulfillment === "ship");
const hasPickup = props.items.some(
(i) => (i.fulfillment ?? "pickup") === "pickup" && (i as { pickup_type?: string }).pickup_type !== "shed"
);
// Block express checkout if pickup items exist but no stop is selected
const stopBlocked = hasPickup && !props.selectedStop;
const emailBlocked = !props.customerEmail.trim();
// Stable key for grouping intents by checkout session (in case of retry)
const sessionKey = useMemo(
() => props.checkoutSessionKey ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
[props.checkoutSessionKey]
);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
if (!hasStripePublishableKey()) {
setAvailable(false);
setError("Stripe publishable key not configured. Use the secure checkout button below.");
setLoading(false);
return;
}
if (props.items.length === 0) {
setLoading(false);
return;
}
if (stopBlocked) {
setLoading(false);
return;
}
(async () => {
const result = await createRetailPaymentIntent(
props.items.map((i) => ({
id: i.id,
name: i.name,
price: Number(String(i.price).replace(/[$,]/g, "")) || 0,
quantity: i.quantity,
})),
{
name: props.customerName,
email: props.customerEmail,
},
props.brandId,
props.selectedStop?.id ?? null,
hasShip
? {
state: props.shippingState,
postal_code: props.shippingPostal,
city: props.shippingCity,
}
: null
);
if (cancelled) return;
if (result.success) {
setClientSecret(result.clientSecret);
setPaymentIntentId(result.paymentIntentId);
setIntentAmount(result.amount);
setAvailable(true);
setError(null);
} else {
setAvailable(false);
setError(result.error);
}
setLoading(false);
})();
return () => {
cancelled = true;
};
// Re-fetch when the inputs that affect the PaymentIntent change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
sessionKey,
props.brandId,
props.selectedStop?.id,
props.customerName,
props.customerEmail,
hasShip ? `${props.shippingState}|${props.shippingPostal}|${props.shippingCity}` : "",
stopBlocked,
]);
// Persist the pending-checkout payload to sessionStorage so the
// success page can create the order. The wallet may overwrite the
// name/email (Apple Pay provides them), so we read from the PaymentIntent
// metadata on the success page where appropriate.
function persistPendingCheckout(intentId: string) {
if (typeof sessionStorage === "undefined") return;
sessionStorage.setItem(
"pending_checkout",
JSON.stringify({
customerName: props.customerName,
customerEmail: props.customerEmail,
customerPhone: props.customerPhone,
stopId: props.selectedStop?.id ?? null,
items: props.items.map((i) => ({
id: i.id,
name: i.name,
price: Number(String(i.price).replace(/[$,]/g, "")) || 0,
quantity: i.quantity,
fulfillment: (i.fulfillment ?? "pickup") as "pickup" | "ship",
})),
cartBrandId: props.brandId,
shippingAddress: hasShip
? {
state: props.shippingState,
postal_code: props.shippingPostal,
city: props.shippingCity,
}
: undefined,
idempotencyKey: sessionKey,
paymentIntentId: intentId,
paymentIntentAmount: intentAmount,
})
);
}
// Stripe.js loader promise
const stripePromise = useMemo(() => getStripe(), []);
if (loading) {
return (
<ExpressShell>
<div className="flex items-center gap-2 text-stone-500 text-sm py-3">
<span className="h-4 w-4 rounded-full border-2 border-stone-300 border-t-stone-700 animate-spin" />
Preparing express checkout
</div>
</ExpressShell>
);
}
if (!available || !clientSecret || !stripePromise) {
return (
<ExpressShell>
{error && (
<p className="text-xs text-stone-500 mb-2">
{error}
</p>
)}
<button
type="button"
onClick={props.onHostedCheckout}
className="w-full rounded-xl bg-gradient-to-r from-stone-900 to-stone-700 px-6 py-3.5 text-sm font-semibold text-white hover:from-stone-800 hover:to-stone-600 transition-all shadow-lg shadow-stone-900/20"
>
Continue to secure checkout
</button>
</ExpressShell>
);
}
return (
<ExpressShell>
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: "flat",
variables: {
colorPrimary: "#1A4D2E",
colorBackground: "#ffffff",
colorText: "#0a0a0a",
colorDanger: "#dc2626",
fontFamily: "system-ui, -apple-system, sans-serif",
spacingUnit: "4px",
borderRadius: "12px",
},
rules: {
".Input": { boxShadow: "none", border: "1px solid #e7e5e4" },
".Input:focus": { border: "1px solid #1A4D2E" },
".Label": { fontWeight: "600", color: "#0a0a0a" },
},
},
}}
>
<CheckoutInner
{...props}
emailBlocked={emailBlocked}
stopBlocked={stopBlocked}
paymentIntentId={paymentIntentId}
intentAmount={intentAmount}
persistPendingCheckout={persistPendingCheckout}
onError={setError}
onSuccess={() => {
// Express / Card confirmed in-page. Navigate to success.
router.push("/checkout/success?payment_intent=" + (paymentIntentId ?? ""));
}}
onHostedCheckout={props.onHostedCheckout}
/>
</Elements>
</ExpressShell>
);
}
function ExpressShell({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<svg className="h-4 w-4 text-stone-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-700">
Express checkout
</p>
</div>
{children}
</div>
);
}
type InnerProps = Props & {
emailBlocked: boolean;
stopBlocked: boolean;
paymentIntentId: string | null;
intentAmount: number | null;
persistPendingCheckout: (intentId: string) => void;
onError: (msg: string) => void;
onSuccess: () => void;
};
function CheckoutInner({
items,
brandId,
customerName,
customerEmail,
customerPhone,
selectedStop,
shippingState,
shippingPostal,
shippingCity,
emailBlocked,
stopBlocked,
paymentIntentId,
persistPendingCheckout,
onError,
onSuccess,
onHostedCheckout,
}: InnerProps) {
const stripe = useStripe();
const elements = useElements();
const [submitting, setSubmitting] = useState(false);
const [expressReady, setExpressReady] = useState(false);
const blocked = emailBlocked || stopBlocked;
const siteUrl =
typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
async function handleExpressConfirm(event: StripeExpressCheckoutElementConfirmEvent) {
if (blocked) {
event.paymentFailed({ reason: 'fail' });
onError(
emailBlocked
? "Enter your email above before paying."
: "Pick a pickup stop before paying."
);
return;
}
if (!stripe || !elements) {
event.paymentFailed({ reason: 'fail' });
onError("Stripe is not ready yet. Please try again.");
return;
}
// Persist the pending checkout payload so the success page can
// create the order. We do this BEFORE confirmPayment because the
// wallet may navigate away briefly during the payment sheet.
if (paymentIntentId) persistPendingCheckout(paymentIntentId);
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${siteUrl}/checkout/success?source=express`,
payment_method_data: {
billing_details: {
name: customerName || undefined,
email: customerEmail || undefined,
phone: customerPhone || undefined,
address: shippingState
? {
state: shippingState,
postal_code: shippingPostal,
city: shippingCity,
country: "US",
}
: undefined,
},
},
},
});
if (confirmError) {
event.paymentFailed({ reason: 'fail' });
onError(confirmError.message ?? "Payment failed. Please try again.");
} else {
// payment confirmed in-page — wallet closes its sheet automatically.
onSuccess();
}
}
async function handleCardSubmit(e: React.FormEvent) {
e.preventDefault();
if (!stripe || !elements) {
onError("Stripe is not ready yet. Please try again.");
return;
}
if (blocked) {
onError(
emailBlocked
? "Enter your email above before paying."
: "Pick a pickup stop before paying."
);
return;
}
setSubmitting(true);
if (paymentIntentId) persistPendingCheckout(paymentIntentId);
const { error: submitError } = await elements.submit();
if (submitError) {
onError(submitError.message ?? "Form is incomplete.");
setSubmitting(false);
return;
}
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${siteUrl}/checkout/success?source=card`,
payment_method_data: {
billing_details: {
name: customerName || undefined,
email: customerEmail || undefined,
phone: customerPhone || undefined,
address: shippingState
? {
state: shippingState,
postal_code: shippingPostal,
city: shippingCity,
country: "US",
}
: undefined,
},
},
},
});
if (confirmError) {
onError(confirmError.message ?? "Payment failed.");
setSubmitting(false);
return;
}
onSuccess();
}
return (
<div className="space-y-4">
{/* Express (Apple Pay, Google Pay, Link, PayPal) */}
<div>
<ExpressCheckoutElement
onConfirm={handleExpressConfirm}
onReady={(ev) => {
const m = ev.availablePaymentMethods;
const any = !!m && (m.applePay || m.googlePay || m.link || m.paypal || m.amazonPay);
setExpressReady(any);
}}
options={{
buttonType: { applePay: "buy", googlePay: "buy" },
buttonTheme: { applePay: "black", googlePay: "black" },
paymentMethodOrder: ["apple_pay", "google_pay", "link", "paypal"],
layout: { maxColumns: 3, maxRows: 1 },
}}
/>
{!expressReady && !blocked && (
<p className="mt-2 text-[11px] text-stone-500 text-center">
No express wallets detected on this device you can still pay by card below.
</p>
)}
{blocked && (
<p className="mt-2 text-[11px] text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-center">
{emailBlocked
? "Enter your email above to enable express checkout."
: "Pick a pickup stop above to enable express checkout."}
</p>
)}
</div>
{/* Divider */}
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-stone-200" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-3 text-[10px] font-bold uppercase tracking-widest text-stone-400">
or pay by card
</span>
</div>
</div>
{/* Card form */}
<form onSubmit={handleCardSubmit}>
<PaymentElement
options={{
layout: "tabs",
fields: { billingDetails: "auto" },
wallets: { applePay: "never", googlePay: "never" },
}}
/>
<button
type="submit"
disabled={!stripe || !elements || submitting || blocked}
className="mt-4 w-full rounded-xl bg-stone-900 hover:bg-stone-800 disabled:bg-stone-400 disabled:cursor-not-allowed px-6 py-3.5 text-sm font-semibold text-white transition-all shadow-lg shadow-stone-900/15"
>
{submitting ? "Processing…" : "Pay by card"}
</button>
</form>
{/* Fallback to hosted checkout (existing flow) */}
{onHostedCheckout && (
<button
type="button"
onClick={onHostedCheckout}
className="w-full text-xs text-stone-500 hover:text-stone-800 underline underline-offset-4 transition-colors"
>
Having trouble? Use the secure hosted checkout instead.
</button>
)}
</div>
);
}
@@ -0,0 +1,873 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence, useScroll, useTransform } from "framer-motion";
import { useCart } from "@/context/CartContext";
import QuickCartSheet from "./QuickCartSheet";
import StorefrontHeader from "./StorefrontHeader";
import StorefrontFooter from "./StorefrontFooter";
type Props = {
brandId: string;
brandName: string;
logoUrl?: string | null;
logoUrlDark?: string | null;
};
const PRODUCT = {
id: "sweet-corn-box-12",
slug: "sweet-corn-box",
name: "Fresh Sweet Corn Box 12 Ears",
tagline: "Straight-from-the-farm sweetness in every bite.",
price: "$29.99",
priceNumeric: 29.99,
// Used by the existing /cart flow to filter pickup vs ship
type: "Pickup & Shipping",
imageUrl:
"https://images.unsplash.com/photo-1551754655-cd27e38d2076?auto=format&fit=crop&w=1600&q=80",
fallbackGradient:
"radial-gradient(ellipse at 50% 50%, #FFE08A 0%, #E5A80F 35%, #8A5A06 80%, #1A4D2E 100%)",
};
const EASE_OUT = [0.22, 0.61, 0.36, 1] as const;
export default function SweetCornProductPage({ brandId, brandName, logoUrl, logoUrlDark }: Props) {
const router = useRouter();
const { addToCart, buyNow } = useCart();
const [sheetOpen, setSheetOpen] = useState(false);
const [justAdded, setJustAdded] = useState(false);
const [buyNowPulse, setBuyNowPulse] = useState(false);
const [imgError, setImgError] = useState(false);
const [imgLoaded, setImgLoaded] = useState(false);
// Parallax for the hero corn ears floating decoration
const { scrollY } = useScroll();
const floatY1 = useTransform(scrollY, [0, 800], [0, -120]);
const floatY2 = useTransform(scrollY, [0, 800], [0, 80]);
// Auto-dismiss the "Added" pulse after a short delay
useEffect(() => {
if (!justAdded) return;
const t = setTimeout(() => setJustAdded(false), 1800);
return () => clearTimeout(t);
}, [justAdded]);
function buildBaseItem() {
return {
id: PRODUCT.id,
name: PRODUCT.name,
price: PRODUCT.price,
brand_id: brandId,
brand_slug: "tuxedo",
is_taxable: true,
pickup_type: "scheduled_stop" as const,
description: PRODUCT.tagline,
};
}
function handleAddToCart() {
addToCart(buildBaseItem(), "pickup");
setJustAdded(true);
// Tiny delay so the user sees the "Added" state before the sheet slides up
setTimeout(() => setSheetOpen(true), 280);
}
function handleBuyNow() {
buyNow(buildBaseItem(), "pickup");
setBuyNowPulse(true);
setTimeout(() => router.push("/checkout"), 140);
}
return (
<div className="min-h-screen bg-[#FAF6EA] text-stone-950">
<StorefrontHeader
brandName={brandName}
brandSlug="tuxedo"
logoUrl={logoUrl}
logoUrlDark={logoUrlDark}
showWholesaleLink
brandAccent="green"
/>
<main>
{/* ── HERO ─────────────────────────────────────────────────────── */}
<section className="relative overflow-hidden bg-[#FAF6EA]">
{/* Subtle paper grain */}
<div
aria-hidden
className="absolute inset-0 pointer-events-none opacity-[0.035] mix-blend-multiply"
style={{
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.7'/></svg>\")",
}}
/>
{/* Decorative giant "12" in the background */}
<div
aria-hidden
className="absolute -top-20 -right-10 lg:-right-24 font-display text-[260px] lg:text-[420px] font-black leading-none text-[#E5A80F]/8 select-none pointer-events-none"
style={{ color: "rgba(229, 168, 15, 0.08)" }}
>
12
</div>
{/* Floating decorative corn ears */}
<motion.div
aria-hidden
style={{ y: floatY1 }}
className="absolute top-32 right-[6%] hidden lg:block"
>
<FloatingCorn className="w-16 text-[#1A4D2E]/15 rotate-12" />
</motion.div>
<motion.div
aria-hidden
style={{ y: floatY2 }}
className="absolute bottom-20 left-[4%] hidden lg:block"
>
<FloatingCorn className="w-12 text-[#E5A80F]/25 -rotate-12" />
</motion.div>
<div className="relative mx-auto max-w-6xl px-5 sm:px-6 lg:px-8 pt-10 sm:pt-14 lg:pt-20 pb-8 lg:pb-12">
{/* Breadcrumb */}
<nav className="flex items-center gap-1.5 text-[11px] font-mono uppercase tracking-widest text-stone-500">
<Link href="/tuxedo" className="hover:text-stone-900 transition-colors">
Tuxedo Corn
</Link>
<Chevron />
<Link href="/tuxedo#products" className="hover:text-stone-900 transition-colors">
Shop
</Link>
<Chevron />
<span className="text-stone-900">Sweet Corn Box</span>
</nav>
<div className="mt-8 lg:mt-12 grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-14 items-start">
{/* LEFT — Visual */}
<div className="lg:col-span-7 relative">
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: EASE_OUT }}
className="relative aspect-[4/3] sm:aspect-[5/4] lg:aspect-[6/5] w-full overflow-hidden rounded-[28px] ring-1 ring-stone-900/10 shadow-[0_30px_60px_-20px_rgba(26,77,46,0.35)]"
>
{/* Gradient fallback (always behind, visible until image loads or on error) */}
<div
className="absolute inset-0"
style={{ background: PRODUCT.fallbackGradient }}
/>
{/* Decorative corn cobs inside the image frame */}
<div className="absolute inset-0 flex items-center justify-center">
<CornIllustration className="w-3/5 max-w-[420px] text-[#1A4D2E]/55" />
</div>
{!imgError && (
<Image
src={PRODUCT.imageUrl}
alt="Freshly harvested Olathe Sweet sweet corn"
fill
priority
sizes="(min-width: 1024px) 58vw, 100vw"
className={`object-cover transition-opacity duration-700 ${
imgLoaded ? "opacity-100" : "opacity-0"
}`}
onLoad={() => setImgLoaded(true)}
onError={() => setImgError(true)}
/>
)}
{/* Bottom scrim for badge legibility */}
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-black/35 to-transparent pointer-events-none" />
{/* Tilted corner stamp */}
<motion.div
initial={{ opacity: 0, rotate: 0, scale: 0.6 }}
animate={{ opacity: 1, rotate: -8, scale: 1 }}
transition={{ delay: 0.55, type: "spring", stiffness: 200, damping: 14 }}
className="absolute top-5 left-5 lg:top-7 lg:left-7 origin-top-left"
>
<div className="flex h-[88px] w-[88px] lg:h-[100px] lg:w-[100px] flex-col items-center justify-center rounded-full border-2 border-dashed border-white/85 bg-[#1A4D2E]/90 text-white text-center shadow-lg backdrop-blur-sm">
<span className="font-mono text-[8px] uppercase tracking-[0.18em] text-white/70">
Peak
</span>
<span className="font-display text-[20px] lg:text-[22px] font-black leading-none">
SEASON
</span>
<span className="font-mono text-[8px] uppercase tracking-[0.18em] text-white/70">
2026
</span>
</div>
</motion.div>
{/* Right corner "12 ears" badge */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4, duration: 0.5 }}
className="absolute bottom-5 right-5 lg:bottom-7 lg:right-7"
>
<div className="rounded-2xl bg-white/95 px-3.5 py-2 ring-1 ring-stone-900/10 shadow-lg backdrop-blur-sm">
<p className="font-mono text-[9px] uppercase tracking-widest text-stone-500 leading-none">
Box contains
</p>
<p className="font-display text-[22px] font-black text-stone-950 leading-none mt-0.5">
12 ears
</p>
</div>
</motion.div>
</motion.div>
{/* Thumbnails row (decorative — same image) */}
<div className="mt-4 flex gap-3">
{[0, 1, 2].map((i) => (
<button
key={i}
aria-label={`View image ${i + 1}`}
className={`relative aspect-square w-16 sm:w-20 overflow-hidden rounded-xl ring-2 transition-all ${
i === 0
? "ring-[#1A4D2E]"
: "ring-stone-900/10 hover:ring-stone-900/30"
}`}
>
<div
className="absolute inset-0"
style={{ background: PRODUCT.fallbackGradient }}
/>
<CornIllustration className="absolute inset-0 m-auto w-3/5 text-[#1A4D2E]/55" />
</button>
))}
<div className="ml-auto hidden sm:flex items-center gap-1.5 rounded-xl bg-stone-900/5 px-3 py-2 text-[10px] font-mono uppercase tracking-widest text-stone-600">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
Lot 04-26 · In season
</div>
</div>
</div>
{/* RIGHT — Copy + Buy Box */}
<div className="lg:col-span-5">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.1, ease: EASE_OUT }}
>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full bg-[#1A4D2E] px-3 py-1 text-[10px] font-bold uppercase tracking-widest text-white">
<span className="h-1.5 w-1.5 rounded-full bg-[#FCD34D] animate-pulse" />
Ships fresh
</span>
<span className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
Same-day harvest when possible
</span>
</div>
<h1 className="mt-5 font-display text-[44px] sm:text-[52px] lg:text-[60px] font-black leading-[0.95] tracking-[-0.02em] text-stone-950">
<span className="block">Sweet Corn</span>
<span className="block italic font-normal text-[#1A4D2E]">Box</span>
</h1>
<p className="mt-4 font-display text-[19px] sm:text-[21px] leading-[1.35] text-stone-700 italic">
{PRODUCT.tagline}
</p>
<p className="mt-5 text-[15px] leading-relaxed text-stone-700 max-w-prose">
Our 12-Ear Corn Box is packed with peak-season, super-sweet corn picked
that morning and rushed to your door. Perfect for BBQs, family dinners,
or freezing for winter.
</p>
{/* Why our corn (inline) */}
<ul className="mt-6 space-y-2.5">
{WHY_CORN.map((item) => (
<li key={item.title} className="flex items-start gap-3">
<span className="mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[#1A4D2E] text-white">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</span>
<div>
<span className="font-bold text-stone-950 text-[14px]">
{item.title}
</span>
<span className="text-stone-600 text-[14px]">
{item.body && <> {item.body}</>}
</span>
</div>
</li>
))}
</ul>
{/* Price + buy box (desktop sticky) */}
<div
id="buy-box"
className="mt-8 lg:mt-10 rounded-3xl bg-white ring-1 ring-stone-900/10 p-6 sm:p-7 shadow-[0_20px_40px_-12px_rgba(26,77,46,0.18)]"
>
<div className="flex items-end justify-between gap-4">
<div>
<p className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
One box · 12 ears
</p>
<p className="mt-1 font-display text-[44px] font-black leading-none tracking-tight text-stone-950">
{PRODUCT.price}
</p>
<p className="mt-1 text-[11px] text-stone-500">
$2.50 / ear · tax included
</p>
</div>
<div className="text-right">
<p className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
In stock
</p>
<p className="mt-1 font-display text-[20px] font-bold text-[#1A4D2E]">
240 boxes
</p>
<p className="text-[10px] text-stone-500">ready this week</p>
</div>
</div>
{/* Quantity */}
<div className="mt-5 flex items-center gap-3">
<span className="font-mono text-[10px] uppercase tracking-widest text-stone-500">
Qty
</span>
<div className="inline-flex items-center rounded-full ring-1 ring-stone-900/15 bg-stone-50">
<button
aria-label="Decrease quantity"
className="h-10 w-10 flex items-center justify-center text-stone-700 hover:text-stone-950 hover:bg-stone-100 rounded-l-full transition-colors"
onClick={() => {
/* single-product page — qty is always 1 for now */
}}
>
</button>
<span className="w-10 text-center font-bold text-stone-950">1</span>
<button
aria-label="Increase quantity"
className="h-10 w-10 flex items-center justify-center text-stone-700 hover:text-stone-950 hover:bg-stone-100 rounded-r-full transition-colors"
>
+
</button>
</div>
<span className="ml-auto text-[12px] text-stone-500 hidden sm:inline">
Ships in a compostable cooler
</span>
</div>
{/* Add to Cart (primary) */}
<motion.button
onClick={handleAddToCart}
whileTap={{ scale: 0.98 }}
className={`mt-5 relative w-full overflow-hidden rounded-2xl h-14 sm:h-16 font-bold text-[15px] sm:text-[16px] tracking-wide transition-all ${
justAdded
? "bg-[#1A4D2E] text-white"
: "bg-stone-950 text-white hover:bg-stone-800"
}`}
>
<AnimatePresence mode="wait">
{justAdded ? (
<motion.span
key="added"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.18 }}
className="flex items-center justify-center gap-2"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Added to cart
</motion.span>
) : (
<motion.span
key="add"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.18 }}
className="flex items-center justify-center gap-2"
>
Add Box to Cart · {PRODUCT.price}
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</motion.span>
)}
</AnimatePresence>
</motion.button>
{/* Buy Now (secondary, urgent orange) */}
<motion.button
onClick={handleBuyNow}
whileTap={{ scale: 0.98 }}
className={`mt-3 group relative w-full overflow-hidden rounded-2xl h-12 sm:h-[52px] font-bold text-[14px] tracking-wide transition-all ${
buyNowPulse
? "bg-[#D97706] text-white"
: "bg-[#FFF1D6] text-[#9A4A06] ring-1 ring-[#E5A80F]/40 hover:bg-[#FFE7B0]"
}`}
>
<span className="flex items-center justify-center gap-2">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" />
</svg>
Quick Buy Ships Today
<span className="ml-1 font-mono text-[10px] font-medium uppercase tracking-widest opacity-70 hidden sm:inline">
1-click
</span>
</span>
</motion.button>
{/* Express row (visual only) */}
<div className="mt-4 grid grid-cols-3 gap-2">
<ExpressChip label="Apple Pay" />
<ExpressChip label="Google Pay" />
<ExpressChip label="Shop Pay" />
</div>
<p className="mt-4 text-center text-[11px] text-stone-500">
One click. One box. Farm-fresh delivered.
</p>
</div>
</motion.div>
</div>
</div>
</div>
</section>
{/* ── GUARANTEE / TRUST STRIP ─────────────────────────────────────── */}
<section className="relative bg-[#1A4D2E] text-white overflow-hidden">
<div
aria-hidden
className="absolute inset-0 opacity-[0.08]"
style={{
background:
"radial-gradient(ellipse at 20% 50%, rgba(252, 211, 77, 0.4) 0%, transparent 50%), radial-gradient(ellipse at 80% 50%, rgba(252, 211, 77, 0.3) 0%, transparent 50%)",
}}
/>
<div className="relative mx-auto max-w-6xl px-5 sm:px-6 lg:px-8 py-12 sm:py-16">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10">
<TrustColumn
icon={<TruckIcon />}
eyebrow="Ships Fresh"
title="Same-day harvest when possible"
body="We pick before the heat steals a single calorie of sweetness. By the time your order arrives, the corn was still in the field that morning."
/>
<TrustColumn
icon={<ShieldIcon />}
eyebrow="Satisfaction"
title="100% Sweetness Guarantee"
body="If its not the sweetest corn youve ever had, well replace it. No questions, no forms, no fuss."
accent
/>
<TrustColumn
icon={<SparkleIcon />}
eyebrow="Versatile"
title="Great for every summer table"
body="Grilling, boiling, freezing, or corn-on-the-cob parties — twelve ears is exactly the right amount for a family of four to share."
/>
</div>
</div>
</section>
{/* ── FARM STORY ─────────────────────────────────────────────────── */}
<section className="relative bg-[#F3EDD8]">
<div className="mx-auto max-w-6xl px-5 sm:px-6 lg:px-8 py-20 sm:py-28">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center">
<div className="lg:col-span-5">
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[#9A4A06]">
Field notes · Vol. IV
</p>
<h2 className="mt-4 font-display text-[36px] sm:text-[44px] lg:text-[52px] font-black leading-[1.02] tracking-tight text-stone-950">
Picked by hand, <span className="italic font-normal text-[#1A4D2E]">stayed</span> sweet for days.
</h2>
<p className="mt-5 text-stone-700 text-[16px] leading-relaxed max-w-prose">
We hand-select every ear for size and quality. No machine knows when a
cob is at peak sugar content. Thats why your box is a little heavier
than you expect and a whole lot sweeter than any corn youve bought
from a grocery store shelf.
</p>
<div className="mt-8 flex flex-wrap items-center gap-6">
<Stat number="40+" label="Years growing" />
<span className="h-10 w-px bg-stone-900/15" />
<Stat number="3" label="Generations" />
<span className="h-10 w-px bg-stone-900/15" />
<Stat number="100%" label="Hand-picked" />
</div>
</div>
<div className="lg:col-span-7 relative">
<div className="relative aspect-[5/4] overflow-hidden rounded-[28px] ring-1 ring-stone-900/10 shadow-[0_30px_60px_-20px_rgba(26,77,46,0.35)]">
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, #FCD34D 0%, #E5A80F 45%, #8A5A06 100%)",
}}
/>
{/* Abstract corn field lines */}
<svg
viewBox="0 0 400 320"
className="absolute inset-0 w-full h-full"
aria-hidden
>
<defs>
<pattern id="rows" width="50" height="20" patternUnits="userSpaceOnUse" patternTransform="rotate(-8)">
<line x1="0" y1="10" x2="50" y2="10" stroke="#1A4D2E" strokeWidth="2" strokeLinecap="round" />
</pattern>
</defs>
<rect width="400" height="320" fill="url(#rows)" opacity="0.45" />
</svg>
{/* Floating cob clusters */}
<CornIllustration className="absolute -left-6 top-1/2 -translate-y-1/2 w-32 text-[#1A4D2E]/85 rotate-[-8deg]" />
<CornIllustration className="absolute right-6 top-8 w-24 text-[#1A4D2E]/75 rotate-[12deg]" />
<CornIllustration className="absolute right-16 bottom-8 w-20 text-[#1A4D2E]/70 -rotate-6" />
{/* Sun stamp */}
<div className="absolute top-5 right-5 h-20 w-20 rounded-full bg-[#FFE08A] ring-4 ring-[#1A4D2E]/15 flex items-center justify-center">
<span className="font-display text-[10px] font-bold text-[#1A4D2E] text-center leading-tight">
HAND
<br />
PICKED
</span>
</div>
{/* Bottom data strip */}
<div className="absolute inset-x-4 bottom-4 rounded-2xl bg-stone-950/90 backdrop-blur-sm px-4 py-3 flex items-center justify-between text-white text-[11px] font-mono">
<div>
<span className="text-white/50">FIELD</span> · 04-N
</div>
<div>
<span className="text-white/50">BRIX</span> · 24.2°
</div>
<div>
<span className="text-white/50">PICK</span> · 06:14
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* ── FAQ MICRO ──────────────────────────────────────────────────── */}
<section className="relative bg-[#FAF6EA]">
<div className="mx-auto max-w-4xl px-5 sm:px-6 lg:px-8 py-20 sm:py-24">
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[#9A4A06] text-center">
Buyers notes
</p>
<h2 className="mt-4 font-display text-[36px] sm:text-[44px] font-black leading-tight text-center text-stone-950">
Questions, answered.
</h2>
<div className="mt-12 divide-y divide-stone-900/10 border-t border-b border-stone-900/10">
{FAQS.map((faq) => (
<FAQItem key={faq.q} q={faq.q} a={faq.a} />
))}
</div>
</div>
</section>
{/* ── FINAL CTA STRIP (desktop) ─────────────────────────────────── */}
<section className="hidden lg:block relative bg-stone-950 text-white overflow-hidden">
<div
aria-hidden
className="absolute inset-0 opacity-30"
style={{
background:
"radial-gradient(ellipse at 30% 50%, rgba(229, 168, 15, 0.5) 0%, transparent 60%), radial-gradient(ellipse at 80% 50%, rgba(26, 77, 46, 0.6) 0%, transparent 60%)",
}}
/>
<div className="relative mx-auto max-w-6xl px-8 py-16 flex items-center justify-between gap-10">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.25em] text-[#FCD34D]">
Ready when you are
</p>
<h3 className="mt-3 font-display text-[40px] font-black leading-[1.05]">
Twelve ears. <span className="italic font-normal">One click.</span>
</h3>
<p className="mt-2 text-stone-400 max-w-md">
Free shipping on orders over $40. Cancel anytime before harvest.
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<button
onClick={handleAddToCart}
className="rounded-2xl h-14 px-7 bg-white text-stone-950 font-bold hover:bg-stone-100 active:scale-[0.98] transition-all"
>
Add to cart {PRODUCT.price}
</button>
<button
onClick={handleBuyNow}
className="rounded-2xl h-14 px-7 bg-[#FCD34D] text-stone-950 font-bold hover:bg-[#FBBF24] active:scale-[0.98] transition-all"
>
Quick Buy
</button>
</div>
</div>
</section>
</main>
{/* ── STICKY MOBILE BOTTOM CTA BAR ──────────────────────────────────── */}
<div className="lg:hidden fixed inset-x-0 bottom-0 z-40 bg-[#FAF6EA]/95 backdrop-blur-xl border-t border-stone-900/10 shadow-[0_-12px_30px_-12px_rgba(26,77,46,0.3)]">
<div className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<p className="font-mono text-[9px] uppercase tracking-widest text-stone-500 leading-none">
12-Ear Box
</p>
<p className="mt-1 font-display text-[26px] font-black leading-none text-stone-950">
{PRODUCT.price}
</p>
</div>
<button
onClick={handleBuyNow}
className="h-14 min-w-[120px] rounded-2xl bg-[#FFF1D6] text-[#9A4A06] font-bold text-[14px] ring-1 ring-[#E5A80F]/40 active:scale-[0.98] transition-all"
>
Quick Buy
</button>
<button
onClick={handleAddToCart}
className="h-14 min-w-[160px] rounded-2xl bg-stone-950 text-white font-bold text-[15px] active:scale-[0.98] transition-all"
>
Add · {PRODUCT.price}
</button>
</div>
{/* Safe-area spacer for iOS */}
<div className="h-[env(safe-area-inset-bottom)]" />
</div>
{/* Bottom padding so sticky bar doesn't cover last content on mobile */}
<div className="lg:hidden h-28" aria-hidden />
<StorefrontFooter
brandName={brandName}
brandSlug="tuxedo"
logoUrl={logoUrl}
logoUrlDark={logoUrlDark}
brandAccent="green"
/>
<QuickCartSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
productName={PRODUCT.name}
/>
</div>
);
}
/* ── Sub-components ─────────────────────────────────────────────── */
function TrustColumn({
icon,
eyebrow,
title,
body,
accent,
}: {
icon: React.ReactNode;
eyebrow: string;
title: string;
body: string;
accent?: boolean;
}) {
return (
<div className="relative">
<div
className={`flex h-12 w-12 items-center justify-center rounded-2xl ${
accent ? "bg-[#FCD34D] text-stone-950" : "bg-white/10 text-[#FCD34D]"
} ring-1 ring-white/15`}
>
{icon}
</div>
<p
className={`mt-5 font-mono text-[10px] uppercase tracking-[0.25em] ${
accent ? "text-[#FCD34D]" : "text-white/60"
}`}
>
{eyebrow}
</p>
<h3 className="mt-2 font-display text-[24px] font-bold leading-[1.15] text-white">
{title}
</h3>
<p className="mt-3 text-[14px] leading-relaxed text-white/75 max-w-sm">{body}</p>
</div>
);
}
function Stat({ number, label }: { number: string; label: string }) {
return (
<div>
<p className="font-display text-[40px] font-black leading-none text-stone-950">
{number}
</p>
<p className="mt-1 font-mono text-[10px] uppercase tracking-widest text-stone-500">
{label}
</p>
</div>
);
}
function FAQItem({ q, a }: { q: string; a: string }) {
const [open, setOpen] = useState(false);
return (
<div className="py-5">
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between gap-4 text-left group"
>
<span className="font-display text-[18px] sm:text-[20px] font-bold text-stone-950 group-hover:text-[#1A4D2E] transition-colors">
{q}
</span>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ring-1 ring-stone-900/15 transition-transform duration-300 ${
open ? "rotate-45 bg-stone-950 text-white" : "text-stone-700"
}`}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className="overflow-hidden"
>
<p className="pt-3 text-[15px] leading-relaxed text-stone-700 max-w-prose">{a}</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function Chevron() {
return (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
);
}
function ExpressChip({ label }: { label: string }) {
return (
<div className="flex h-9 items-center justify-center rounded-lg bg-stone-100 ring-1 ring-stone-900/5 text-[11px] font-semibold text-stone-700">
{label}
</div>
);
}
function TruckIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7h11v10H3z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M14 10h4l3 3v4h-7z" />
<circle cx="7" cy="18" r="2" />
<circle cx="17" cy="18" r="2" />
</svg>
);
}
function ShieldIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 2l8 4v6c0 5-3.5 9-8 10-4.5-1-8-5-8-10V6l8-4z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4" />
</svg>
);
}
function SparkleIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3l2 5 5 2-5 2-2 5-2-5-5-2 5-2 2-5z" />
</svg>
);
}
function CornIllustration({ className }: { className?: string }) {
return (
<svg viewBox="0 0 100 140" className={className} aria-hidden="true">
{/* Husk leaves */}
<path
d="M30 70c-12-15-15-40-5-60 5 18 15 30 25 38"
fill="currentColor"
opacity="0.55"
/>
<path
d="M70 70c12-15 15-40 5-60-5 18-15 30-25 38"
fill="currentColor"
opacity="0.55"
/>
<path
d="M50 75c-3-20 0-50 5-65 1 20-1 45-3 60"
fill="currentColor"
opacity="0.45"
/>
{/* Cob */}
<ellipse cx="50" cy="78" rx="20" ry="34" fill="currentColor" />
{/* Kernels */}
<g fill="#FAF6EA" opacity="0.75">
{Array.from({ length: 8 }).map((_, row) =>
Array.from({ length: 5 }).map((_, col) => {
const x = 50 - 14 + col * 7 + (row % 2 ? 3.5 : 0);
const y = 55 + row * 6;
return <circle key={`${row}-${col}`} cx={x} cy={y} r="1.6" />;
})
)}
</g>
{/* Silk at top */}
<g stroke="currentColor" strokeWidth="1.4" fill="none" opacity="0.7" strokeLinecap="round">
<path d="M50 45c-6-8-12-12-18-12" />
<path d="M50 45c0-10 4-15 8-20" />
<path d="M50 45c6-6 12-8 18-10" />
</g>
</svg>
);
}
function FloatingCorn({ className }: { className?: string }) {
return (
<svg viewBox="0 0 60 80" className={className} aria-hidden="true">
<ellipse cx="30" cy="40" rx="14" ry="24" fill="currentColor" />
<g fill="white" opacity="0.3">
<circle cx="26" cy="28" r="1.2" />
<circle cx="32" cy="32" r="1.2" />
<circle cx="28" cy="38" r="1.2" />
<circle cx="34" cy="42" r="1.2" />
<circle cx="26" cy="48" r="1.2" />
<circle cx="32" cy="52" r="1.2" />
<circle cx="28" cy="58" r="1.2" />
</g>
</svg>
);
}
/* ── Static data ─────────────────────────────────────────────── */
const WHY_CORN = [
{ title: "Naturally grown & non-GMO", body: "no lab has touched this seed" },
{ title: "Harvested at peak sugar content", body: "before the heat can steal a calorie" },
{ title: "Hand-selected for size & quality", body: "the only machine we trust is a cooler" },
{ title: "Stays sweet for days in the fridge", body: "and freezes beautifully for winter" },
];
const FAQS = [
{
q: "How fast will my box ship?",
a: "Same-day harvest when the weather cooperates. Youll get a tracking link the moment the cooler leaves the farm — typically within 24 hours of your order.",
},
{
q: "Can I pick up at a local stop instead?",
a: "Yes — at checkout, choose pickup and select a stop near you. We deliver to scheduled stops in Colorado, Utah, New Mexico, and Arizona through the summer.",
},
{
q: "What if my corn isnt the sweetest Ive ever had?",
a: "We replace it. Send a photo to hello@tuxedocorn.com and well send a fresh box the next morning. No forms, no questions.",
},
{
q: "How long does it stay fresh?",
a: "Seven days in the fridge with the husks on. Three months in the freezer if you blanch it for three minutes first.",
},
];
+31 -1
View File
@@ -20,6 +20,8 @@ type StopInfo = {
brand_id: string;
};
export type { StopInfo };
type CartContextType = {
cart: CartItem[];
subtotal: number;
@@ -30,6 +32,12 @@ type CartContextType = {
cartRestored: boolean; // true when server cart was loaded (for toast)
setSelectedStop: (stop: StopInfo | null) => void;
addToCart: (item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship") => void;
/**
* Replaces the cart with a single item (clears other items + selected stop)
* and returns the resulting item so the caller can navigate to /checkout
* for a true 1-tap "Buy Now" flow.
*/
buyNow: (item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship") => CartItem;
increaseQuantity: (id: string) => void;
decreaseQuantity: (id: string) => void;
removeFromCart: (id: string) => void;
@@ -201,6 +209,27 @@ export function CartProvider({ children }: { children: ReactNode }) {
});
}
/**
* Replaces the entire cart with this single item. Use for "Buy Now" /
* "Quick Buy" flows where the buyer wants to skip the cart step and go
* straight to checkout. The returned CartItem reflects the final state
* (existing items of the same product are merged by quantity).
*/
function buyNow(item: Omit<CartItem, "quantity">, fulfillment?: "pickup" | "ship"): CartItem {
// Always clear selected stop on a buy-now — the buyer is bypassing the
// standard stop-picker flow and will pick a stop at checkout.
setSelectedStop(null);
const finalItem: CartItem = {
...item,
quantity: 1,
fulfillment: fulfillment ?? "pickup",
};
setCart([finalItem]);
setJustAdded(finalItem);
return finalItem;
}
function increaseQuantity(id: string) {
setCart((prev) =>
prev.map((item) => (item.id === id ? { ...item, quantity: item.quantity + 1 } : item))
@@ -291,6 +320,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
cartRestored: showRestoredToast,
setSelectedStop: setSelectedStop,
addToCart,
buyNow,
increaseQuantity,
decreaseQuantity,
removeFromCart,
@@ -306,7 +336,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
);
}
export function useCart() {
export function useCart(): CartContextType {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used inside CartProvider");
return context;
+12 -2
View File
@@ -1,9 +1,19 @@
// Shared AdminUser type — safe to import from both server and client components
//
// `brand_id` is the active brand (one of `brand_ids`, or null for platform_admin).
// `brand_ids` is the full list of brands the admin can act in.
// - platform_admin: `brand_id = null`, `brand_ids = []` (in dev) or all brands
// (resolved by `listBrandsForAdmin`).
// - multi_brand_admin: `brand_id` = selected/cookie brand, `brand_ids` = 2+.
// - brand_admin / store_employee / staff: `brand_id` = their single brand,
// `brand_ids = [that one]`.
export type AdminUser = {
id?: string;
user_id: string;
brand_id: string | null;
role: "platform_admin" | "brand_admin" | "store_employee" | "staff";
brand_ids: string[];
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
active: boolean;
can_manage_products: boolean;
can_manage_stops: boolean;
can_manage_orders: boolean;
@@ -15,4 +25,4 @@ export type AdminUser = {
can_manage_reports: boolean;
can_manage_settings: boolean;
must_change_password?: boolean;
};
};
+90
View File
@@ -0,0 +1,90 @@
/**
* Brand-scope helpers for multi-brand admin support.
*
* Resolution order (documented in
* docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md):
* 1. URL/explicit `requested` brand id (highest priority)
* 2. `active_brand_id` cookie (the persistent "what brand am I in right now")
* 3. `adminUser.brand_id` (legacy single-brand fallback)
* 4. First of `adminUser.brand_ids`
* 5. (platform_admin only) `null` → "all brands"
*
* For non-platform-admins, the returned brand is validated against
* `adminUser.brand_ids` — if `requested` or the cookie brand is not in the
* admin's accessible brands, the resolver falls through to a brand the admin
* does have access to (silent recovery).
*/
import "server-only";
import { cookies } from "next/headers";
import type { AdminUser } from "./admin-permissions-types";
export const ACTIVE_BRAND_COOKIE = "active_brand_id";
/**
* Resolve the active brand id for the given admin user.
*
* @param adminUser - The current admin user (must already be loaded).
* @param requested - Optional explicit brand id (e.g. from a URL param).
* When set and the admin has access, wins over cookie.
* @returns The brand id to act in, or `null` for platform_admin "all brands".
*/
export async function getActiveBrandId(
adminUser: AdminUser,
requested?: string | null
): Promise<string | null> {
const cookieStore = await cookies();
const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null;
// platform_admin: requested > cookie > null (all brands)
if (adminUser.role === "platform_admin") {
return requested ?? cookieBrand ?? null;
}
// Non-platform-admin: validate that requested/cookie brands are accessible
if (requested && adminUser.brand_ids.includes(requested)) {
return requested;
}
if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) {
return cookieBrand;
}
// Fall back to the legacy single brand, then first of the membership list
return adminUser.brand_id ?? adminUser.brand_ids[0] ?? null;
}
/**
* Set the persistent active-brand cookie. Should only be called after
* validating the admin has access (use `assertBrandAccess`).
*/
export async function setActiveBrandCookie(brandId: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(ACTIVE_BRAND_COOKIE, brandId, {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30, // 30 days
});
}
/**
* Clear the active-brand cookie. Used when platform_admin selects
* "All brands" (the cookie absence = "no specific brand pinned").
*/
export async function clearActiveBrandCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(ACTIVE_BRAND_COOKIE);
}
/**
* Throws if the admin user is not a platform_admin and does not have the
* given brand in their membership list. Use this for server actions and
* API routes that receive a brandId from URL/form/RPC return rather than
* `getActiveBrandId`.
*/
export function assertBrandAccess(adminUser: AdminUser, brandId: string): void {
if (adminUser.role === "platform_admin") return;
if (!adminUser.brand_ids.includes(brandId)) {
throw new Error("Brand access denied");
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Browser-only Stripe.js loader.
*
* Reads `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and returns a cached
* `loadStripe` promise. Never call this from a server component — it
* pulls in `@stripe/stripe-js` which depends on `window`.
*
* The published key is safe to ship to the browser; the secret key
* never leaves the server (see `src/actions/billing/retail-checkout.ts`).
*/
"use client";
import { loadStripe, type Stripe } from "@stripe/stripe-js";
let stripePromise: Promise<Stripe | null> | null = null;
/**
* Returns the cached Stripe.js promise, creating it on the first call.
* Returns `null` if the publishable key is not configured — callers
* should fall back to the hosted Stripe Checkout flow in that case.
*/
export function getStripe(): Promise<Stripe | null> | null {
if (typeof window === "undefined") return null;
const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
if (!key) return null;
if (!stripePromise) {
stripePromise = loadStripe(key);
}
return stripePromise;
}
/** Synchronous check — useful for deciding whether to render Stripe Elements. */
export function hasStripePublishableKey(): boolean {
return Boolean(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
-- Add seasonal availability date fields to products table
-- Allows products to have start/end availability dates (e.g., "Available mid-July through mid-September")
ALTER TABLE products ADD COLUMN IF NOT EXISTS available_from TIMESTAMPTZ;
ALTER TABLE products ADD COLUMN IF NOT EXISTS available_until TIMESTAMPTZ;
-- Add comment for documentation
COMMENT ON COLUMN products.available_from IS 'Start date for product availability (seasonal)';
COMMENT ON COLUMN products.available_until IS 'End date for product availability (seasonal)';
@@ -0,0 +1,70 @@
-- Migration 202: Fix delete_stop RPC — orders table has no deleted_at column
--
-- The delete_stop RPC added in migration 075 guards against deleting a stop
-- that has active orders:
--
-- SELECT COUNT(*) INTO v_active_order_count
-- FROM orders o JOIN stops s ON o.stop_id = s.id
-- WHERE s.id = p_stop_id
-- AND o.pickup_complete = false
-- AND o.deleted_at IS NULL; -- <-- column does not exist on orders
--
-- The orders table never got a deleted_at column, so the SELECT raises
-- "column o.deleted_at does not exist" and the RPC returns HTTP 400 from
-- PostgREST. Stop deletion has been silently broken since 075 landed.
--
-- Fix: drop the o.deleted_at IS NULL clause. The pickup_complete = false
-- guard is sufficient for now (no soft-deleted orders can exist).
-- If/when orders get a deleted_at column, re-add the clause.
--
-- This migration only touches the function. Schema is unchanged.
CREATE OR REPLACE FUNCTION public.delete_stop(p_stop_id UUID, p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop RECORD;
v_active_order_count INT;
BEGIN
-- Lock the stop row for update
SELECT * INTO v_stop FROM stops WHERE id = p_stop_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Brand scoping: brand_admin can only delete their own stops
IF p_brand_id IS NOT NULL AND v_stop.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
ELSIF p_brand_id IS NULL AND v_stop.brand_id IS NOT NULL THEN
-- Caller didn't pass brand_id but stop has one — guard against platform admin accident
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Guard: refuse to delete a stop that still has active (un-picked-up) orders.
-- Note: orders has no deleted_at column (hard delete pattern) so we only
-- check pickup_complete. Re-add `AND o.deleted_at IS NULL` if/when orders
-- grows a soft-delete column.
SELECT COUNT(*) INTO v_active_order_count
FROM orders o
JOIN stops s ON o.stop_id = s.id
WHERE s.id = p_stop_id
AND o.pickup_complete = false;
IF v_active_order_count > 0 THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Cannot delete stop — it has ' || v_active_order_count || ' active order(s). Mark orders as picked up first.'
);
END IF;
-- Soft delete
UPDATE stops SET deleted_at = NOW() WHERE id = p_stop_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- Grants are inherited from 075; verify they're present (no-op if so).
GRANT EXECUTE ON FUNCTION public.delete_stop(UUID, UUID) TO anon, authenticated, service_role;
+470
View File
@@ -0,0 +1,470 @@
-- Migration 203: Locations table (reusable venues) + admin RPCs
--
-- Purpose
-- -------
-- Today, "where a stop happens" is captured in 3 denormalized fields on the
-- stops table:
-- * location (venue name, e.g. "Tractor Supply")
-- * address (street address, e.g. "13778 E I-25 Frontage Rd")
-- * phone (was never on stops — only enriched from the xlsx Stop Directory)
--
-- That works for one-off inserts but breaks down for the Tuxedo 2026 tour
-- where the same venue (Tractor Supply, Murdoch's, etc.) repeats 5-15 times
-- across the season with the same address/phone/contact. Editing the address
-- means updating N stops. Listing "all unique venues we visit" requires
-- SELECT DISTINCT with a GROUP BY, not a real table.
--
-- This migration introduces a `locations` table — the canonical venue record
-- — and links each stop to it via a nullable location_id FK. Existing
-- denormalized fields on stops are kept (backwards compatible) but new
-- admin paths should prefer location_id.
--
-- The 269 seeded Tuxedo stops are backfilled into 49 unique locations
-- (one per (brand_id, name, address) tuple) by a follow-up script —
-- this migration only creates the schema and RPCs.
--
-- RPCs (all SECURITY DEFINER, all brand-scoped):
-- * admin_create_location — insert single location
-- * admin_create_locations_batch — insert many in one call (mirrors stops pattern)
-- * admin_update_location — partial update of name/address/phone/contact
-- * admin_delete_location — soft delete via deleted_at (mirrors stops)
-- * get_locations_for_brand — public read RPC (filters deleted_at, soft-publish)
-- * admin_attach_location_to_stop — link a stop to a location (idempotent)
BEGIN;
-- =============================================================================
-- 1. locations table
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES public.brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
phone TEXT,
contact_name TEXT,
contact_email TEXT,
notes TEXT,
active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ DEFAULT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Slug per brand for nice URLs (locations/slug-{id}) — derived from name + zip
-- to disambiguate. Same collision-handling pattern as stops.
ALTER TABLE public.locations
ADD COLUMN IF NOT EXISTS slug TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_locations_brand_slug
ON public.locations (brand_id, slug)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_locations_brand_active
ON public.locations (brand_id, active)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_locations_brand_name
ON public.locations (brand_id, lower(name))
WHERE deleted_at IS NULL;
-- updated_at trigger (reuses the generic set_updated_at fn if present)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'set_updated_at') THEN
DROP TRIGGER IF EXISTS trg_locations_updated_at ON public.locations;
CREATE TRIGGER trg_locations_updated_at
BEFORE UPDATE ON public.locations
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
END IF;
END$$;
-- =============================================================================
-- 2. Link stops → locations
-- =============================================================================
ALTER TABLE public.stops
ADD COLUMN IF NOT EXISTS location_id UUID REFERENCES public.locations(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_stops_location_id
ON public.stops (location_id)
WHERE deleted_at IS NULL;
-- =============================================================================
-- 3. RLS for locations
-- =============================================================================
ALTER TABLE public.locations ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "platform_admin_read_locations" ON public.locations;
DROP POLICY IF EXISTS "brand_admin_read_locations" ON public.locations;
DROP POLICY IF EXISTS "platform_admin_all_locations" ON public.locations;
DROP POLICY IF EXISTS "brand_admin_all_locations" ON public.locations;
-- Platform admin sees all non-deleted
CREATE POLICY "platform_admin_read_locations" ON public.locations
FOR SELECT USING (
deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM public.admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
)
);
-- Brand admin / store_employee sees their own brand's non-deleted
CREATE POLICY "brand_admin_read_locations" ON public.locations
FOR SELECT USING (
deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM public.admin_users au
WHERE au.user_id = auth.uid()
AND au.role IN ('brand_admin', 'store_employee')
AND au.brand_id = locations.brand_id
)
);
-- Writes go through SECURITY DEFINER RPCs (no direct INSERT/UPDATE/DELETE from anon)
-- so no INSERT/UPDATE/DELETE policies are granted to anon/authenticated.
-- =============================================================================
-- 4. RPCs
-- =============================================================================
-- ── 4a. admin_create_location ────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.admin_create_location(
p_brand_id UUID,
p_name TEXT,
p_address TEXT DEFAULT NULL,
p_city TEXT DEFAULT NULL,
p_state TEXT DEFAULT NULL,
p_zip TEXT DEFAULT NULL,
p_phone TEXT DEFAULT NULL,
p_contact_name TEXT DEFAULT NULL,
p_contact_email TEXT DEFAULT NULL,
p_notes TEXT DEFAULT NULL,
p_active BOOLEAN DEFAULT true
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_id UUID;
v_slug TEXT;
v_slug_base TEXT;
v_counter INT := 0;
BEGIN
IF p_brand_id IS NULL THEN
RAISE EXCEPTION 'brand_id is required';
END IF;
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
RAISE EXCEPTION 'name is required';
END IF;
v_slug_base := lower(regexp_replace(trim(p_name), '[^a-z0-9]+', '-', 'g'))
|| COALESCE('-' || regexp_replace(COALESCE(p_zip, ''), '[^a-z0-9]+', '', 'gi'), '');
v_slug_base := trim(BOTH '-' FROM v_slug_base);
v_slug := v_slug_base;
WHILE EXISTS (SELECT 1 FROM locations WHERE brand_id = p_brand_id AND slug = v_slug AND deleted_at IS NULL) LOOP
v_counter := v_counter + 1;
v_slug := v_slug_base || '-' || v_counter;
END LOOP;
INSERT INTO public.locations (
brand_id, name, address, city, state, zip, phone,
contact_name, contact_email, notes, active, slug
) VALUES (
p_brand_id, trim(p_name),
NULLIF(trim(p_address), ''),
NULLIF(trim(p_city), ''),
NULLIF(trim(p_state), ''),
NULLIF(trim(p_zip), ''),
NULLIF(trim(p_phone), ''),
NULLIF(trim(p_contact_name), ''),
NULLIF(trim(p_contact_email), ''),
NULLIF(trim(p_notes), ''),
p_active, v_slug
)
RETURNING id INTO v_id;
RETURN jsonb_build_object('id', v_id, 'slug', v_slug);
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'admin_create_location failed: % (SQLSTATE %)', SQLERRM, SQLSTATE;
END;
$$;
-- ── 4b. admin_create_locations_batch ─────────────────────────────────────────
-- Mirrors admin_create_stops_batch. p_locations: JSONB array of objects with
-- the same shape as the single-row params. Returns JSONB array of {id, slug}.
-- Transactional: any per-row failure rolls the whole batch back.
CREATE OR REPLACE FUNCTION public.admin_create_locations_batch(
p_brand_id UUID,
p_locations JSONB
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_row JSONB;
v_result JSONB := '[]'::JSONB;
v_id UUID;
v_slug TEXT;
v_slug_base TEXT;
v_counter INT;
BEGIN
IF p_brand_id IS NULL THEN
RAISE EXCEPTION 'brand_id is required';
END IF;
IF p_locations IS NULL OR jsonb_typeof(p_locations) <> 'array' OR jsonb_array_length(p_locations) = 0 THEN
RETURN '[]'::JSONB;
END IF;
FOR v_row IN SELECT * FROM jsonb_array_elements(p_locations) LOOP
IF v_row->>'name' IS NULL OR length(trim(v_row->>'name')) = 0 THEN
RAISE EXCEPTION 'name is required for all locations';
END IF;
v_slug_base := lower(regexp_replace(trim(v_row->>'name'), '[^a-z0-9]+', '-', 'g'))
|| COALESCE('-' || regexp_replace(COALESCE(v_row->>'zip', ''), '[^a-z0-9]+', '', 'gi'), '');
v_slug_base := trim(BOTH '-' FROM v_slug_base);
v_slug := v_slug_base;
v_counter := 0;
WHILE EXISTS (SELECT 1 FROM locations WHERE brand_id = p_brand_id AND slug = v_slug AND deleted_at IS NULL) LOOP
v_counter := v_counter + 1;
v_slug := v_slug_base || '-' || v_counter;
END LOOP;
INSERT INTO public.locations (
brand_id, name, address, city, state, zip, phone,
contact_name, contact_email, notes, active, slug
) VALUES (
p_brand_id,
trim(v_row->>'name'),
NULLIF(trim(v_row->>'address'), ''),
NULLIF(trim(v_row->>'city'), ''),
NULLIF(trim(v_row->>'state'), ''),
NULLIF(trim(v_row->>'zip'), ''),
NULLIF(trim(v_row->>'phone'), ''),
NULLIF(trim(v_row->>'contact_name'), ''),
NULLIF(trim(v_row->>'contact_email'), ''),
NULLIF(trim(v_row->>'notes'), ''),
COALESCE((v_row->>'active')::BOOLEAN, true),
v_slug
)
RETURNING id INTO v_id;
v_result := v_result || jsonb_build_object('id', v_id, 'slug', v_slug)::JSONB;
END LOOP;
RETURN v_result;
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'admin_create_locations_batch failed: % (SQLSTATE %)', SQLERRM, SQLSTATE;
END;
$$;
-- ── 4c. admin_update_location ────────────────────────────────────────────────
-- Partial update: only fields present in p_updates (JSONB) are written.
-- Example: SELECT admin_update_location('<id>', '<brand_id>', '{"phone": "555-1234"}');
CREATE OR REPLACE FUNCTION public.admin_update_location(
p_location_id UUID,
p_brand_id UUID,
p_updates JSONB
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_existing RECORD;
v_key TEXT;
v_value TEXT;
BEGIN
IF p_location_id IS NULL THEN
RAISE EXCEPTION 'location_id is required';
END IF;
IF p_updates IS NULL OR jsonb_typeof(p_updates) <> 'object' THEN
RAISE EXCEPTION 'updates must be a JSON object';
END IF;
-- Lock + ownership check
SELECT * INTO v_existing FROM public.locations
WHERE id = p_location_id AND deleted_at IS NULL FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
IF p_brand_id IS NOT NULL AND v_existing.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
-- Build dynamic UPDATE — only set fields explicitly present in p_updates
FOR v_key, v_value IN
SELECT key, value FROM jsonb_each_text(p_updates)
WHERE key IN ('name', 'address', 'city', 'state', 'zip', 'phone',
'contact_name', 'contact_email', 'notes', 'active')
LOOP
EXECUTE format(
'UPDATE public.locations SET %I = $1 WHERE id = $2',
v_key
) USING v_value, p_location_id;
END LOOP;
RETURN jsonb_build_object('success', true);
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'admin_update_location failed: % (SQLSTATE %)', SQLERRM, SQLSTATE;
END;
$$;
-- ── 4d. admin_delete_location ────────────────────────────────────────────────
-- Soft delete via deleted_at. If any non-deleted stop still references this
-- location, refuse — caller must reassign or hard-delete those stops first.
CREATE OR REPLACE FUNCTION public.admin_delete_location(
p_location_id UUID,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_loc RECORD;
v_stops_count INT;
BEGIN
SELECT * INTO v_loc FROM public.locations
WHERE id = p_location_id AND deleted_at IS NULL FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
IF p_brand_id IS NOT NULL AND v_loc.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
SELECT COUNT(*) INTO v_stops_count FROM public.stops
WHERE location_id = p_location_id AND deleted_at IS NULL;
IF v_stops_count > 0 THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Cannot delete location — ' || v_stops_count || ' stop(s) still reference it. Reassign or delete those stops first.'
);
END IF;
UPDATE public.locations SET deleted_at = NOW() WHERE id = p_location_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── 4e. get_locations_for_brand ──────────────────────────────────────────────
-- Public read RPC. Mirrors get_public_stops_for_brand.
-- Returns active, non-deleted locations for a brand slug.
CREATE OR REPLACE FUNCTION public.get_locations_for_brand(p_brand_slug TEXT)
RETURNS TABLE (
id UUID,
name TEXT,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
phone TEXT,
contact_name TEXT,
contact_email TEXT,
notes TEXT,
slug TEXT,
stop_count BIGINT
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN QUERY
SELECT
l.id, l.name, l.address, l.city, l.state, l.zip, l.phone,
l.contact_name, l.contact_email, l.notes, l.slug,
(SELECT COUNT(*) FROM stops s
WHERE s.location_id = l.id AND s.deleted_at IS NULL)::BIGINT AS stop_count
FROM locations l
INNER JOIN brands b ON l.brand_id = b.id
WHERE l.deleted_at IS NULL
AND l.active = true
AND b.slug = p_brand_slug
ORDER BY lower(l.name) ASC;
END;
$$;
-- ── 4f. admin_attach_location_to_stop ────────────────────────────────────────
-- One-way link: set stops.location_id. Idempotent.
CREATE OR REPLACE FUNCTION public.admin_attach_location_to_stop(
p_stop_id UUID,
p_location_id UUID,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_stop RECORD;
v_location RECORD;
BEGIN
SELECT * INTO v_stop FROM stops
WHERE id = p_stop_id AND deleted_at IS NULL FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
IF p_brand_id IS NOT NULL AND v_stop.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
SELECT * INTO v_location FROM locations
WHERE id = p_location_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Location not found');
END IF;
IF v_location.brand_id != v_stop.brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Location belongs to a different brand');
END IF;
UPDATE stops SET location_id = p_location_id WHERE id = p_stop_id;
-- Also denormalize the location name + address from the venue so the
-- storefront cards stay useful even when stops.location_id is unset.
UPDATE stops SET location = v_location.name, address = COALESCE(v_location.address, stops.address)
WHERE id = p_stop_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- 5. Grants
-- =============================================================================
GRANT EXECUTE ON FUNCTION public.admin_create_location(
UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN
) TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.admin_create_locations_batch(UUID, JSONB)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.admin_update_location(UUID, UUID, JSONB)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.admin_delete_location(UUID, UUID)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.get_locations_for_brand(TEXT)
TO anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.admin_attach_location_to_stop(UUID, UUID, UUID)
TO anon, authenticated, service_role;
COMMIT;
@@ -0,0 +1,19 @@
-- Migration 204: Add permissive read policy for locations
--
-- Why: The admin Stops & Routes page reads stops via the Supabase JS client
-- (anon key + RLS). The `stops` table has a `Public read stops` policy with
-- qual=true, which lets the anon role see all non-deleted stops — that's how
-- the page works in dev mode (no Supabase auth). The admin_locations tab needs
-- the same behavior so the server component can use the same pattern:
-- supabase.from("locations").select(...)
--
-- Brand scoping is enforced at the application layer in the server component
-- (eq("brand_id", adminUser.brand_id)), matching how stops are scoped.
--
-- Brand-scoped public reads (storefront pages) go through get_locations_for_brand
-- RPC, which filters active=true.
DROP POLICY IF EXISTS "Public read locations" ON public.locations;
CREATE POLICY "Public read locations" ON public.locations
FOR SELECT
USING (true);
@@ -0,0 +1,57 @@
-- Migration 205: admin_list_locations RPC
--
-- Returns ALL non-deleted locations for a brand (including inactive) with a
-- stop_count aggregation. Mirrors get_locations_for_brand but is brand_id-
-- keyed and admin-side (no active filter) so the Locations tab in the admin
-- Stops & Routes page can show inactive venues and draft venues.
--
-- brand_id NULL means platform_admin scope (all brands). This is the
-- standard application-layer brand-scoping pattern (SECURITY DEFINER RPCs
-- bypass RLS; caller is responsible for authorization).
CREATE OR REPLACE FUNCTION public.admin_list_locations(
p_brand_id UUID DEFAULT NULL
)
RETURNS TABLE (
id UUID,
brand_id UUID,
name TEXT,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
phone TEXT,
contact_name TEXT,
contact_email TEXT,
notes TEXT,
active BOOLEAN,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
slug TEXT,
stop_count BIGINT
)
LANGUAGE sql
SECURITY DEFINER
STABLE
SET search_path = public
AS $$
SELECT
l.id, l.brand_id, l.name, l.address, l.city, l.state, l.zip,
l.phone, l.contact_name, l.contact_email, l.notes, l.active,
l.created_at, l.updated_at, l.deleted_at, l.slug,
COALESCE(s.cnt, 0) AS stop_count
FROM public.locations l
LEFT JOIN (
SELECT location_id, COUNT(*) AS cnt
FROM public.stops
WHERE deleted_at IS NULL
GROUP BY location_id
) s ON s.location_id = l.id
WHERE l.deleted_at IS NULL
AND (p_brand_id IS NULL OR l.brand_id = p_brand_id)
ORDER BY lower(l.name) ASC;
$$;
GRANT EXECUTE ON FUNCTION public.admin_list_locations(UUID)
TO anon, authenticated, service_role;
@@ -0,0 +1,142 @@
-- Migration 206: Re-dedupe locations by (name, city, address)
--
-- Problem
-- -------
-- The original backfill (after migration 203) grouped venues by (name, address)
-- only — so "Tractor Supply" in Brighton, CO and "Tractor Supply" in
-- Canon City, CO both got rolled up to one location if they happened to share
-- the same address (or both had a NULL address). The same affected:
-- * Tractor Supply — 58 stops at 10 distinct (city, address) combos
-- * Ace Hardware — 27 stops at 3 distinct combos
-- * Murdoch's Ranch & Home — 25 stops at 3 distinct combos
-- * Big Tool Box — 15 stops at 2 distinct combos
-- * JAX Farm & Ranch — 13 stops at 2 distinct combos
-- Result: 5 of the 26 venues were each represented as a single over-broad
-- location record, hiding the real per-city venue from the Locations tab.
--
-- Fix
-- ---
-- Re-dedupe by (name, city, address). For each unique combo, create a fresh
-- location row, re-link stops to it, and soft-delete the over-grouped originals
-- (which by then have zero stop references). Idempotent: safe to re-run; on
-- re-run the CTE returns no rows and the script is a no-op.
--
-- Result: 41 locations for the Tuxedo brand (up from 26). 269 stops still
-- linked, just to the correct per-city venue record.
DO $$
DECLARE
v_brand_id UUID := '64294306-5f42-463d-a5e8-2ad6c81a96de';
v_inserted INT := 0;
v_relinked INT := 0;
v_soft_del INT := 0;
BEGIN
-- 1. Insert one new location per distinct (name, city, address) tuple that
-- isn't already a current non-deleted location. Skip if a row with the
-- same (brand_id, name, lower(city), lower(address)) already exists.
WITH venues AS (
SELECT DISTINCT
s.brand_id,
s.location AS name,
NULLIF(btrim(COALESCE(s.city, '')), '') AS city,
NULLIF(btrim(COALESCE(s.address, '')), '') AS address
FROM public.stops s
WHERE s.brand_id = v_brand_id
AND s.deleted_at IS NULL
AND s.location IS NOT NULL
AND length(trim(s.location)) > 0
),
ranked AS (
-- Stable, per-(name, city, address) ordering so slugs are reproducible
SELECT
v.*,
row_number() OVER (
PARTITION BY v.brand_id, lower(trim(v.name))
ORDER BY lower(COALESCE(v.city, '')), lower(COALESCE(v.address, ''))
) AS rn
FROM venues v
),
inserted AS (
INSERT INTO public.locations (
brand_id, name, city, address, active, slug
)
SELECT
r.brand_id,
trim(r.name),
r.city,
r.address,
true,
-- Slug: name, optionally suffixed with -city, then a numeric counter
-- only when multiple venues share the same name (e.g. Tractor Supply
-- appears in 10 cities, so we get -1, -2, … -10).
-- Use [[:alnum:]] so uppercase letters aren't stripped (e.g. "Tractor
-- Supply" → "tractor-supply", not "ractor-upply").
trim(BOTH '-' FROM lower(regexp_replace(trim(r.name), '[^[:alnum:]]+', '-', 'g')))
|| CASE
WHEN r.city IS NOT NULL AND length(trim(r.city)) > 0
THEN '-' || trim(BOTH '-' FROM lower(regexp_replace(trim(r.city), '[^[:alnum:]]+', '-', 'g')))
ELSE ''
END
|| CASE WHEN r.rn > 1 THEN '-' || r.rn::text ELSE '' END
FROM ranked r
WHERE NOT EXISTS (
SELECT 1 FROM public.locations l
WHERE l.brand_id = r.brand_id
AND l.name = trim(r.name)
AND l.deleted_at IS NULL
AND lower(COALESCE(l.city, '')) = lower(COALESCE(r.city, ''))
AND lower(COALESCE(l.address, '')) = lower(COALESCE(r.address, ''))
)
RETURNING id, name, city, address
)
SELECT COUNT(*) INTO v_inserted FROM inserted;
RAISE NOTICE 'Inserted % new per-city locations', v_inserted;
-- 2. Re-link each stop to the location whose (name, city, address) matches.
-- Use lower() on both sides for case-insensitive matching.
WITH new_links AS (
SELECT
s.id AS stop_id,
l.id AS location_id
FROM public.stops s
JOIN public.locations l
ON l.brand_id = s.brand_id
AND lower(trim(l.name)) = lower(trim(s.location))
AND lower(COALESCE(l.city, '')) = lower(COALESCE(NULLIF(btrim(s.city), ''), ''))
AND lower(COALESCE(l.address, '')) = lower(COALESCE(NULLIF(btrim(s.address), ''), ''))
AND l.deleted_at IS NULL
WHERE s.brand_id = v_brand_id
AND s.deleted_at IS NULL
)
UPDATE public.stops s
SET location_id = nl.location_id
FROM new_links nl
WHERE s.id = nl.stop_id;
GET DIAGNOSTICS v_relinked = ROW_COUNT;
RAISE NOTICE 'Re-linked % stops to their per-city locations', v_relinked;
-- 3. Soft-delete any location that no longer has a non-deleted stop pointing
-- to it. (These are the over-grouped originals from the previous dedupe.)
UPDATE public.locations l
SET deleted_at = NOW()
WHERE l.brand_id = v_brand_id
AND l.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM public.stops s
WHERE s.location_id = l.id
AND s.deleted_at IS NULL
);
GET DIAGNOSTICS v_soft_del = ROW_COUNT;
RAISE NOTICE 'Soft-deleted % now-orphan locations', v_soft_del;
END$$;
-- Final summary
SELECT
(SELECT COUNT(*) FROM public.locations
WHERE brand_id = '64294306-5f42-463d-a5e8-2ad6c81a96de' AND deleted_at IS NULL) AS locations,
(SELECT COUNT(*) FROM public.stops
WHERE brand_id = '64294306-5f42-463d-a5e8-2ad6c81a96de' AND deleted_at IS NULL AND location_id IS NOT NULL) AS linked_stops,
(SELECT COUNT(*) FROM public.locations
WHERE brand_id = '64294306-5f42-463d-a5e8-2ad6c81a96de' AND deleted_at IS NOT NULL) AS soft_deleted;
@@ -0,0 +1,185 @@
-- Migration 207: Multi-Brand Admin Support
--
-- Adds:
-- 1. New role: 'multi_brand_admin' (functionally equivalent to brand_admin,
-- but disambiguates intent: "manages 2+ brands" vs "manages 1 brand").
-- 2. Junction table: admin_user_brands — replaces the single brand_id
-- implicit invariant. The legacy admin_users.brand_id column is kept
-- for backwards compatibility (see migration 220_*.sql to drop it later).
-- 3. RPCs: add_admin_user_brand / remove_admin_user_brand (manages the
-- junction and auto-promotes/demotes the role based on brand count).
--
-- Design:
-- - Composite PK (admin_user_id, brand_id) enforces uniqueness.
-- - Index on brand_id makes "which admins are in Brand X?" queries fast.
-- - ON DELETE CASCADE for both FKs — deleting an admin or brand cleans up.
-- - added_by is nullable so first-insert rows (admin with no other admin)
-- can still insert their own membership.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Update role CHECK constraint to include multi_brand_admin
-- ═══════════════════════════════════════════════════════════════════════════
-- The constraint name in Supabase's auto-generated schema is conventionally
-- "admin_users_role_check" (the default Postgres naming), but we drop both
-- the canonical and a common alternative to be safe across schema generations.
ALTER TABLE public.admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check;
ALTER TABLE public.admin_users DROP CONSTRAINT IF EXISTS admin_users_role_chk;
ALTER TABLE public.admin_users
ADD CONSTRAINT admin_users_role_check
CHECK (role IN ('platform_admin', 'brand_admin', 'multi_brand_admin', 'store_employee', 'staff'));
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Create admin_user_brands junction table
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS public.admin_user_brands (
admin_user_id UUID NOT NULL REFERENCES public.admin_users(id) ON DELETE CASCADE,
brand_id UUID NOT NULL REFERENCES public.brands(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
added_by UUID REFERENCES public.admin_users(id),
PRIMARY KEY (admin_user_id, brand_id)
);
CREATE INDEX IF NOT EXISTS admin_user_brands_brand_id_idx
ON public.admin_user_brands(brand_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Backfill: copy legacy single-brand admins into the junction
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO public.admin_user_brands (admin_user_id, brand_id)
SELECT id, brand_id FROM public.admin_users
WHERE brand_id IS NOT NULL
ON CONFLICT (admin_user_id, brand_id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Promote anyone with > 1 brand in the junction to multi_brand_admin
-- ═══════════════════════════════════════════════════════════════════════════
UPDATE public.admin_users
SET role = 'multi_brand_admin'
WHERE role = 'brand_admin'
AND id IN (
SELECT admin_user_id FROM public.admin_user_brands
GROUP BY admin_user_id HAVING COUNT(*) > 1
);
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. RLS: enable on the junction (no public read; only platform_admin all)
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE public.admin_user_brands ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_user_brands_platform_admin_all" ON public.admin_user_brands;
CREATE POLICY "admin_user_brands_platform_admin_all" ON public.admin_user_brands
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
AND au.active = true
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
AND au.active = true
)
);
-- Brand admins can read their own membership rows (so the BrandSelector
-- dropdown works without exposing other admins' memberships)
DROP POLICY IF EXISTS "admin_user_brands_self_read" ON public.admin_user_brands;
CREATE POLICY "admin_user_brands_self_read" ON public.admin_user_brands
FOR SELECT TO authenticated
USING (
admin_user_id IN (
SELECT id FROM public.admin_users
WHERE user_id = auth.uid() AND active = true
)
);
-- ═══════════════════════════════════════════════════════════════════════════
-- 6. RPC: add_admin_user_brand
-- Adds an admin to a brand. Auto-promotes to multi_brand_admin if they
-- end up with 2+ brands.
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.add_admin_user_brand(
p_admin_user_id UUID,
p_brand_id UUID,
p_added_by UUID DEFAULT NULL
)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.admin_user_brands (admin_user_id, brand_id, added_by)
VALUES (p_admin_user_id, p_brand_id, p_added_by)
ON CONFLICT (admin_user_id, brand_id) DO NOTHING;
-- Promote to multi_brand_admin if they now have 2+ brands
UPDATE public.admin_users
SET role = 'multi_brand_admin'
WHERE id = p_admin_user_id
AND role = 'brand_admin'
AND (
SELECT COUNT(*) FROM public.admin_user_brands
WHERE admin_user_id = p_admin_user_id
) > 1;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 7. RPC: remove_admin_user_brand
-- Removes an admin from a brand. Auto-demotes to brand_admin if they
-- drop to 1 brand. Does NOT demote to anything else (e.g. staff) — only
-- brand_admin <-> multi_brand_admin transitions.
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.remove_admin_user_brand(
p_admin_user_id UUID,
p_brand_id UUID
)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_remaining_count INT;
BEGIN
DELETE FROM public.admin_user_brands
WHERE admin_user_id = p_admin_user_id
AND brand_id = p_brand_id;
GET DIAGNOSTICS v_remaining_count = ROW_COUNT;
-- If we just removed a row and the user is now down to 1 brand, demote
IF v_remaining_count > 0 THEN
SELECT COUNT(*) INTO v_remaining_count
FROM public.admin_user_brands
WHERE admin_user_id = p_admin_user_id;
IF v_remaining_count = 1 THEN
UPDATE public.admin_users
SET role = 'brand_admin'
WHERE id = p_admin_user_id
AND role = 'multi_brand_admin';
END IF;
END IF;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 8. Refresh PostgREST schema cache
-- ═══════════════════════════════════════════════════════════════════════════
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,73 @@
-- Migration 208: Change stops.date from TIMESTAMPTZ to DATE
--
-- Why
-- ---
-- The `stops.date` column was created as TIMESTAMPTZ but is used throughout
-- the app as a calendar date (no time-of-day component). The mismatch
-- forces every client consumer to do `new Date(s.date + "T00:00:00")` to
-- coerce the timestamp into a local-midnight Date — and that hack silently
-- produces an Invalid Date when Supabase returns a `2026-06-15 00:00:00+00`
-- style string, leaving the Stops & Routes calendar empty.
--
-- Storing the column as DATE makes Supabase return a clean `YYYY-MM-DD`
-- string, which the existing `+ "T00:00:00"` parsing handles correctly.
--
-- The `time` (TEXT, "HH:MM") and `cutoff_time` (TIMESTAMPTZ) columns are
-- unchanged — they legitimately carry time-of-day information.
--
-- Affected RPC
-- -------------
-- `get_public_stops_for_brand` declares `date TIMESTAMPTZ` in its
-- RETURNS TABLE. Recreate it with `date DATE` so the return type matches
-- the underlying column.
-- 1. Drop the existing RPC so we can recreate it with a new return type.
-- PostgreSQL refuses CREATE OR REPLACE when the OUT-parameter row type
-- changes (error 42P13).
DROP FUNCTION IF EXISTS get_public_stops_for_brand(TEXT);
-- 2. Convert the column. USING date::DATE truncates the time component,
-- which is what we want — all existing rows are stored at 00:00 anyway.
ALTER TABLE public.stops
ALTER COLUMN date TYPE DATE USING date::DATE;
-- 3. Recreate the public RPC with the corrected return type.
CREATE OR REPLACE FUNCTION get_public_stops_for_brand(p_brand_slug TEXT)
RETURNS TABLE (
id UUID,
city TEXT,
state TEXT,
date DATE,
"time" TEXT,
location TEXT,
address TEXT,
slug TEXT,
cutoff_time TIMESTAMPTZ
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN QUERY
SELECT
s.id,
s.city,
s.state,
s.date,
s."time",
s.location,
s.address,
s.slug,
s.cutoff_time
FROM stops s
INNER JOIN brands b ON s.brand_id = b.id
WHERE s.active = true
AND b.slug = p_brand_slug
ORDER BY s.date ASC;
END;
$$;
GRANT EXECUTE ON FUNCTION get_public_stops_for_brand(TEXT) TO anon;
GRANT EXECUTE ON FUNCTION get_public_stops_for_brand(TEXT) TO authenticated;
GRANT EXECUTE ON FUNCTION get_public_stops_for_brand(TEXT) TO service_role;