Compare commits
21 Commits
v2.0.0
..
c8fa2e8b52
| Author | SHA1 | Date | |
|---|---|---|---|
| c8fa2e8b52 | |||
| 4ebbc6dacf | |||
| 9458fd0506 | |||
| 83ad6536a3 | |||
| 244551ce70 | |||
| b1d4174721 | |||
| 001840ab05 | |||
| f0a703794a | |||
| 7d1e6f784b | |||
| b5f7252ac0 | |||
| 24cf9a7261 | |||
| 8428f3a490 | |||
| 77fb8fe7ee | |||
| eabc709076 | |||
| 4bd2ed0db2 | |||
| c1396096ad | |||
| a53516bfe6 | |||
| fb23c21ad9 | |||
| 0cf2ee7948 | |||
| 6c1c616c1c | |||
| 4909a78aca |
@@ -176,7 +176,7 @@ jobs:
|
||||
|
||||
# Upload env file and sync build output
|
||||
echo "Uploading env file..."
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no "$ENV_FILE" tyler@route.crispygoat.com:$APP_DIR/.env.production
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no "$ENV_FILE" tyler@route.crispygoat.com:$APP_DIR/.env
|
||||
|
||||
echo "Copying .next/..."
|
||||
scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r .next tyler@route.crispygoat.com:$APP_DIR/
|
||||
|
||||
@@ -47,3 +47,4 @@ playwright-report/
|
||||
.mcp.json
|
||||
.env*
|
||||
public/videos/tuxedo-hero.mp4
|
||||
.neon
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Migration 003: Batch insert RPCs for tour stop / location seeding
|
||||
-- Required by db/seeds/2026-tuxedo-tour-stops.sql and scripts/import-tuxedo-stops.ts
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- admin_create_locations_batch: insert or update locations from tour seed
|
||||
-- Payload shape: { name, address, city, state, zip, phone, contact_name, contact_email, notes, active }[]
|
||||
CREATE OR REPLACE FUNCTION admin_create_locations_batch(p_brand_id UUID, p_locations JSONB)
|
||||
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
DECLARE
|
||||
v_loc JSONB;
|
||||
BEGIN
|
||||
FOR v_loc IN SELECT * FROM jsonb_array_elements(p_locations)
|
||||
LOOP
|
||||
INSERT INTO locations (
|
||||
brand_id, name, address, city, state, zip,
|
||||
phone, contact_name, contact_email, notes, active
|
||||
) VALUES (
|
||||
p_brand_id,
|
||||
v_loc->>'name',
|
||||
v_loc->>'address',
|
||||
v_loc->>'city',
|
||||
v_loc->>'state',
|
||||
NULLIF(v_loc->>'zip', '')::TEXT,
|
||||
v_loc->>'phone',
|
||||
v_loc->>'contact_name',
|
||||
v_loc->>'contact_email',
|
||||
v_loc->>'notes',
|
||||
COALESCE((v_loc->>'active')::BOOLEAN, true)
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- admin_create_stops_batch: insert stops from tour seed
|
||||
-- Payload shape: { city, state, location, date, time, address, zip, cutoff_time, active, notes }[]
|
||||
-- date format: '2026-07-22 00:00:00+00' — cast to DATE
|
||||
CREATE OR REPLACE FUNCTION admin_create_stops_batch(p_brand_id UUID, p_stops JSONB)
|
||||
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
DECLARE
|
||||
v_stop JSONB;
|
||||
BEGIN
|
||||
FOR v_stop IN SELECT * FROM jsonb_array_elements(p_stops)
|
||||
LOOP
|
||||
INSERT INTO stops (
|
||||
brand_id, name, location, address, city, state, zip,
|
||||
date, "time", cutoff_date, status, is_public, notes
|
||||
) VALUES (
|
||||
p_brand_id,
|
||||
COALESCE(NULLIF(v_stop->>'name', ''), (v_stop->>'location')::TEXT || ' - ' || (v_stop->>'city')::TEXT || ', ' || (v_stop->>'state')::TEXT),
|
||||
v_stop->>'location',
|
||||
v_stop->>'address',
|
||||
v_stop->>'city',
|
||||
v_stop->>'state',
|
||||
NULLIF(v_stop->>'zip', '')::TEXT,
|
||||
CASE
|
||||
WHEN v_stop->>'date' IS NULL THEN NULL
|
||||
ELSE LEFT(v_stop->>'date', 10)::DATE
|
||||
END,
|
||||
v_stop->>'time',
|
||||
NULLIF(v_stop->>'cutoff_time', '')::DATE,
|
||||
'active',
|
||||
COALESCE((v_stop->>'active')::BOOLEAN, true),
|
||||
v_stop->>'notes'
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,255 @@
|
||||
# Nested Layout Skeleton
|
||||
|
||||
Visual map of how pages nest inside each other in the Next.js App Router.
|
||||
Each level persists across navigation — children swap, parents stay.
|
||||
|
||||
## Mental model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ app/layout.tsx (root) │
|
||||
│ html / body / fonts / Providers / Toast / CookieBanner │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ No global header. Each route group renders its own chrome. │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┼─────────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Public routes Storefronts Admin
|
||||
(/, /pricing, (/tuxedo/*, (/admin/*)
|
||||
/contact, /login) /indian-river-direct/*)
|
||||
```
|
||||
|
||||
## Full tree
|
||||
|
||||
```
|
||||
src/app/
|
||||
├── layout.tsx ────────────────────────────────────────────────── [ROOT]
|
||||
│ • <html>, <body>
|
||||
│ • next/font: Fraunces (display), Manrope (sans), Fragment_Mono
|
||||
│ • <Providers> (theme, posthog, sentry, etc.)
|
||||
│ • <ToastNotificationContainer />
|
||||
│ • <CookieConsentBanner />
|
||||
│ ─── pages render INSIDE ───
|
||||
│ │
|
||||
│ ├── error.tsx [ROOT-LEVEL] Atelier error page
|
||||
│ ├── loading.tsx [ROOT-LEVEL] "Preparing your harvest"
|
||||
│ ├── not-found.tsx [ROOT-LEVEL] "Off the beaten path"
|
||||
│ │
|
||||
│ ├── page.tsx / LandingPageClient
|
||||
│ │ └── renders its own SiteHeader + Hero + SiteFooter
|
||||
│ │
|
||||
│ ├── login/
|
||||
│ │ ├── page.tsx /login LoginClient (standalone)
|
||||
│ │ └── loading.tsx [isolated]
|
||||
│ │
|
||||
│ ├── cart/
|
||||
│ │ ├── page.tsx /cart CartClient (standalone)
|
||||
│ │ └── loading.tsx
|
||||
│ │
|
||||
│ ├── checkout/
|
||||
│ │ ├── page.tsx /checkout CheckoutClient (standalone)
|
||||
│ │ ├── loading.tsx
|
||||
│ │ └── success/page.tsx /checkout/success
|
||||
│ │
|
||||
│ ├── change-password/page.tsx
|
||||
│ ├── pricing/page.tsx + loading.tsx
|
||||
│ ├── contact/page.tsx + loading.tsx
|
||||
│ ├── blog/page.tsx
|
||||
│ ├── changelog/page.tsx
|
||||
│ ├── roadmap/page.tsx
|
||||
│ ├── waitlist/page.tsx
|
||||
│ ├── security/page.tsx
|
||||
│ ├── privacy-policy/page.tsx
|
||||
│ ├── terms-and-conditions/page.tsx
|
||||
│ ├── maintenance/page.tsx
|
||||
│ │
|
||||
│ ├── ─── STOREFRONTS ─────────────────────────────────────────
|
||||
│ │
|
||||
│ ├── tuxedo/
|
||||
│ │ ├── layout.tsx ─────────────────────── [TUXEDO CHROME]
|
||||
│ │ │ • Dark emerald gradient backdrop
|
||||
│ │ │ • Ambient emerald orb (top-right blur)
|
||||
│ │ │ • <StorefrontHeader> + <StorefrontFooter> per page
|
||||
│ │ │ • BreadcrumbList + LocalBusiness JSON-LD in metadata
|
||||
│ │ │ ─── INSIDE ───
|
||||
│ │ │ ├── error.tsx
|
||||
│ │ │ ├── loading.tsx
|
||||
│ │ │ ├── page.tsx /tuxedo
|
||||
│ │ │ ├── stops/
|
||||
│ │ │ │ ├── page.tsx /tuxedo/stops
|
||||
│ │ │ │ ├── loading.tsx
|
||||
│ │ │ │ └── [slug]/
|
||||
│ │ │ │ ├── page.tsx /tuxedo/stops/:slug
|
||||
│ │ │ │ ├── error.tsx
|
||||
│ │ │ │ └── loading.tsx
|
||||
│ │ │ ├── about/
|
||||
│ │ │ │ ├── layout.tsx
|
||||
│ │ │ │ ├── page.tsx /tuxedo/about
|
||||
│ │ │ │ ├── error.tsx
|
||||
│ │ │ │ └── loading.tsx
|
||||
│ │ │ ├── faq/
|
||||
│ │ │ │ ├── layout.tsx
|
||||
│ │ │ │ ├── page.tsx /tuxedo/faq
|
||||
│ │ │ │ ├── error.tsx
|
||||
│ │ │ │ └── loading.tsx
|
||||
│ │ │ ├── contact/
|
||||
│ │ │ │ ├── layout.tsx
|
||||
│ │ │ │ ├── page.tsx /tuxedo/contact
|
||||
│ │ │ │ ├── error.tsx
|
||||
│ │ │ │ └── loading.tsx
|
||||
│ │ │ ├── products/
|
||||
│ │ │ │ └── [slug]/page.tsx /tuxedo/products/:slug
|
||||
│ │ │ └── time-clock/page.tsx /tuxedo/time-clock
|
||||
│ │
|
||||
│ ├── indian-river-direct/
|
||||
│ │ ├── layout.tsx ─────────────────── [IRD CHROME]
|
||||
│ │ │ • Light blue/white glass gradient backdrop
|
||||
│ │ │ • Three decorative blue orbs
|
||||
│ │ │ • <StorefrontHeader> + <StorefrontFooter> per page
|
||||
│ │ │ • LocalBusiness (FL, family-owned 1985) JSON-LD
|
||||
│ │ │ ─── INSIDE ───
|
||||
│ │ │ ├── error.tsx
|
||||
│ │ │ ├── loading.tsx
|
||||
│ │ │ ├── page.tsx /indian-river-direct
|
||||
│ │ │ ├── stops/
|
||||
│ │ │ │ ├── page.tsx
|
||||
│ │ │ │ └── [slug]/
|
||||
│ │ │ │ ├── page.tsx
|
||||
│ │ │ │ ├── error.tsx
|
||||
│ │ │ │ └── loading.tsx
|
||||
│ │ │ ├── about/ (layout + page + error + loading)
|
||||
│ │ │ ├── faq/ (layout + page + error + loading)
|
||||
│ │ │ └── contact/ (layout + page + error + loading)
|
||||
│ │
|
||||
│ ├── ─── ADMIN ──────────────────────────────────────────────
|
||||
│ │
|
||||
│ └── admin/
|
||||
│ ├── layout.tsx ───────────────────── [ADMIN SHELL]
|
||||
│ │ • dynamic = "force-dynamic" (reads cookies)
|
||||
│ │ • getAdminUser() → AdminSidebar + content area
|
||||
│ │ • ToastProvider + ToastContainer
|
||||
│ │ • Sidebar persists across all /admin/* routes
|
||||
│ │ • Parchment background via .admin-section
|
||||
│ │ ─── INSIDE ───
|
||||
│ │ ├── error.tsx Admin error page
|
||||
│ │ ├── loading.tsx Skeleton: header + stats + table
|
||||
│ │ ├── page.tsx /admin (Dashboard)
|
||||
│ │ │
|
||||
│ │ ├── orders/
|
||||
│ │ │ ├── page.tsx /admin/orders
|
||||
│ │ │ ├── [id]/page.tsx /admin/orders/:id
|
||||
│ │ │ └── new/page.tsx /admin/orders/new
|
||||
│ │ │
|
||||
│ │ ├── products/
|
||||
│ │ │ ├── page.tsx /admin/products
|
||||
│ │ │ ├── [id]/page.tsx
|
||||
│ │ │ ├── new/page.tsx
|
||||
│ │ │ └── import/page.tsx
|
||||
│ │ │
|
||||
│ │ ├── stops/
|
||||
│ │ │ ├── page.tsx /admin/stops
|
||||
│ │ │ ├── [id]/page.tsx
|
||||
│ │ │ └── new/page.tsx
|
||||
│ │ │
|
||||
│ │ ├── communications/
|
||||
│ │ │ ├── loading.tsx
|
||||
│ │ │ ├── page.tsx /admin/communications
|
||||
│ │ │ ├── compose/page.tsx
|
||||
│ │ │ ├── campaigns/[id]/page.tsx
|
||||
│ │ │ ├── contacts/page.tsx
|
||||
│ │ │ ├── logs/page.tsx
|
||||
│ │ │ ├── segments/page.tsx
|
||||
│ │ │ ├── settings/page.tsx
|
||||
│ │ │ ├── templates/page.tsx
|
||||
│ │ │ ├── templates/[id]/page.tsx
|
||||
│ │ │ ├── analytics/page.tsx
|
||||
│ │ │ ├── abandoned-carts/page.tsx
|
||||
│ │ │ └── welcome-sequence/page.tsx
|
||||
│ │ │
|
||||
│ │ ├── settings/ (sub-tree of 13 pages: billing, brand,
|
||||
│ │ │ payments, shipping, ai, apps,
|
||||
│ │ │ integrations, square-sync, ...)
|
||||
│ │ │
|
||||
│ │ ├── route-trace/ (lots, lookup, settings)
|
||||
│ │ ├── me/
|
||||
│ │ ├── pickup/ Store-employee landing
|
||||
│ │ ├── sales/import/
|
||||
│ │ ├── import/
|
||||
│ │ ├── analytics/
|
||||
│ │ ├── command-center/
|
||||
│ │ ├── advanced/
|
||||
│ │ ├── reports/
|
||||
│ │ ├── shipping/
|
||||
│ │ ├── taxes/
|
||||
│ │ ├── time-tracking/
|
||||
│ │ ├── water-log/ (sub-tree)
|
||||
│ │ ├── wholesale/
|
||||
│ │ └── launch-checklist/
|
||||
│ │
|
||||
│ ├── wholesale/ (separate sub-app, not under admin/layout)
|
||||
│ │ ├── login/page.tsx
|
||||
│ │ ├── portal/page.tsx
|
||||
│ │ ├── register/page.tsx
|
||||
│ │ ├── employee/page.tsx
|
||||
│ │ └── payment/{success,cancel}/page.tsx
|
||||
│ │
|
||||
│ ├── water/ (separate sub-app, not under admin/layout)
|
||||
│ │ ├── page.tsx
|
||||
│ │ ├── loading.tsx
|
||||
│ │ └── admin/ (login + page)
|
||||
│ │
|
||||
│ ├── ird/time-clock/ (time-clock for IRD employees)
|
||||
│ ├── trace/[lotNumber]/page.tsx (public route-trace lookup)
|
||||
│ └── protected-example/page.tsx (smoke test for middleware)
|
||||
```
|
||||
|
||||
## Key nesting rules
|
||||
|
||||
1. **The root `app/layout.tsx` is the only one that renders `<html>` and `<body>`**. Every other layout is a child of it.
|
||||
2. **A `layout.tsx` in a folder wraps every `page.tsx` in that folder and all subfolders**. Children swap; layout state persists.
|
||||
3. **`loading.tsx` and `error.tsx` are per-folder** — Next.js shows them when navigating to a sibling `page.tsx`. They re-render as you move between sections.
|
||||
4. **Storefronts are fully isolated**: each `tuxedo/layout.tsx` and `indian-river-direct/layout.tsx` provides its own backdrop and lets individual pages render their own `StorefrontHeader` / `StorefrontFooter`.
|
||||
5. **Admin is a single SPA-feeling shell**: navigating between `/admin/orders` and `/admin/products` keeps the `AdminSidebar` mounted, only the content area swaps.
|
||||
|
||||
## Things that DO NOT yet nest (gaps)
|
||||
|
||||
| Page | Issue | Fix |
|
||||
|---|---|---|
|
||||
| `/cart` | No shared layout with `/checkout` | Add `app/(shop)/layout.tsx` route group to wrap both |
|
||||
| `/wholesale/*` | Wholesale pages have no shared layout | Add `app/wholesale/layout.tsx` with auth gate + nav |
|
||||
| `/water/*` | Water pages have no shared layout | Add `app/water/layout.tsx` |
|
||||
| `/admin/communications/*` | 13 pages share no sub-layout | Could add `app/admin/communications/layout.tsx` with section tabs |
|
||||
| `/admin/settings/*` | 13 settings pages share no sub-layout | Add `app/admin/settings/layout.tsx` with settings sidebar |
|
||||
|
||||
## How to add a nested layout
|
||||
|
||||
```bash
|
||||
# Example: add a settings sub-layout
|
||||
mkdir -p src/app/admin/settings
|
||||
# Create src/app/admin/settings/layout.tsx
|
||||
# It will wrap every page under /admin/settings/* automatically.
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/app/admin/settings/layout.tsx
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[14rem_1fr] gap-6">
|
||||
<SettingsNav /> {/* sticks while content swaps */}
|
||||
<section>{children}</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## How to add parallel routes (modals as overlays)
|
||||
|
||||
```bash
|
||||
mkdir -p 'src/app/@modal'
|
||||
mkdir -p 'src/app/admin/products/(.)new' # intercepts /admin/products/new
|
||||
# Layout returns <>{children}{modal}</>
|
||||
```
|
||||
|
||||
This makes `/admin/products/new` open as a modal over `/admin/products` instead of a hard navigation.
|
||||
+8
-2
@@ -12,8 +12,9 @@ const nextConfig: NextConfig = {
|
||||
// /home/tyler/package-lock.json as the root directory."
|
||||
// The deploy runner's APP_DIR is /home/tyler/route-commerce, so
|
||||
// resolving relative to the project root is correct both locally and
|
||||
// in CI.
|
||||
outputFileTracingRoot: ".",
|
||||
// in CI. We resolve to an absolute path to avoid the warning in
|
||||
// Next.js 16 which prefers absolute paths here.
|
||||
outputFileTracingRoot: __dirname,
|
||||
|
||||
// Enable strict mode
|
||||
reactStrictMode: true,
|
||||
@@ -111,6 +112,11 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
// Enable optimizePackageImports for better bundle size
|
||||
optimizePackageImports: ["lucide-react", "@radix-ui/react-icons", "framer-motion"],
|
||||
// Enable React's <ViewTransition> and Next.js' automatic route
|
||||
// transitions. Combined with the smooth-transition wrappers around
|
||||
// page content (see src/components/transitions), navigation feels
|
||||
// like a single continuous app rather than a sequence of page loads.
|
||||
viewTransition: true,
|
||||
},
|
||||
|
||||
// Compiler options
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"migrate:one": "node scripts/migrate.js",
|
||||
"db:migrate": "node scripts/migrate.js",
|
||||
"db:seed": "tsx db/seed.ts",
|
||||
"db:seed:tour": "node scripts/seed-tuxedo-2026.js",
|
||||
"db:reset": "node scripts/db-reset.js",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"type-check": "npx tsc --noEmit",
|
||||
@@ -29,6 +30,7 @@
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@neondatabase/auth": "^0.4.2-beta",
|
||||
"@neondatabase/serverless": "^1.1.0",
|
||||
"@sentry/nextjs": "^10.55.0",
|
||||
"@stripe/react-stripe-js": "^6.6.0",
|
||||
"@stripe/stripe-js": "^9.7.0",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Cleanup script: remove duplicate stops from the Tuxedo Corn 2026 tour.
|
||||
* Keeps the row with the latest created_at per (brand_id, date, city, state, location, time).
|
||||
* Run: DATABASE_URL="postgresql://..." npx tsx scripts/cleanup-duplicate-stops.ts
|
||||
*/
|
||||
import { Pool } from "pg";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
process.env.DATABASE_ADMIN_URL ??
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// Find duplicate groups (same brand_id, date, city, state, location, time)
|
||||
const dupes = await client.query(`
|
||||
SELECT brand_id::text, date, city, state, location, time, count(*) as cnt, min(created_at::text) as oldest, max(created_at::text) as newest
|
||||
FROM stops
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY brand_id, date, city, state, location, time
|
||||
HAVING count(*) > 1
|
||||
ORDER BY date, city
|
||||
`);
|
||||
|
||||
console.log(`Found ${dupes.rowCount} duplicate groups`);
|
||||
if (dupes.rowCount === 0) {
|
||||
await client.query("COMMIT");
|
||||
console.log("No duplicates found.");
|
||||
return;
|
||||
}
|
||||
|
||||
let totalDeleted = 0;
|
||||
for (const row of dupes.rows) {
|
||||
// Keep the row with the latest created_at, delete the rest
|
||||
const del = await client.query(`
|
||||
DELETE FROM stops
|
||||
WHERE brand_id = $1
|
||||
AND date = $2
|
||||
AND city = $3
|
||||
AND state = $4
|
||||
AND location = $5
|
||||
AND time IS NOT DISTINCT FROM $6
|
||||
AND deleted_at IS NULL
|
||||
AND created_at < (
|
||||
SELECT max(created_at) FROM stops s2
|
||||
WHERE s2.brand_id = stops.brand_id
|
||||
AND s2.date = stops.date
|
||||
AND s2.city = stops.city
|
||||
AND s2.state = stops.state
|
||||
AND s2.location = stops.location
|
||||
AND (s2.time IS NOT DISTINCT FROM stops.time)
|
||||
AND s2.deleted_at IS NULL
|
||||
)
|
||||
`, [row.brand_id, row.date, row.city, row.state, row.location, row.time]);
|
||||
totalDeleted += del.rowCount ?? 0;
|
||||
console.log(` Deleted ${del.rowCount} duplicate(s) for ${row.city}, ${row.state} on ${row.date}`);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log(`\nDone. Total duplicates removed: ${totalDeleted}`);
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
// dotenv load temporarily disabled so shell-provided DATABASE_URL wins for this run
|
||||
// import { config } from "dotenv";
|
||||
// config({ path: ".env.local" });
|
||||
import { Pool } from "pg";
|
||||
import ExcelJS from "exceljs";
|
||||
import * as fs from "fs";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const YEAR = 2026;
|
||||
|
||||
const MONTH_MAP: Record<string, string> = {
|
||||
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
||||
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
|
||||
};
|
||||
|
||||
interface ParsedStop {
|
||||
week: string;
|
||||
region: string;
|
||||
date: string;
|
||||
day: string;
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
time: string;
|
||||
timeRange: string;
|
||||
truck: string;
|
||||
statusText: string;
|
||||
notes: string;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
contact: string | null;
|
||||
}
|
||||
|
||||
function parseExcelDate(s: unknown): string | null {
|
||||
if (!s) return null;
|
||||
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
|
||||
if (!m) return null;
|
||||
const mm = MONTH_MAP[m[1]];
|
||||
if (!mm) return null;
|
||||
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseTimeRange(s: unknown): string {
|
||||
if (!s) return "";
|
||||
let c = String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
|
||||
const m = /^(\d{1,2}:\d{2}\s*[AP]M)/i.exec(c);
|
||||
// Prefer full range for display (e.g. "10:00 AM – 1:00 PM"); fall back to start only
|
||||
return c || (m ? m[1].toUpperCase().replace(" ", " ") : "");
|
||||
}
|
||||
|
||||
function splitCityState(s: unknown): [string, string] {
|
||||
if (!s) return ["", ""];
|
||||
const parts = String(s).split(",").map((p) => p.trim());
|
||||
if (parts.length === 1) return [parts[0], ""];
|
||||
return [parts[0], parts[1]];
|
||||
}
|
||||
|
||||
function isWeekHeader(cells: string[]): boolean {
|
||||
return /^Wk\s/i.test(cells[0] || "") && (cells[3] || "").trim() === "";
|
||||
}
|
||||
|
||||
function isOffRow(cells: string[]): boolean {
|
||||
const d = (cells[3] || "").trim();
|
||||
return /OFF|Cross-?Dock/i.test(d);
|
||||
}
|
||||
|
||||
function isDataRow(cells: string[]): boolean {
|
||||
const day = (cells[3] || "").trim();
|
||||
const city = (cells[4] || "").trim();
|
||||
return !!(day && city && city.includes(","));
|
||||
}
|
||||
|
||||
function slugify(s: string): string {
|
||||
let out = (s || "").toLowerCase();
|
||||
out = out.replace(/[^a-z0-9]+/g, "-");
|
||||
return out.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
async function loadStops(xlsxPath: string): Promise<{ stops: ParsedStop[]; skipped: Record<string, number>; dirCount: number; locations: ParsedLocation[] }> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.readFile(xlsxPath);
|
||||
|
||||
const schedule = wb.getWorksheet("Full Schedule");
|
||||
const directory = wb.getWorksheet("Stop Directory");
|
||||
if (!schedule) throw new Error("Missing 'Full Schedule' sheet");
|
||||
|
||||
// Build directory lookup for address enrichment: "T1|host lower" -> info
|
||||
const dirMap = new Map<string, { address: string | null; phone: string | null; contact: string | null; state: string }>();
|
||||
if (directory) {
|
||||
for (let r = 2; r <= directory.rowCount; r++) {
|
||||
const row = directory.getRow(r);
|
||||
const truck = String(row.getCell(1).value || "").trim();
|
||||
const host = String(row.getCell(4).value || "").trim().toLowerCase();
|
||||
if (!truck || !host) continue;
|
||||
dirMap.set(`${truck}|${host}`, {
|
||||
address: String(row.getCell(5).value || "").trim() || null,
|
||||
phone: String(row.getCell(6).value || "").trim() || null,
|
||||
contact: String(row.getCell(7).value || "").trim() || null,
|
||||
state: String(row.getCell(3).value || "").trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stops: ParsedStop[] = [];
|
||||
const skipped: Record<string, number> = { weekHeader: 0, off: 0, invalid: 0, noDate: 0 };
|
||||
|
||||
for (let r = 4; r <= schedule.rowCount; r++) {
|
||||
const row = schedule.getRow(r);
|
||||
const cells = Array.from({ length: 10 }, (_, i) => String(row.getCell(i + 1).value ?? "").trim());
|
||||
|
||||
if (isWeekHeader(cells)) {
|
||||
skipped.weekHeader++;
|
||||
continue;
|
||||
}
|
||||
if (isOffRow(cells)) {
|
||||
skipped.off++;
|
||||
continue;
|
||||
}
|
||||
if (!isDataRow(cells)) {
|
||||
skipped.invalid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const [wk, region, dateText, day, cityState, host, time, truck, status, notes] = cells;
|
||||
const dateIso = parseExcelDate(dateText);
|
||||
if (!dateIso) {
|
||||
skipped.noDate++;
|
||||
continue;
|
||||
}
|
||||
const [city, state] = splitCityState(cityState);
|
||||
if (!city) {
|
||||
skipped.invalid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${truck}|${host.toLowerCase()}`;
|
||||
const d = dirMap.get(key);
|
||||
|
||||
stops.push({
|
||||
week: wk,
|
||||
region,
|
||||
date: dateIso,
|
||||
day,
|
||||
city,
|
||||
state: state || (d?.state || ""),
|
||||
location: host,
|
||||
time: parseTimeRange(time),
|
||||
timeRange: time,
|
||||
truck,
|
||||
statusText: status,
|
||||
notes: notes || "",
|
||||
address: d?.address || null,
|
||||
phone: d?.phone || null,
|
||||
contact: d?.contact || null,
|
||||
});
|
||||
}
|
||||
|
||||
return { stops, skipped, dirCount: dirMap.size, locations: loadLocations(directory) };
|
||||
}
|
||||
|
||||
export type ParsedLocation = {
|
||||
name: string;
|
||||
address: string | null;
|
||||
city: string;
|
||||
state: string;
|
||||
phone: string | null;
|
||||
contactName: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
function loadLocations(directory: ExcelJS.Worksheet | undefined): ParsedLocation[] {
|
||||
if (!directory) return [];
|
||||
const byKey = new Map<string, ParsedLocation>();
|
||||
for (let r = 2; r <= directory.rowCount; r++) {
|
||||
const row = directory.getRow(r);
|
||||
const truck = String(row.getCell(1).value || "").trim();
|
||||
const city = String(row.getCell(2).value || "").trim();
|
||||
const state = String(row.getCell(3).value || "").trim();
|
||||
const host = String(row.getCell(4).value || "").trim();
|
||||
const address = String(row.getCell(5).value || "").trim() || null;
|
||||
let phone = String(row.getCell(6).value || "").trim() || null;
|
||||
const contact = String(row.getCell(7).value || "").trim() || null;
|
||||
// notes column is 12
|
||||
let notes = String(row.getCell(12).value || "").trim() || null;
|
||||
|
||||
if (!host || !city) continue;
|
||||
|
||||
// If phone field contains TBD email, move it to notes
|
||||
if (phone && /@/.test(phone) && (!notes || notes.length < 10)) {
|
||||
notes = phone;
|
||||
phone = null;
|
||||
}
|
||||
|
||||
const key = `${host.toLowerCase()}|${city.toLowerCase()}|${state.toLowerCase()}`;
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, {
|
||||
name: host,
|
||||
address,
|
||||
city,
|
||||
state,
|
||||
phone,
|
||||
contactName: contact || null,
|
||||
notes,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
|
||||
function toLocationRpcRow(l: ParsedLocation) {
|
||||
return {
|
||||
name: l.name,
|
||||
address: l.address,
|
||||
city: l.city,
|
||||
state: l.state,
|
||||
zip: null,
|
||||
phone: l.phone,
|
||||
contact_name: l.contactName,
|
||||
contact_email: null,
|
||||
notes: l.notes,
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toRpcRow(s: ParsedStop) {
|
||||
// Date format matches the python seed script expectation for the RPC
|
||||
const dateWithTz = `${s.date} 00:00:00+00`;
|
||||
const combinedNotes = [
|
||||
s.notes,
|
||||
s.truck ? `Truck: ${s.truck}` : "",
|
||||
s.statusText ? `Status: ${s.statusText}` : "",
|
||||
s.phone ? `Phone: ${s.phone}` : "",
|
||||
s.contact ? `Contact: ${s.contact}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
|
||||
return {
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
location: s.location,
|
||||
date: dateWithTz,
|
||||
time: s.time || s.timeRange || "",
|
||||
address: s.address,
|
||||
zip: null,
|
||||
cutoff_time: null,
|
||||
// active=true makes them visible on the public /tuxedo/stops storefront immediately
|
||||
active: true,
|
||||
// forward notes when the RPC supports it (enriched with truck/status/contact)
|
||||
notes: combinedNotes || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run") || args.includes("-n");
|
||||
let xlsxPath = "./Tuxedo_Corn_2026_Tour_Schedule-3.xlsx";
|
||||
const xArg = args.findIndex((a) => a === "--xlsx" || a === "-x");
|
||||
if (xArg !== -1 && args[xArg + 1]) xlsxPath = args[xArg + 1];
|
||||
const doClean = !args.includes("--no-clean");
|
||||
const emitSql = args.includes("--emit-sql") || args.includes("--sql");
|
||||
|
||||
console.log(`[import-tuxedo-stops] xlsx=${xlsxPath} dryRun=${dryRun} clean=${doClean} emitSql=${emitSql}`);
|
||||
|
||||
const { stops, skipped, dirCount, locations: parsedLocations } = await loadStops(xlsxPath);
|
||||
|
||||
console.log(`\nParsed ${stops.length} stops + ${parsedLocations.length} unique locations`);
|
||||
console.log(`Skipped: week-headers=${skipped.weekHeader}, off/cross-dock=${skipped.off}, invalid=${skipped.invalid}, no-date=${skipped.noDate}`);
|
||||
console.log(`Stop Directory raw entries: ${dirCount}\n`);
|
||||
|
||||
if (!stops.length) {
|
||||
console.error("No stops to insert. Check the xlsx path and filters.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Show samples
|
||||
console.log("Sample (first 3):");
|
||||
stops.slice(0, 3).forEach((s) => {
|
||||
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
|
||||
if (s.address) console.log(` addr: ${s.address}`);
|
||||
});
|
||||
console.log("\nSample (last 3):");
|
||||
stops.slice(-3).forEach((s) => {
|
||||
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
|
||||
});
|
||||
|
||||
// By week/region/truck
|
||||
const byWeek: Record<string, number> = {};
|
||||
const byRegion: Record<string, number> = {};
|
||||
const byTruck: Record<string, number> = {};
|
||||
for (const s of stops) {
|
||||
byWeek[s.week] = (byWeek[s.week] || 0) + 1;
|
||||
byRegion[s.region] = (byRegion[s.region] || 0) + 1;
|
||||
byTruck[s.truck] = (byTruck[s.truck] || 0) + 1;
|
||||
}
|
||||
console.log("\nBy week:", Object.fromEntries(Object.entries(byWeek).sort(([a], [b]) => Number(a) - Number(b))));
|
||||
console.log("By region:", byRegion);
|
||||
console.log("By truck:", byTruck);
|
||||
|
||||
const dates = [...stops.map((s) => s.date)].sort();
|
||||
console.log(`\nDate range: ${dates[0]} → ${dates[dates.length - 1]}`);
|
||||
|
||||
if (emitSql) {
|
||||
const BATCH = 50;
|
||||
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
|
||||
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`;
|
||||
sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`;
|
||||
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n`;
|
||||
sql += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
|
||||
sql += `BEGIN;\n\n`;
|
||||
|
||||
// Locations first (master directory)
|
||||
if (parsedLocations.length > 0) {
|
||||
sql += `-- ===== LOCATIONS (Stop Directory master records) =====\n`;
|
||||
sql += `DELETE FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}';\n\n`;
|
||||
const locPayload = parsedLocations.map(toLocationRpcRow);
|
||||
const locJson = JSON.stringify(locPayload);
|
||||
sql += `-- ${parsedLocations.length} unique locations from Stop Directory\n`;
|
||||
sql += `SELECT admin_create_locations_batch('${TUXEDO_BRAND_ID}'::uuid, $$${locJson}$$::jsonb);\n\n`;
|
||||
}
|
||||
|
||||
// Stops (dated instances)
|
||||
sql += `-- ===== STOPS (2026 tour schedule) =====\n`;
|
||||
sql += `-- Clear prior 2026 tour stops for brand (date-scoped)\n`;
|
||||
sql += `DELETE FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
|
||||
for (let i = 0; i < stops.length; i += BATCH) {
|
||||
const batchStops = stops.slice(i, i + BATCH);
|
||||
const payload = batchStops.map(toRpcRow);
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const bnum = Math.floor(i / BATCH) + 1;
|
||||
sql += `-- stops batch ${bnum}/${Math.ceil(stops.length / BATCH)} (${payload.length})\n`;
|
||||
sql += `SELECT admin_create_stops_batch('${TUXEDO_BRAND_ID}'::uuid, $$${payloadJson}$$::jsonb);\n\n`;
|
||||
}
|
||||
sql += `-- Make stops visible (RPC inserts as draft + active=true in payload)\n`;
|
||||
sql += `UPDATE stops SET status = 'active' WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
|
||||
sql += `COMMIT;\n`;
|
||||
const outPath = "db/seeds/2026-tuxedo-tour-stops.sql";
|
||||
fs.writeFileSync(outPath, sql, "utf8");
|
||||
console.log(`\nWrote ${outPath} (locations: ${parsedLocations.length}, stops: ${stops.length}).`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
const stopBatches = Math.ceil(stops.length / 50);
|
||||
console.log(`\n[DRY RUN] Would load for brand:\n - ${parsedLocations.length} locations (full replace)\n - DELETE date-scoped 2026 stops then INSERT ${stops.length} stops in ${stopBatches} batch(es)\n via admin_*_batch RPCs.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
process.env.DATABASE_ADMIN_URL ??
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||
});
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
if (doClean) {
|
||||
const del = await client.query(
|
||||
`DELETE FROM stops WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
|
||||
[TUXEDO_BRAND_ID],
|
||||
);
|
||||
console.log(`\nCleared ${del.rowCount} prior 2026-dated stops for Tuxedo brand.`);
|
||||
}
|
||||
|
||||
// Locations (from Stop Directory) — full replace for the brand from this xlsx
|
||||
if (parsedLocations.length > 0) {
|
||||
const locDel = await client.query(
|
||||
`DELETE FROM locations WHERE brand_id = $1`,
|
||||
[TUXEDO_BRAND_ID],
|
||||
);
|
||||
console.log(`Cleared ${locDel.rowCount} previous locations for Tuxedo brand.`);
|
||||
|
||||
const LOC_BATCH = 100; // locations are fewer and smaller
|
||||
let locTotal = 0;
|
||||
const locBatches = Math.ceil(parsedLocations.length / LOC_BATCH);
|
||||
for (let i = 0; i < parsedLocations.length; i += LOC_BATCH) {
|
||||
const batchLocs = parsedLocations.slice(i, i + LOC_BATCH);
|
||||
const payload = batchLocs.map(toLocationRpcRow);
|
||||
const bnum = Math.floor(i / LOC_BATCH) + 1;
|
||||
process.stdout.write(` Locations batch ${bnum}/${locBatches} (${payload.length}) via admin_create_locations_batch... `);
|
||||
try {
|
||||
await client.query(
|
||||
"SELECT admin_create_locations_batch($1::uuid, $2::jsonb)",
|
||||
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
|
||||
);
|
||||
locTotal += payload.length;
|
||||
console.log("OK");
|
||||
} catch (e: any) {
|
||||
console.log("FAIL");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
console.log(` Inserted ${locTotal} locations.`);
|
||||
}
|
||||
|
||||
const BATCH = 50;
|
||||
let total = 0;
|
||||
const batches = Math.ceil(stops.length / BATCH);
|
||||
|
||||
for (let i = 0; i < stops.length; i += BATCH) {
|
||||
const batchStops = stops.slice(i, i + BATCH);
|
||||
const payload = batchStops.map(toRpcRow);
|
||||
const bnum = Math.floor(i / BATCH) + 1;
|
||||
|
||||
process.stdout.write(` Batch ${bnum}/${batches} (${payload.length}) via admin_create_stops_batch... `);
|
||||
try {
|
||||
await client.query(
|
||||
"SELECT admin_create_stops_batch($1::uuid, $2::jsonb)",
|
||||
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
|
||||
);
|
||||
total += payload.length;
|
||||
console.log("OK");
|
||||
} catch (e: any) {
|
||||
console.log("FAIL");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// RPC inserts with status='draft' (per prior script); flip to active for public visibility.
|
||||
// Scope by the tour date range so we only touch rows from this import.
|
||||
if (total > 0) {
|
||||
const pub = await client.query(
|
||||
`UPDATE stops SET status = 'active' WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
|
||||
[TUXEDO_BRAND_ID],
|
||||
);
|
||||
console.log(`\nPublished (status=active): ${pub.rowCount} stops.`);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log(`\nDone. Inserted ${parsedLocations.length} locations + ${total} stops for Tuxedo Corn 2026 tour (brand ${TUXEDO_BRAND_ID}).`);
|
||||
console.log("Locations are the master directory; stops are the dated schedule instances.");
|
||||
console.log("They should now appear on /tuxedo/stops and in admin locations UI.");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("\nInsert failed (rolled back):", err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Seed Tuxedo Corn 2026 tour: locations + stops.
|
||||
*
|
||||
* Uses sql`...` tagged template from @neondatabase/serverless (WebSocket mode)
|
||||
* for INSERTs — this is the only Neon serverless API that actually executes writes.
|
||||
*
|
||||
* Run with:
|
||||
* node scripts/seed-tuxedo-2026.js
|
||||
*
|
||||
* Requires DATABASE_URL in .env.local (or pass explicitly).
|
||||
*/
|
||||
require("dotenv").config({ path: ".env.local" });
|
||||
const { neon } = require("@neondatabase/serverless");
|
||||
const ExcelJS = require("exceljs");
|
||||
const path = require("path");
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const YEAR = 2026;
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("❌ DATABASE_URL not set in .env.local");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cleanUrl = url.replace(/[?&]channel_binding=require/gi, "").trim();
|
||||
// Use direct compute endpoint with WebSocket for full DDL + DML support
|
||||
const directUrl = cleanUrl.replace("-pooler.", ".");
|
||||
const sql = neon(directUrl, { webSocket: true });
|
||||
|
||||
const MONTH_MAP = {
|
||||
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
||||
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
|
||||
};
|
||||
|
||||
function parseExcelDate(s) {
|
||||
if (!s) return null;
|
||||
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
|
||||
if (!m) return null;
|
||||
const mm = MONTH_MAP[m[1]];
|
||||
if (!mm) return null;
|
||||
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseTimeRange(s) {
|
||||
if (!s) return "";
|
||||
return String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
async function parseXlsx(xlsxPath) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(xlsxPath);
|
||||
|
||||
// --- Locations from Stop Directory ---
|
||||
const locSheet = workbook.getWorksheet("Stop Directory");
|
||||
const locations = [];
|
||||
const seenLoc = new Set();
|
||||
|
||||
if (locSheet) {
|
||||
locSheet.eachRow((row, rowNum) => {
|
||||
if (rowNum === 1) return;
|
||||
// Column mapping via getCell():
|
||||
// col1=Truck, col2=City, col3=State, col4=Host(venue name), col5=Address, col6=Phone, col7=Contact, col12=Notes
|
||||
const rawCity = String(row.getCell(2).value || "").trim();
|
||||
const rawState = String(row.getCell(3).value || "").trim();
|
||||
const rawName = String(row.getCell(4).value || "").trim();
|
||||
const rawAddress = String(row.getCell(5).value || "").trim();
|
||||
if (!rawCity || !rawName) return;
|
||||
const key = `${rawName}|${rawCity}|${rawState}`;
|
||||
if (seenLoc.has(key)) return;
|
||||
seenLoc.add(key);
|
||||
locations.push({
|
||||
name: rawName,
|
||||
address: rawAddress || null,
|
||||
city: rawCity,
|
||||
state: rawState,
|
||||
zip: null,
|
||||
phone: String(row.getCell(6).value || "").trim() || null,
|
||||
contact_name: String(row.getCell(7).value || "").trim() || null,
|
||||
contact_email: null,
|
||||
notes: String(row.getCell(12).value || "").trim() || null,
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
console.log(`Parsed ${locations.length} locations`);
|
||||
|
||||
// --- Stops from Full Schedule ---
|
||||
const schedSheet = workbook.getWorksheet("Full Schedule");
|
||||
const stops = [];
|
||||
const venueAddressMap = new Map();
|
||||
|
||||
if (locSheet) {
|
||||
locSheet.eachRow((row, rowNum) => {
|
||||
if (rowNum === 1) return;
|
||||
const name = String(row.getCell(4).value || "").trim();
|
||||
const address = String(row.getCell(5).value || "").trim();
|
||||
if (name && address) venueAddressMap.set(name, address);
|
||||
});
|
||||
}
|
||||
|
||||
if (schedSheet) {
|
||||
schedSheet.eachRow((row) => {
|
||||
const rowValues = row.values || [];
|
||||
// Column mapping (1-indexed, col1=null):
|
||||
// col2=Wk, col3=Type, col4=Date, col5=Day, col6=City/Location, col7=Host/Venue, col8=Time, col9=Truck, col10=Status, col11=Notes
|
||||
const firstCell = String(rowValues[1] || "").trim();
|
||||
// Skip title rows, week headers ("Wk 1"), OFF, Cross-Dock
|
||||
if (!firstCell || /^Wk\s*\d+/i.test(firstCell) || firstCell === "OFF" || firstCell === "Cross-Dock") return;
|
||||
|
||||
const dateStr = parseExcelDate(rowValues[3]);
|
||||
if (!dateStr) return;
|
||||
|
||||
const cityStateRaw = String(rowValues[5] || "").trim();
|
||||
const cityParts = cityStateRaw.split(",").map((s) => s.trim());
|
||||
const city = cityParts[0] || "";
|
||||
const state = cityParts[1] || "";
|
||||
const location = String(rowValues[6] || "").trim();
|
||||
if (!city || !state || !location) return;
|
||||
|
||||
const rawTime = rowValues[7];
|
||||
const time = rawTime ? parseTimeRange(rawTime) : "";
|
||||
const truck = String(rowValues[8] || "").trim();
|
||||
const statusText = String(rowValues[9] || "").trim();
|
||||
const notesText = String(rowValues[10] || "").trim();
|
||||
|
||||
const venueAddress = venueAddressMap.get(location) || null;
|
||||
const notes = [truck ? `Truck: ${truck}` : "", statusText, notesText].filter(Boolean).join(" | ");
|
||||
|
||||
stops.push({
|
||||
name: `${location} - ${city}, ${state}`,
|
||||
location,
|
||||
address: venueAddress,
|
||||
city,
|
||||
state,
|
||||
zip: null,
|
||||
date: dateStr,
|
||||
time,
|
||||
cutoff_date: null,
|
||||
status: "active",
|
||||
is_public: true,
|
||||
notes: notes || null,
|
||||
});
|
||||
});
|
||||
}
|
||||
console.log(`Parsed ${stops.length} stops`);
|
||||
return { locations, stops };
|
||||
}
|
||||
|
||||
async function seedLocations(locs) {
|
||||
if (locs.length === 0) return;
|
||||
await sql`DELETE FROM locations WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid`;
|
||||
for (const loc of locs) {
|
||||
await sql`INSERT INTO locations (brand_id, name, address, city, state, zip, phone, contact_name, contact_email, notes, active)
|
||||
VALUES (${TUXEDO_BRAND_ID}::uuid, ${loc.name}, ${loc.address}, ${loc.city}, ${loc.state}, ${loc.zip}, ${loc.phone}, ${loc.contact_name}, ${loc.contact_email}, ${loc.notes}, ${loc.active})`;
|
||||
}
|
||||
console.log(`Seeded ${locs.length} locations`);
|
||||
}
|
||||
|
||||
async function seedStops(stops) {
|
||||
if (stops.length === 0) return;
|
||||
const minDate = `${YEAR}-07-01`;
|
||||
const maxDate = `${YEAR}-09-30`;
|
||||
await sql`DELETE FROM stops WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid AND date >= ${minDate} AND date <= ${maxDate}`;
|
||||
for (const stop of stops) {
|
||||
await sql`INSERT INTO stops (brand_id, name, location, address, city, state, zip, date, "time", cutoff_date, status, is_public, notes)
|
||||
VALUES (${TUXEDO_BRAND_ID}::uuid, ${stop.name}, ${stop.location}, ${stop.address}, ${stop.city}, ${stop.state}, ${stop.zip}, ${stop.date}, ${stop.time}, ${stop.cutoff_date}, ${stop.status}, ${stop.is_public}, ${stop.notes})`;
|
||||
}
|
||||
console.log(`Seeded ${stops.length} stops`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const xlsxPath = path.join(__dirname, "..", "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx");
|
||||
console.log("Parsing:", xlsxPath);
|
||||
const { locations, stops } = await parseXlsx(xlsxPath);
|
||||
if (locations.length === 0 && stops.length === 0) {
|
||||
console.error("❌ No data parsed from xlsx");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Seeding ${locations.length} locations + ${stops.length} stops for Tuxedo Corn...`);
|
||||
await seedLocations(locations);
|
||||
await seedStops(stops);
|
||||
const locCount = await sql.query(`SELECT COUNT(*) FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
|
||||
const stopCount = await sql.query(`SELECT COUNT(*) FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
|
||||
console.log(`\n✅ Done — ${locCount[0].count} locations, ${stopCount[0].count} stops in DB`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("❌ Seed failed:", e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -38,10 +38,7 @@ MONTH_MAP = {
|
||||
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
|
||||
}
|
||||
|
||||
DEFAULT_XLSX = (
|
||||
"/home/coder/dev/x1/kyle/route_commerce-main/"
|
||||
"Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
|
||||
)
|
||||
DEFAULT_XLSX = "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" # run from repo root; override with --xlsx /path/to/file.xlsx
|
||||
|
||||
|
||||
def parse_excel_date(s):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import CommunicationsLoading from "@/components/admin/CommunicationsLoading";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return <CommunicationsLoading activeTab="campaigns" />;
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { getSession } from "@/lib/auth";
|
||||
import "@/styles/admin-design-system.css";
|
||||
import { ToastProvider } from "@/components/admin/Toast";
|
||||
import { ToastContainer } from "@/components/admin/ToastContainer";
|
||||
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
|
||||
import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer";
|
||||
|
||||
// Admin layout calls getAdminUser() which reads cookies(). Without this,
|
||||
// Next.js tries to prerender the entire /admin/* tree statically and the
|
||||
@@ -105,8 +107,17 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
activeBrandId={activeBrandId}
|
||||
brands={brands}
|
||||
/>
|
||||
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
{children}
|
||||
{/* The main content area swaps on every navigation. Wrapping
|
||||
it in <SmoothViewTransition> opts the swap into a soft
|
||||
crossfade via the View Transitions API — sidebar and
|
||||
background stay mounted, only the body fades. */}
|
||||
<div
|
||||
id="page-content"
|
||||
className="min-h-screen lg:pl-60 admin-section outline-none"
|
||||
style={{ backgroundColor: "var(--admin-bg)" }}
|
||||
>
|
||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||
<RouteAnnouncer />
|
||||
</div>
|
||||
</ToastProviderWrapper>
|
||||
);
|
||||
|
||||
+11
-44
@@ -1,47 +1,14 @@
|
||||
import LoadingSkeleton, { SkeletonCard, SkeletonTable } from "@/components/shared/LoadingSkeleton";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/**
|
||||
* Admin shell loading state.
|
||||
*
|
||||
* Replaces the previous skeleton (header + 4 stat cards + 2 panels +
|
||||
* 5-row table) with a 1px-thick animated bar at the top of the page.
|
||||
* The AdminSidebar and parchment background stay mounted, so the
|
||||
* transition is a subtle fade — the user never sees an "empty" version
|
||||
* of the admin while the next page streams in.
|
||||
*/
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<main className="min-h-screen px-4 sm:px-6 md:px-8 py-8" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<LoadingSkeleton variant="text" width="w-48" height="h-8" />
|
||||
<LoadingSkeleton variant="text" width="w-64" height="h-4" />
|
||||
</div>
|
||||
<LoadingSkeleton variant="button" />
|
||||
</div>
|
||||
|
||||
{/* Stats cards skeleton */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl border bg-white p-5"
|
||||
style={{ borderColor: "var(--admin-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-pulse rounded-xl h-10 w-10" style={{ backgroundColor: "var(--admin-bg)" }} />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="animate-pulse h-3 w-16 rounded" style={{ backgroundColor: "var(--admin-bg)" }} />
|
||||
<div className="animate-pulse h-5 w-12 rounded" style={{ backgroundColor: "var(--admin-bg)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content cards skeleton */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<SkeletonTable rows={8} cols={4} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||
import { getDashboardStats } from "@/actions/dashboard";
|
||||
import DashboardClient from "@/components/admin/DashboardClient";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
@@ -122,6 +123,10 @@ export default async function AdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch dashboard stats server-side to eliminate client-side waterfall.
|
||||
// Stats are now available immediately — no "—" placeholder flash.
|
||||
const stats = await getDashboardStats();
|
||||
|
||||
return (
|
||||
<DashboardClient
|
||||
brandId={adminUser?.brand_id ?? null}
|
||||
@@ -131,6 +136,7 @@ export default async function AdminPage() {
|
||||
enabledAddons={enabledAddons}
|
||||
usage={usage}
|
||||
limits={limits}
|
||||
stats={stats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { pool } from "@/lib/db";
|
||||
import StopEditForm from "@/components/admin/StopEditForm";
|
||||
import StopProductAssignment from "@/components/admin/StopProductAssignment";
|
||||
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
|
||||
@@ -26,7 +26,6 @@ interface Stop {
|
||||
cutoff_time: string | null;
|
||||
status: string;
|
||||
location: string;
|
||||
slug: string;
|
||||
active: boolean;
|
||||
brands?: { name: string; slug: string };
|
||||
}
|
||||
@@ -48,15 +47,6 @@ interface ProductStop {
|
||||
export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const [{ data: stop, error }, { data: brands }] = await Promise.all([
|
||||
supabase
|
||||
.from("stops")
|
||||
.select("*, brands(name, slug)")
|
||||
.eq("id", id)
|
||||
.single() as unknown as { data: Stop | null; error: { message: string } | null },
|
||||
supabase.from("brands").select("id, name, slug") as unknown as { data: { id: string; name: string; slug: string }[] | null },
|
||||
]);
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) {
|
||||
@@ -65,17 +55,24 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
|
||||
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
||||
|
||||
if (adminUser.brand_id && stop?.brand_id !== adminUser.brand_id) {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
// Fetch stop
|
||||
const { rows: stopsRows } = await pool.query<Stop & { brand_name: string; brand_slug: string }>(
|
||||
`SELECT s.*, b.name as brand_name, b.slug as brand_slug
|
||||
FROM stops s
|
||||
LEFT JOIN brands b ON b.id = s.brand_id
|
||||
WHERE s.id = $1
|
||||
LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
const stopRow = stopsRows[0];
|
||||
|
||||
if (error || !stop) {
|
||||
if (!stopRow) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-600">Stop not found</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
|
||||
{error?.message ?? "Stop not found"}
|
||||
{id}
|
||||
</pre>
|
||||
<Link
|
||||
href="/admin/stops"
|
||||
@@ -88,30 +85,68 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const [{ data: allProducts }, { data: productStops }] = await Promise.all([
|
||||
supabase
|
||||
.from("products")
|
||||
.select("id, name, type, price")
|
||||
.eq("brand_id", stop.brand_id)
|
||||
.eq("active", true) as unknown as { data: Product[] | null },
|
||||
supabase
|
||||
.from("product_stops")
|
||||
.select("id, product_id, products(id, name, type, price)")
|
||||
.eq("stop_id", id) as unknown as { data: ProductStop[] | null },
|
||||
]);
|
||||
if (adminUser.brand_id && stopRow.brand_id !== adminUser.brand_id) {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
|
||||
const assignedProducts = (productStops ?? [])
|
||||
.map((ps: ProductStop) => ({
|
||||
id: ps.id,
|
||||
product_id: ps.product_id,
|
||||
products: ps.products ? {
|
||||
name: ps.products.name,
|
||||
type: ps.products.type,
|
||||
price: ps.products.price,
|
||||
image_url: ps.products.image_url,
|
||||
} : null,
|
||||
}))
|
||||
.filter(Boolean);
|
||||
const stop: Stop = {
|
||||
id: stopRow.id,
|
||||
brand_id: stopRow.brand_id,
|
||||
city: stopRow.city ?? "",
|
||||
state: stopRow.state ?? "",
|
||||
address: stopRow.address ?? null,
|
||||
zip: stopRow.zip ?? null,
|
||||
date: stopRow.date ? String(stopRow.date) : "",
|
||||
time: stopRow.time ?? "",
|
||||
cutoff_date: stopRow.cutoff_date ? String(stopRow.cutoff_date) : null,
|
||||
cutoff_time: stopRow.cutoff_date ? String(stopRow.cutoff_date) : null,
|
||||
status: stopRow.status ?? "active",
|
||||
location: stopRow.location ?? "",
|
||||
active: stopRow.status === "active",
|
||||
brands: { name: stopRow.brand_name ?? "", slug: stopRow.brand_slug ?? "" },
|
||||
};
|
||||
|
||||
// Fetch all products for this brand
|
||||
const { rows: productRows } = await pool.query<{ id: string; name: string; type: string; price: number; image_url: string | null }>(
|
||||
`SELECT id, name, type, price, image_url
|
||||
FROM products
|
||||
WHERE brand_id = $1 AND active = true
|
||||
ORDER BY name`,
|
||||
[stop.brand_id]
|
||||
);
|
||||
|
||||
// Fetch product-stop assignments
|
||||
const { rows: productStopRows } = await pool.query<{ id: string; product_id: string; name: string; type: string; price: number; image_url: string | null }>(
|
||||
`SELECT ps.id, ps.product_id, p.name, p.type, p.price, p.image_url
|
||||
FROM product_stops ps
|
||||
JOIN products p ON p.id = ps.product_id
|
||||
WHERE ps.stop_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// Fetch brands list
|
||||
const { rows: brandRows } = await pool.query<{ id: string; name: string; slug: string }>(
|
||||
`SELECT id, name, slug FROM brands ORDER BY name`
|
||||
);
|
||||
|
||||
const allProducts = productRows.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
price: Number(p.price),
|
||||
image_url: p.image_url,
|
||||
}));
|
||||
|
||||
const assignedProducts = productStopRows.map((ps) => ({
|
||||
id: ps.id,
|
||||
product_id: ps.product_id,
|
||||
products: {
|
||||
name: ps.name,
|
||||
type: ps.type,
|
||||
price: Number(ps.price),
|
||||
image_url: ps.image_url,
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
@@ -168,11 +203,11 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{stop.cutoff_time && (
|
||||
{stop.cutoff_date && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Cutoff</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
{new Date(stop.cutoff_time).toLocaleString()}
|
||||
{new Date(stop.cutoff_date + "T00:00:00").toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -199,7 +234,7 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
<div className="mt-6">
|
||||
<StopProductAssignment
|
||||
stopId={stop.id}
|
||||
allProducts={allProducts ?? []}
|
||||
allProducts={allProducts}
|
||||
assignedProducts={assignedProducts}
|
||||
callerUid={adminUser.user_id}
|
||||
/>
|
||||
@@ -221,14 +256,14 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
date: stop.date,
|
||||
time: stop.time,
|
||||
location: stop.location,
|
||||
slug: stop.slug,
|
||||
slug: stop.brands?.slug ?? "",
|
||||
active: stop.active,
|
||||
brand_id: stop.brand_id,
|
||||
address: stop.address,
|
||||
zip: stop.zip,
|
||||
cutoff_time: stop.cutoff_time,
|
||||
}}
|
||||
brands={brands ?? []}
|
||||
brands={brandRows ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { pool } from "@/lib/db";
|
||||
import NewStopForm from "@/components/admin/NewStopForm";
|
||||
import StopProductAssignment from "@/components/admin/StopProductAssignment";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
@@ -32,24 +32,35 @@ export default async function NewStopPage({
|
||||
|
||||
let duplicateFrom: Stop | null = null;
|
||||
if (duplicate) {
|
||||
const { data } = await supabase
|
||||
.from("stops")
|
||||
.select("city, state, location, date, time, brand_id, active, address, zip, cutoff_time")
|
||||
.eq("id", duplicate)
|
||||
.single() as unknown as { data: Stop | null };
|
||||
duplicateFrom = data;
|
||||
const { rows } = await pool.query<Stop>(
|
||||
`SELECT city, state, location, date, time, brand_id, active, address, zip, cutoff_date
|
||||
FROM stops WHERE id = $1 LIMIT 1`,
|
||||
[duplicate]
|
||||
);
|
||||
const row = rows[0];
|
||||
if (row) {
|
||||
duplicateFrom = {
|
||||
city: row.city ?? "",
|
||||
state: row.state ?? "",
|
||||
location: row.location ?? "",
|
||||
date: row.date ? String(row.date) : "",
|
||||
time: row.time ?? "",
|
||||
brand_id: row.brand_id,
|
||||
active: row.active ?? true,
|
||||
address: row.address ?? null,
|
||||
zip: row.zip ?? null,
|
||||
cutoff_time: row.cutoff_time ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const brandId =
|
||||
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [{ data: allProducts }] = await Promise.all([
|
||||
supabase
|
||||
.from("products")
|
||||
.select("id, name, type, price")
|
||||
.eq("brand_id", brandId)
|
||||
.eq("active", true) as unknown as { data: { id: string; name: string; type: string; price: number }[] | null },
|
||||
]);
|
||||
const { rows: productRows } = await pool.query<{ id: string; name: string; type: string; price: number }>(
|
||||
`SELECT id, name, type, price FROM products WHERE brand_id = $1 AND active = true ORDER BY name`,
|
||||
[brandId]
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
|
||||
@@ -1,28 +1,10 @@
|
||||
import StopsDashboardClient from "@/components/admin/stops/StopsDashboardClient";
|
||||
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
import StopTableClient from "@/components/admin/StopTableClient";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface Stop {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
active: boolean;
|
||||
deleted_at: string | null;
|
||||
brand_id: string;
|
||||
address: string | null;
|
||||
zip: string | null;
|
||||
cutoff_time: string | null;
|
||||
status: string;
|
||||
brands: { name: string } | { name: string }[];
|
||||
}
|
||||
|
||||
const StopIcon = () => (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
|
||||
@@ -30,44 +12,64 @@ const StopIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default async function AdminStopsPage() {
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminStopsPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
||||
|
||||
if (!adminUser.can_manage_stops) {
|
||||
redirect("/admin/pickup");
|
||||
interface DbStopRow {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: Date | string;
|
||||
time: string | null;
|
||||
location: string;
|
||||
status: string;
|
||||
brand_id: string;
|
||||
address: string | null;
|
||||
zip: string | null;
|
||||
cutoff_date: string | null;
|
||||
brand_name?: string;
|
||||
}
|
||||
let stops: DbStopRow[] = [];
|
||||
let error: string | null = null;
|
||||
|
||||
let query = supabase
|
||||
.from("stops")
|
||||
.select(`
|
||||
id,
|
||||
city,
|
||||
state,
|
||||
date,
|
||||
time,
|
||||
location,
|
||||
active,
|
||||
deleted_at,
|
||||
brand_id,
|
||||
address,
|
||||
zip,
|
||||
cutoff_time,
|
||||
status,
|
||||
brands (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.is("deleted_at", null)
|
||||
.order("date", { ascending: true });
|
||||
// Resolve active brand from cookie/URL (respects platform_admin "All brands" = null)
|
||||
let activeBrandId: string | null = null;
|
||||
try {
|
||||
activeBrandId = await getActiveBrandId(adminUser);
|
||||
|
||||
if (adminUser?.brand_id) {
|
||||
query = query.eq("brand_id", adminUser.brand_id);
|
||||
// If brand-scoped (not platform_admin) and no active brand, restrict query
|
||||
const brandCondition =
|
||||
activeBrandId
|
||||
? `brand_id = '${activeBrandId}'`
|
||||
: adminUser.role === "platform_admin"
|
||||
? "1=1"
|
||||
: `brand_id IN ('${(adminUser.brand_ids ?? []).join("','")}')`;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.id, s.city, s.state, s.date, s."time", s.location, s.status,
|
||||
s.brand_id, s.address, s.zip, s.cutoff_date,
|
||||
b.name as brand_name
|
||||
FROM stops s
|
||||
LEFT JOIN brands b ON b.id = s.brand_id
|
||||
WHERE ${brandCondition}
|
||||
ORDER BY s.date ASC
|
||||
LIMIT 200`
|
||||
);
|
||||
stops = rows;
|
||||
console.log("[admin/stops] Fetched", rows.length, "stops");
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
console.error("[admin/stops] Query error:", error);
|
||||
}
|
||||
|
||||
const { data: stops, error } = await query as unknown as { data: Stop[] | null; error: { message: string } | null };
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
@@ -78,37 +80,44 @@ export default async function AdminStopsPage() {
|
||||
<span className="text-[var(--admin-text-muted)]">/</span>
|
||||
<span className="text-[var(--admin-text-primary)] font-medium">Stops & Routes</span>
|
||||
</nav>
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-red-600 tracking-tight">
|
||||
Error loading stops
|
||||
</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)]">
|
||||
{error.message}
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-red-600 tracking-tight">Error loading stops</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)] overflow-auto">
|
||||
{error}
|
||||
</pre>
|
||||
<div className="mt-4 p-4 rounded-xl bg-stone-100 text-sm">
|
||||
<p className="font-semibold">Debug info:</p>
|
||||
<p>adminUser.brand_id: {adminUser.brand_id ?? "null"}</p>
|
||||
<p>adminUser.role: {adminUser.role}</p>
|
||||
<p>adminUser.email: {adminUser.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<PageHeader
|
||||
breadcrumb={[
|
||||
{ label: "Admin", href: "/admin" },
|
||||
{ label: "Stops & Routes" }
|
||||
]}
|
||||
icon={<StopIcon />}
|
||||
title="Stops & Routes"
|
||||
subtitle={adminUser?.brand_id ? "Managing stops for your brand." : "Manage routes, pickup locations, dates, and cutoff times."}
|
||||
actions={<StopsHeaderActions brandId={adminUser?.brand_id ?? ""} />}
|
||||
/>
|
||||
</div>
|
||||
// Transform stops for the client component
|
||||
const stopsForClient = stops.map((s) => ({
|
||||
id: s.id,
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
date: s.date instanceof Date ? s.date.toISOString().split("T")[0] : String(s.date).split("T")[0],
|
||||
time: s.time || "",
|
||||
location: s.location,
|
||||
active: s.status === "active",
|
||||
brand_id: s.brand_id,
|
||||
status: s.status,
|
||||
address: s.address,
|
||||
zip: s.zip,
|
||||
cutoff_time: s.cutoff_date,
|
||||
brands: s.brand_name ? { name: s.brand_name } : null,
|
||||
}));
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
<StopsDashboardClient stops={stops ?? []} />
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<StopTableClient stops={stopsForClient} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,11 +19,12 @@ export async function GET() {
|
||||
{ status: "ok", message: "admin_users table present" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Database schema check failed";
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
message: err?.message ?? "Database schema check failed",
|
||||
message,
|
||||
hint: "Run migrations against the DATABASE_URL (see docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md)",
|
||||
},
|
||||
{ status: 503 }
|
||||
|
||||
@@ -9,19 +9,25 @@ type RequestBody = {
|
||||
/** Raw file content (CSV text). AI parsing is attempted if useAI=true. */
|
||||
text: string;
|
||||
brandId: string;
|
||||
/** If true and OPENAI_API_KEY is set, parse unstructured text with AI. */
|
||||
/** If true and an AI API key is set, parse unstructured text with AI. */
|
||||
useAI?: boolean;
|
||||
};
|
||||
|
||||
const AI_MODEL = "gpt-4o-mini";
|
||||
|
||||
async function parseWithAI(text: string, brandId: string): Promise<{
|
||||
async function parseWithAI(text: string, _brandId: string): Promise<{
|
||||
stops: ParsedStop[];
|
||||
warnings: string[];
|
||||
}> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
// Prefer MiniMax (env-level) — fall back to OpenAI.
|
||||
const provider: "minimax" | "openai" =
|
||||
process.env.MINIMAX_API_KEY ? "minimax" : process.env.OPENAI_API_KEY ? "openai" : "openai";
|
||||
const apiKey = provider === "minimax" ? process.env.MINIMAX_API_KEY! : process.env.OPENAI_API_KEY!;
|
||||
const baseURL = provider === "minimax"
|
||||
? (process.env.MINIMAX_BASE_URL || "https://api.minimax.io/v1")
|
||||
: "https://api.openai.com/v1";
|
||||
const model = provider === "minimax" ? "MiniMax-M3" : "gpt-4o-mini";
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENAI_API_KEY is not configured. Use CSV format for reliable parsing.");
|
||||
throw new Error("MINIMAX_API_KEY or OPENAI_API_KEY is not configured. Use CSV format for reliable parsing.");
|
||||
}
|
||||
|
||||
const systemPrompt = `You are a schedule extraction assistant. Given raw schedule text, extract all stop entries.
|
||||
@@ -37,26 +43,27 @@ Return a JSON array where each entry has:
|
||||
|
||||
If a row lacks required fields (city, state, location), omit it and add a warning.`;
|
||||
|
||||
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
const res = await fetch(`${baseURL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: AI_MODEL,
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: `Extract stops from this schedule:\n\n${text.slice(0, 8000)}` },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
// response_format is OpenAI-specific. MiniMax /v1/chat/completions may not honor it.
|
||||
...(provider === "openai" ? { response_format: { type: "json_object" } } : {}),
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`OpenAI API error: ${res.status} — ${err}`);
|
||||
throw new Error(`${provider} API error: ${res.status} — ${err}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function BlogPage() {
|
||||
{/* Hero */}
|
||||
<section className="py-12 sm:py-16 md:py-20">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Blog & Resources
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-[#6b8f71]">
|
||||
@@ -151,7 +151,7 @@ export default function BlogPage() {
|
||||
<span className="inline-block w-fit px-3 py-1 bg-[#c97a3e]/10 text-[#c97a3e] text-sm font-medium rounded-full mb-4">
|
||||
Featured
|
||||
</span>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Getting Started with Route Commerce
|
||||
</h2>
|
||||
<p className="text-[#666] mb-6">
|
||||
@@ -234,7 +234,7 @@ export default function BlogPage() {
|
||||
{/* Newsletter */}
|
||||
<section className="py-20 bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f]">
|
||||
<div className="max-w-2xl mx-auto px-6 text-center">
|
||||
<h2 className="text-3xl font-bold text-white mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-white mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Stay in the Loop
|
||||
</h2>
|
||||
<p className="text-[#faf8f5]/80 mb-8">
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function BrandsPage() {
|
||||
|
||||
<style jsx>{`
|
||||
.brands-page {
|
||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||
font-family: var(--font-manrope);
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -1,76 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Your Cart — Route Commerce",
|
||||
description: "Review and manage your shopping cart. Select pickup stops, adjust quantities, and proceed to checkout.",
|
||||
keywords: ["cart", "shopping cart", "produce order", "checkout", "pickup"],
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
{/* Dark background matching cart page */}
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" />
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{/* Minimal header skeleton */}
|
||||
<div className="h-20 border-b border-white/5" />
|
||||
|
||||
{/* Content skeleton */}
|
||||
<main className="px-6 py-12 relative">
|
||||
<div className="mx-auto grid max-w-6xl gap-10 lg:grid-cols-[1fr_380px]">
|
||||
<div>
|
||||
{/* Title skeleton */}
|
||||
<div className="h-10 w-56 rounded-lg bg-white/5 animate-pulse" />
|
||||
<div className="h-5 w-80 rounded mt-3 bg-white/5 animate-pulse" />
|
||||
|
||||
{/* Cart items skeleton */}
|
||||
<div className="mt-10 space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="glass-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="h-5 w-40 rounded bg-white/5 animate-pulse" />
|
||||
<div className="h-4 w-24 rounded bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-11 w-11 rounded-xl bg-white/5 animate-pulse" />
|
||||
<div className="h-11 w-10 rounded bg-white/5 animate-pulse" />
|
||||
<div className="h-11 w-11 rounded-xl bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
|
||||
<div className="h-5 w-20 rounded bg-white/5 animate-pulse" />
|
||||
<div className="h-4 w-16 rounded bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar skeleton */}
|
||||
<aside>
|
||||
<div className="glass-card p-6 sticky top-6">
|
||||
<div className="h-6 w-32 rounded bg-white/5 animate-pulse" />
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="h-4 w-full rounded bg-white/5 animate-pulse" />
|
||||
<div className="h-4 w-3/4 rounded bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
<div className="mt-5 h-14 w-full rounded-xl bg-white/5 animate-pulse" />
|
||||
<div className="mt-4 h-4 w-32 mx-auto rounded bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Status for accessibility */}
|
||||
<span role="status" className="sr-only">Loading your cart...</span>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export default function ChangelogPage() {
|
||||
<main className="max-w-4xl mx-auto px-6 py-16">
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Changelog
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||
|
||||
@@ -1,65 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Processing...",
|
||||
description: "Loading checkout...",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
|
||||
<div className="h-20 border-b border-slate-100/50" />
|
||||
|
||||
<main className="px-6 py-12">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
|
||||
<div>
|
||||
{/* Title skeleton */}
|
||||
<div className="h-10 w-32 rounded-lg bg-slate-200 animate-pulse" />
|
||||
<div className="h-5 w-64 rounded mt-3 bg-slate-100 animate-pulse" />
|
||||
|
||||
{/* Form skeleton */}
|
||||
<div className="mt-8 space-y-6">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm border border-slate-100">
|
||||
<div className="h-6 w-40 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="h-12 w-full rounded-xl bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="h-12 w-full rounded-xl bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button skeleton */}
|
||||
<div className="h-14 w-full rounded-xl bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar skeleton */}
|
||||
<aside>
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm border border-slate-100 sticky top-6">
|
||||
<div className="h-6 w-32 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="mt-4 space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<div className="h-4 w-32 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<span role="status" className="sr-only">Loading checkout...</span>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,78 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Loading contact...",
|
||||
description: "Loading...",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header skeleton */}
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-xl border-b border-[#e5e5e5]">
|
||||
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-[#e5e5e5] to-[#f5f5f5] animate-pulse" />
|
||||
<div className="h-6 w-36 rounded bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
<div className="h-5 w-24 rounded bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-32">
|
||||
{/* Page header skeleton */}
|
||||
<div className="text-center mb-16">
|
||||
<div className="h-4 w-20 rounded-full bg-[#e5e5e5] mx-auto mb-4 animate-pulse" />
|
||||
<div className="h-12 w-64 rounded-lg bg-[#e5e5e5] mx-auto mb-4 animate-pulse" />
|
||||
<div className="h-5 w-96 rounded bg-[#e5e5e5] mx-auto animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Contact cards skeleton */}
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-16">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-3xl bg-white border border-[#e5e5e5] p-8">
|
||||
<div className="h-14 w-14 rounded-2xl bg-[#e5e5e5] mx-auto mb-5 animate-pulse" />
|
||||
<div className="h-5 w-24 rounded bg-[#e5e5e5] mx-auto mb-3 animate-pulse" />
|
||||
<div className="h-4 w-40 rounded bg-[#e5e5e5] mx-auto animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form skeleton */}
|
||||
<div className="rounded-3xl bg-white border border-[#e5e5e5] p-10">
|
||||
<div className="h-5 w-20 rounded-full bg-[#e5e5e5] mb-3 animate-pulse" />
|
||||
<div className="h-8 w-40 rounded bg-[#e5e5e5] mb-4 animate-pulse" />
|
||||
<div className="h-1 w-12 bg-[#e5e5e5] mb-8 animate-pulse" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-[#e5e5e5] animate-pulse" />
|
||||
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-[#e5e5e5] animate-pulse" />
|
||||
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-[#e5e5e5] animate-pulse" />
|
||||
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-[#e5e5e5] animate-pulse" />
|
||||
<div className="h-32 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
<div className="h-12 w-32 rounded-xl bg-[#e5e5e5] animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<span role="status" className="sr-only">Loading contact page...</span>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
+73
-33
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Root error boundary — the safety net for any uncaught error in the
|
||||
* app. Renders inside the root layout, so it inherits the same font
|
||||
* variables and design tokens as everything else.
|
||||
*/
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
reset,
|
||||
@@ -9,44 +15,78 @@ export default function ErrorPage({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Surface to the console for development; the Sentry-ready logger
|
||||
// in src/lib/sentry.ts can be wired in here when keys are configured.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[app/error]", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-6 relative">
|
||||
{/* Background */}
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" />
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-red-500/5 rounded-full blur-3xl" />
|
||||
<main className="atelier-canvas relative min-h-screen flex items-center justify-center px-6 py-16 overflow-hidden">
|
||||
<div className="atelier-grain" aria-hidden="true" />
|
||||
|
||||
{/* Ambient orbs */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div
|
||||
className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-30"
|
||||
style={{ background: "radial-gradient(circle, rgba(185, 28, 28, 0.12) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-1/4 right-1/4 w-96 h-96 rounded-full opacity-20"
|
||||
style={{ background: "radial-gradient(circle, rgba(202, 138, 4, 0.16) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center max-w-md mx-auto relative">
|
||||
<div className="glass-card p-10">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-red-500/10 border border-red-500/20 mb-6">
|
||||
<svg className="w-8 h-8 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<div className="relative z-10 max-w-md w-full text-center atelier-enter">
|
||||
<div className="atelier-section-num justify-center mb-4">No. 500</div>
|
||||
|
||||
<h1 className="atelier-title text-5xl sm:text-6xl">
|
||||
Something <span className="atelier-italic text-[#786B53]">broke</span>
|
||||
</h1>
|
||||
<p className="mt-4 text-stone-600 text-sm leading-relaxed">
|
||||
We hit an unexpected snag loading this page. Our harvest crew has been notified.
|
||||
</p>
|
||||
|
||||
{error.message && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary className="atelier-fineprint cursor-pointer hover:text-stone-900 transition-colors">
|
||||
Error details
|
||||
</summary>
|
||||
<div className="mt-3 rounded-lg border border-stone-200 bg-white/70 backdrop-blur-sm p-3.5 text-xs font-mono text-stone-700 break-words">
|
||||
{error.message}
|
||||
{error.digest && (
|
||||
<div className="mt-2 pt-2 border-t border-stone-200 text-stone-500">
|
||||
digest: <span className="text-stone-700">{error.digest}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="atelier-rule my-8" />
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="atelier-cta"
|
||||
aria-label="Retry loading this page"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold text-white tracking-tight">Something went wrong</h1>
|
||||
<p className="mt-3 text-zinc-400 text-sm">
|
||||
{error.message || "An unexpected error occurred."}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="mt-2 text-xs text-zinc-600 font-mono">Digest: {error.digest}</p>
|
||||
)}
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 px-5 py-2.5 text-sm font-medium text-white transition-all backdrop-blur-sm"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
</div>
|
||||
<span className="relative z-10">Try again</span>
|
||||
<span className="atelier-cta-shimmer" aria-hidden="true" />
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="atelier-discard text-center"
|
||||
>
|
||||
← Return home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,6 +177,70 @@ select:-webkit-autofill:focus {
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
/* ─── View Transitions (route fades) ────────────────────────────────────── */
|
||||
/*
|
||||
* Native browser View Transitions API, opt-in via the
|
||||
* <ViewTransition name="page-content"> component and Next.js's
|
||||
* experimental.viewTransition config. These rules define the actual
|
||||
* crossfade / slide. Without them, transitions still work but use
|
||||
* the browser default (instant cut).
|
||||
*
|
||||
* 220ms is the sweet spot for route changes: long enough to soften
|
||||
* the cut, short enough to feel like a single app. The cubic-bezier
|
||||
* keeps the start crisp and the finish gentle.
|
||||
*/
|
||||
::view-transition-old(page-content) {
|
||||
animation: route-fade-out 180ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
::view-transition-new(page-content) {
|
||||
animation: route-fade-in 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes route-fade-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
@keyframes route-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Top-of-page shimmer for the LoadingFade placeholder bar. */
|
||||
@keyframes transition-shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
|
||||
/* Reduce motion: respect the user's OS-level preference. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(page-content),
|
||||
::view-transition-new(page-content) {
|
||||
animation: none !important;
|
||||
}
|
||||
@keyframes transition-shimmer {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
}
|
||||
/* Atelier modal + stagger animations also respect reduced-motion */
|
||||
.atelier-enter,
|
||||
.atelier-backdrop,
|
||||
.atelier-stagger > *,
|
||||
.atelier-cta .atelier-cta-shimmer {
|
||||
animation: none !important;
|
||||
}
|
||||
.atelier-stagger > * {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Light mode placeholder (used by storefront) */
|
||||
.light ::placeholder,
|
||||
.light::-webkit-input-placeholder,
|
||||
@@ -1030,3 +1094,75 @@ select:-webkit-autofill:focus {
|
||||
color: #991B1B;
|
||||
}
|
||||
.atelier-error svg { flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
/* ─── Polish utilities — "Atelier des Récoltes" additions ─────── */
|
||||
|
||||
/* Tabular numerals for prices, quantities, dates */
|
||||
.atelier-numerals {
|
||||
font-family: var(--font-fraunces);
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
/* Inline editorial pill — for tags, badges, status chips */
|
||||
.atelier-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px 4px 8px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(28, 25, 23, 0.08);
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: #1C1917;
|
||||
white-space: nowrap;
|
||||
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.atelier-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-color: rgba(34, 78, 47, 0.25);
|
||||
}
|
||||
.atelier-pill .atelier-pill-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #16A34A;
|
||||
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
||||
}
|
||||
.atelier-pill.is-gold .atelier-pill-dot {
|
||||
background: #CA8A04;
|
||||
box-shadow: 0 0 0 2px rgba(202, 138, 4, 0.18);
|
||||
}
|
||||
.atelier-pill.is-muted .atelier-pill-dot {
|
||||
background: #A8A29E;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Refined focus ring for atelier inputs (replaces default browser ring) */
|
||||
.atelier-input:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 1px 0 0 #224E2F;
|
||||
}
|
||||
|
||||
/* Small caps editorial small-text — for footnote, fine print */
|
||||
.atelier-fineprint {
|
||||
font-family: var(--font-fragment-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #786B53;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Soft cream canvas (lighter than atelier-canvas) — for sub-pages */
|
||||
.atelier-canvas-soft {
|
||||
background-color: #FDFBF6;
|
||||
background-image:
|
||||
radial-gradient(ellipse 60% 40% at 50% 0%, rgba(180, 155, 100, 0.06) 0%, transparent 60%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-4xl px-6 py-20">
|
||||
{/* Hero skeleton */}
|
||||
<div className="mb-20 animate-pulse text-center">
|
||||
<div className="h-3 w-24 rounded bg-blue-100 mx-auto mb-5" />
|
||||
<div className="h-20 w-full max-w-xl mx-auto rounded-lg bg-blue-50 mb-5" />
|
||||
<div className="h-8 w-full max-w-2xl mx-auto rounded bg-blue-50" />
|
||||
</div>
|
||||
|
||||
{/* Content sections skeleton */}
|
||||
<div className="space-y-20">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="grid md:grid-cols-2 gap-12 items-center"
|
||||
>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 w-20 rounded bg-blue-100" />
|
||||
<div className="h-8 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
||||
</div>
|
||||
<div className="h-64 rounded-3xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,62 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-4xl px-6 py-20">
|
||||
{/* Header skeleton */}
|
||||
<div className="mb-16 animate-pulse text-center">
|
||||
<div className="h-4 w-20 rounded bg-blue-100 mx-auto mb-5" />
|
||||
<div className="h-16 w-64 mx-auto rounded-lg bg-stone-200 mb-5" />
|
||||
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Contact cards skeleton */}
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-16">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="rounded-3xl bg-white border-2 border-stone-200 p-8 shadow-xl text-center"
|
||||
>
|
||||
<div className="h-14 w-14 rounded-2xl bg-blue-100 mx-auto mb-5" />
|
||||
<div className="h-5 w-24 mx-auto rounded bg-stone-200 mb-3" />
|
||||
<div className="h-4 w-full max-w-[160px] mx-auto rounded bg-stone-100" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form skeleton */}
|
||||
<div className="rounded-3xl bg-white border-2 border-stone-200 p-10 shadow-xl">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 w-32 rounded bg-stone-200" />
|
||||
<div className="h-8 w-48 rounded bg-stone-200" />
|
||||
<div className="grid gap-6 md:grid-cols-2 mt-8">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
<div className="h-14 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
<div className="h-14 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-stone-100" />
|
||||
<div className="h-14 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
<div className="h-32 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
<div className="h-14 w-40 rounded-xl bg-blue-100 mt-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,41 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-3xl px-6 py-20">
|
||||
{/* Header skeleton */}
|
||||
<div className="mb-14 text-center animate-pulse">
|
||||
<div className="h-4 w-16 rounded bg-blue-100 mx-auto mb-4" />
|
||||
<div className="h-16 w-48 mx-auto rounded-lg bg-stone-200 mb-5" />
|
||||
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Search skeleton */}
|
||||
<div className="mb-12">
|
||||
<div className="h-14 rounded-2xl bg-white border-2 border-stone-200 shadow-lg" />
|
||||
</div>
|
||||
|
||||
{/* FAQ items skeleton */}
|
||||
<div className="space-y-4">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="rounded-2xl bg-white border-2 border-stone-200 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-5">
|
||||
<div className="h-5 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-8 w-8 rounded-full bg-blue-100" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,23 +1,35 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
// BreadcrumbList schema for SEO
|
||||
const indianRiverBreadcrumbSchema = {
|
||||
// Structured data for the Indian River Direct storefront.
|
||||
// LocalBusiness + BreadcrumbList for the family farm brand.
|
||||
const indianRiverStructuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": BASE_URL,
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Home", item: BASE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Indian River Direct", item: `${BASE_URL}/indian-river-direct` },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Indian River Direct",
|
||||
"item": `${BASE_URL}/indian-river-direct`,
|
||||
"@type": "LocalBusiness",
|
||||
"@id": `${BASE_URL}/indian-river-direct/#localbusiness`,
|
||||
name: "Indian River Direct",
|
||||
description:
|
||||
"Fresh peaches and citrus from our Florida groves to truckload sales in your neighborhood. Family-owned since 1985.",
|
||||
url: `${BASE_URL}/indian-river-direct`,
|
||||
image: `${BASE_URL}/og-default.svg`,
|
||||
priceRange: "$$",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressRegion: "FL",
|
||||
addressCountry: "US",
|
||||
},
|
||||
foundingDate: "1985",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -41,7 +53,7 @@ export const metadata: Metadata = {
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/og-indian-river.jpg",
|
||||
url: "/og-default.svg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Indian River Direct - Fresh Peaches & Citrus",
|
||||
@@ -54,7 +66,7 @@ export const metadata: Metadata = {
|
||||
description: "Fresh peaches and citrus from our Florida groves. Family-owned since 1985. Pre-order for 2026 season.",
|
||||
site: "@IndianRiverDirect",
|
||||
creator: "@IndianRiverDirect",
|
||||
images: ["/og-indian-river.jpg"],
|
||||
images: ["/og-default.svg"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/indian-river-direct`,
|
||||
@@ -68,7 +80,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
},
|
||||
other: {
|
||||
"application/ld+json": JSON.stringify(indianRiverBreadcrumbSchema),
|
||||
"application/ld+json": JSON.stringify(indianRiverStructuredData),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -91,8 +103,10 @@ export default function IndianRiverLayout({ children }: { children: React.ReactN
|
||||
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-gradient-to-br from-blue-50/40 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Content wrapper */}
|
||||
<div className="relative">{children}</div>
|
||||
{/* Content wrapper — crossfades on navigation. */}
|
||||
<div id="page-content" className="relative outline-none">
|
||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +1,7 @@
|
||||
"use client";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="min-h-screen bg-white/50 backdrop-blur-xl">
|
||||
<div className="mx-auto max-w-6xl px-6 py-16">
|
||||
{/* Header skeleton with blue accent */}
|
||||
<div className="mb-16 animate-pulse">
|
||||
<div className="h-3 w-20 rounded bg-blue-100 mb-4" />
|
||||
<div className="h-16 w-80 rounded-lg bg-blue-50 mb-4" />
|
||||
<div className="h-6 w-96 max-w-full rounded bg-blue-50" />
|
||||
</div>
|
||||
|
||||
{/* Product cards skeleton */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="rounded-3xl bg-white border-2 border-stone-200 overflow-hidden shadow-lg"
|
||||
>
|
||||
{/* Image skeleton */}
|
||||
<div className="h-48 bg-gradient-to-br from-blue-50 to-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-blue-100/60 to-transparent" />
|
||||
</div>
|
||||
{/* Content skeleton */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-6 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
||||
<div className="pt-4 flex items-center justify-between">
|
||||
<div className="h-8 w-20 rounded-lg bg-blue-50" />
|
||||
<div className="h-11 w-28 rounded-xl bg-blue-100" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stops section skeleton */}
|
||||
<div className="mt-20">
|
||||
<div className="animate-pulse mb-10">
|
||||
<div className="h-3 w-24 rounded bg-blue-100 mb-4" />
|
||||
<div className="h-10 w-64 rounded-lg bg-blue-50 mb-3" />
|
||||
<div className="h-4 w-48 rounded bg-blue-50" />
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="rounded-3xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100 p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="h-6 w-32 rounded bg-stone-200 mb-2" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
</div>
|
||||
<div className="h-6 w-16 rounded-full bg-blue-100" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="h-4 w-28 rounded bg-stone-100" />
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
/** Indian River Direct storefront loading — light blue glass backdrop
|
||||
* stays mounted in the layout. */
|
||||
export default function IndianRiverLoading() {
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,70 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50/80 backdrop-blur-xl">
|
||||
<div className="mx-auto max-w-5xl px-6 py-20">
|
||||
{/* Back navigation skeleton */}
|
||||
<div className="mb-10 animate-pulse">
|
||||
<div className="h-5 w-32 rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Stop header skeleton */}
|
||||
<div className="mb-12 animate-pulse rounded-3xl bg-white/60 border border-white/50 p-8">
|
||||
<div className="h-3 w-32 rounded bg-blue-100 mb-4" />
|
||||
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
|
||||
<div className="h-5 w-full max-w-md rounded bg-stone-200 mb-4" />
|
||||
<div className="h-px w-12 bg-blue-100" />
|
||||
</div>
|
||||
|
||||
{/* Stop info skeleton */}
|
||||
<div className="mb-14 rounded-3xl bg-white/80 border border-white/50 p-8 shadow-xl">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
|
||||
>
|
||||
<div className="h-10 w-10 rounded-xl bg-blue-50" />
|
||||
<div className="flex-1">
|
||||
<div className="h-3 w-12 rounded bg-stone-200 mb-2" />
|
||||
<div className="h-5 w-24 rounded bg-stone-200" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products section skeleton */}
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 w-20 rounded bg-blue-100 mb-4" />
|
||||
<div className="h-10 w-48 rounded bg-stone-200 mb-4" />
|
||||
<div className="h-4 w-64 rounded bg-stone-200 mb-10" />
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="rounded-3xl bg-white overflow-hidden shadow-lg"
|
||||
>
|
||||
<div className="h-48 bg-gradient-to-br from-blue-50 to-white" />
|
||||
<div className="p-6 space-y-3">
|
||||
<div className="h-6 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
+7
-42
@@ -1,8 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Loading Route Commerce...",
|
||||
description: "Loading...",
|
||||
title: "Loading — Route Commerce",
|
||||
description: "Loading content from Route Commerce",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
@@ -10,44 +11,8 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden" style={{ backgroundColor: "#faf8f5" }}>
|
||||
{/* Subtle loading animation */}
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div
|
||||
className="w-16 h-16 rounded-2xl flex items-center justify-center animate-pulse"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #1a4d2e 0%, #166534 100%)",
|
||||
boxShadow: "0 8px 32px rgba(26, 77, 46, 0.3)"
|
||||
}}
|
||||
aria-label="Loading"
|
||||
role="status"
|
||||
>
|
||||
<svg className="w-8 h-8 text-white animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
Loading
|
||||
</p>
|
||||
<p className="text-sm text-stone-500 mt-1">Please wait...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div
|
||||
className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-10"
|
||||
style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-10"
|
||||
style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// No skeleton. The previous page stays visible while the next one
|
||||
// streams in, and the View Transition API crossfades them. This
|
||||
// thin top bar is the only signal that navigation is in flight.
|
||||
return <LoadingFade />;
|
||||
}
|
||||
+117
-98
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useId } from "react";
|
||||
|
||||
type LoginClientProps = {
|
||||
error: string | null;
|
||||
@@ -12,6 +12,8 @@ const REDIRECT_URL = "/admin";
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
export default function LoginClient({ error }: LoginClientProps) {
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -54,154 +56,171 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
window.location.href = REDIRECT_URL;
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className="min-h-screen flex flex-col relative overflow-hidden"
|
||||
style={{ backgroundColor: "#faf8f5", height: "100vh" }}
|
||||
>
|
||||
<style jsx global>{`
|
||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
||||
html, body { overflow: hidden; }
|
||||
`}</style>
|
||||
const displayError = error ?? localError;
|
||||
|
||||
return (
|
||||
<main className="atelier-canvas relative min-h-screen flex flex-col overflow-hidden">
|
||||
{/* Decorative grain layer */}
|
||||
<div className="atelier-grain" aria-hidden="true" />
|
||||
|
||||
{/* Ambient background flourishes */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-20" style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }} />
|
||||
<div className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-15" style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }} />
|
||||
<div className="absolute top-1/3 left-1/4 w-72 h-72 rounded-full opacity-10" style={{ background: "radial-gradient(circle, #1a4d2e15 0%, transparent 70%)", filter: "blur(30px)" }} />
|
||||
<div
|
||||
className="absolute -top-32 -right-32 w-[28rem] h-[28rem] rounded-full opacity-30"
|
||||
style={{ background: "radial-gradient(circle at 30% 30%, rgba(202, 138, 4, 0.18) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-48 -left-48 w-[36rem] h-[36rem] rounded-full opacity-25"
|
||||
style={{ background: "radial-gradient(circle at 70% 70%, rgba(34, 78, 47, 0.16) 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl ring-1 ring-black/5 overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-[#6b8f71]/30 to-transparent" />
|
||||
{/* Editorial corner flourish */}
|
||||
<div className="absolute top-6 left-6 atelier-flourish w-20 h-20 opacity-40" aria-hidden="true" />
|
||||
|
||||
<div className="p-8 sm:p-10">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Editorial numeral + section label */}
|
||||
<div className="text-center mb-6">
|
||||
<span className="atelier-section-num">No. 01</span>
|
||||
</div>
|
||||
|
||||
<div className="atelier-enter relative bg-white/90 backdrop-blur-xl rounded-2xl ring-1 ring-stone-200/80 shadow-[0_24px_64px_-24px_rgba(20,83,45,0.25)] overflow-hidden">
|
||||
{/* Top hairline rule */}
|
||||
<div className="atelier-rule" />
|
||||
|
||||
<div className="px-8 sm:px-10 py-10">
|
||||
{/* Brand mark */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div
|
||||
className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5"
|
||||
className="inline-flex h-14 w-14 items-center justify-center rounded-2xl mb-5"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
||||
boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)",
|
||||
background: "linear-gradient(135deg, #14532D 0%, #1F6B3E 50%, #14532D 100%)",
|
||||
boxShadow: "0 10px 28px -6px rgba(20, 83, 45, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12)",
|
||||
}}
|
||||
>
|
||||
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
className="text-3xl font-semibold text-stone-900"
|
||||
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
Welcome back
|
||||
<h1 className="atelier-title text-3xl sm:text-4xl text-center">
|
||||
Welcome <span className="atelier-italic text-[#786B53]">back</span>
|
||||
</h1>
|
||||
<p
|
||||
className="mt-2 text-sm"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
|
||||
>
|
||||
Sign in to your account
|
||||
<p className="mt-2 text-sm text-stone-500 text-center">
|
||||
Sign in to your <em className="atelier-italic not-italic font-normal">atelier</em> account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSignIn();
|
||||
}}
|
||||
className="space-y-5"
|
||||
noValidate
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={emailId} className="atelier-section-label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id={emailId}
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
|
||||
placeholder="you@example.com"
|
||||
className="atelier-input"
|
||||
placeholder="you@yourfarm.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSignIn(); }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
Password
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor={passwordId} className="atelier-section-label">
|
||||
Password
|
||||
</label>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="text-[10px] tracking-[0.15em] uppercase text-stone-500 hover:text-[#14532D] transition-colors"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)" }}
|
||||
>
|
||||
Forgot?
|
||||
</a>
|
||||
</div>
|
||||
<input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
|
||||
className="atelier-input"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSignIn(); }}
|
||||
/>
|
||||
</div>
|
||||
{(error || localError) && (
|
||||
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">
|
||||
{error ?? localError}
|
||||
</p>
|
||||
|
||||
{displayError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="atelier-stagger rounded-lg border border-rose-200/80 bg-rose-50/80 px-3.5 py-2.5 text-sm text-rose-800 flex items-start gap-2"
|
||||
>
|
||||
<svg className="h-4 w-4 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignIn}
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl px-6 py-3.5 text-sm font-semibold text-white shadow-sm transition-all disabled:opacity-60 disabled:cursor-not-allowed active:scale-[0.98]"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: loading
|
||||
? "linear-gradient(135deg, #6b8f71 0%, #7ba085 100%)"
|
||||
: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
||||
boxShadow: "0 8px 24px rgba(26, 77, 46, 0.20)",
|
||||
}}
|
||||
className="atelier-cta w-full"
|
||||
>
|
||||
{loading ? "Signing in…" : "Sign in with email"}
|
||||
<span className="relative z-10">{loading ? "Signing in…" : "Sign in"}</span>
|
||||
{!loading && (
|
||||
<svg className="h-4 w-4 relative z-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="atelier-cta-shimmer" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Development bypass buttons */}
|
||||
{isDev && (
|
||||
<div className="mt-6 pt-6 border-t border-stone-200">
|
||||
<p className="text-xs text-stone-500 text-center mb-3" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||
Dev Mode — Quick Access
|
||||
</p>
|
||||
<div className="mt-8 pt-6 border-t border-stone-200/80">
|
||||
<p className="atelier-section-label text-center mb-3">Dev Mode · Quick Access</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDevLogin("platform_admin")}
|
||||
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: "#1a4d2e",
|
||||
}}
|
||||
>
|
||||
Platform Admin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDevLogin("brand_admin")}
|
||||
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: "#2d6a45",
|
||||
}}
|
||||
>
|
||||
Brand Admin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDevLogin("store_employee")}
|
||||
className="rounded-lg px-3 py-2 text-xs font-medium text-white transition-all hover:opacity-90"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
background: "#6b8f71",
|
||||
}}
|
||||
>
|
||||
Store Employee
|
||||
</button>
|
||||
{[
|
||||
{ role: "platform_admin", label: "Platform", color: "#14532D" },
|
||||
{ role: "brand_admin", label: "Brand", color: "#1F6B3E" },
|
||||
{ role: "store_employee", label: "Store", color: "#6F8562" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.role}
|
||||
type="button"
|
||||
onClick={() => handleDevLogin(opt.role)}
|
||||
className="rounded-lg px-3 py-2 text-[11px] font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
||||
style={{
|
||||
fontFamily: "var(--font-manrope)",
|
||||
background: opt.color,
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="atelier-fineprint text-center mt-6">
|
||||
Protected by Neon Auth · Encrypted at rest
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,59 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Loading...",
|
||||
description: "Loading...",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-6 py-12 bg-gradient-to-br from-stone-50 to-amber-50/30">
|
||||
{/* Background orbs */}
|
||||
<div
|
||||
className="fixed w-96 h-96 rounded-full pointer-events-none animate-pulse"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(34, 197, 94, 0.15) 0%, transparent 70%)',
|
||||
top: '10%',
|
||||
left: '10%',
|
||||
filter: 'blur(60px)'
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Loading card */}
|
||||
<div className="w-full max-w-sm relative">
|
||||
<div className="bg-white/70 backdrop-blur-2xl rounded-[2rem] shadow-lg p-8">
|
||||
{/* Logo skeleton */}
|
||||
<div className="flex justify-center mb-10">
|
||||
<div className="h-20 w-20 rounded-3xl bg-slate-200 animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Text skeleton */}
|
||||
<div className="space-y-3">
|
||||
<div className="h-8 w-32 rounded bg-slate-200 mx-auto animate-pulse" />
|
||||
<div className="h-4 w-24 rounded bg-slate-100 mx-auto animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Form skeleton */}
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="h-12 rounded-xl bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-slate-100 animate-pulse" />
|
||||
<div className="h-12 rounded-xl bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="h-12 rounded-xl bg-slate-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span role="status" className="sr-only">Loading login page...</span>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export default function MaintenancePage() {
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<h1 className="text-4xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h1 className="text-4xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Under Maintenance
|
||||
</h1>
|
||||
<p className="text-lg text-[#6b8f71] mb-6">
|
||||
|
||||
+63
-60
@@ -1,79 +1,82 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Page Not Found — Route Commerce",
|
||||
description: "The page you are looking for could not be found. Explore the rest of Route Commerce.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#faf8f5] flex items-center justify-center px-6">
|
||||
{/* Background decorations */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-20 left-20 w-40 h-40 bg-[#1a4d2e]/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-40 right-20 w-60 h-60 bg-[#c97a3e]/5 rounded-full blur-3xl" />
|
||||
<main className="atelier-canvas relative min-h-screen flex items-center justify-center px-6 py-16 overflow-hidden">
|
||||
<div className="atelier-grain" aria-hidden="true" />
|
||||
|
||||
{/* Ambient orbs */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div
|
||||
className="absolute top-1/3 left-1/3 w-96 h-96 rounded-full opacity-30"
|
||||
style={{ background: "radial-gradient(circle, rgba(34, 78, 47, 0.16) 0%, transparent 70%)", filter: "blur(60px)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-1/3 right-1/3 w-96 h-96 rounded-full opacity-25"
|
||||
style={{ background: "radial-gradient(circle, rgba(202, 138, 4, 0.16) 0%, transparent 70%)", filter: "blur(48px)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center max-w-md mx-auto relative">
|
||||
{/* 404 Illustration */}
|
||||
<div className="relative mb-8">
|
||||
<div className="w-24 h-24 mx-auto bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] rounded-3xl flex items-center justify-center shadow-xl">
|
||||
<svg className="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="absolute -top-4 -right-4 w-12 h-12 bg-[#c97a3e]/20 rounded-full flex items-center justify-center text-2xl">📦</span>
|
||||
<div className="relative z-10 max-w-lg w-full text-center atelier-enter">
|
||||
<div className="atelier-section-num justify-center mb-4">No. 404</div>
|
||||
|
||||
{/* Large editorial numeral */}
|
||||
<div
|
||||
className="atelier-numeral text-[10rem] sm:text-[14rem] leading-none select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
404
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl font-bold text-[#1a1a1a] mb-4 tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
404
|
||||
<h1 className="atelier-title text-3xl sm:text-4xl mt-2">
|
||||
Off the <span className="atelier-italic text-[#786B53]">beaten path</span>
|
||||
</h1>
|
||||
<h2 className="text-2xl font-semibold text-[#1a1a1a] mb-3">
|
||||
Page not found
|
||||
</h2>
|
||||
<p className="text-[#6b8f71] mb-8">
|
||||
Looks like this route got lost along the way. Let's get you back on track.
|
||||
<p className="mt-4 text-stone-600 text-sm leading-relaxed max-w-sm mx-auto">
|
||||
We searched the field, but couldn't find that page. The route may have moved or never existed.
|
||||
</p>
|
||||
|
||||
{/* Search suggestion */}
|
||||
<div className="bg-white rounded-2xl p-4 border border-[#e5e5e5] mb-8">
|
||||
<p className="text-sm text-[#888] mb-3">Try searching for what you need:</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="flex-1 px-4 py-2 rounded-xl border border-[#e5e5e5] text-sm focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50"
|
||||
/>
|
||||
<button className="px-4 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="atelier-rule my-8" />
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[#888]">Or explore these pages:</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Link href="/" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Home
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
{[
|
||||
{ href: "/", label: "Home", desc: "Overview" },
|
||||
{ href: "/pricing", label: "Pricing", desc: "Plans" },
|
||||
{ href: "/contact", label: "Contact", desc: "Get in touch" },
|
||||
{ href: "/tuxedo", label: "Tuxedo", desc: "Sweet corn" },
|
||||
].map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group flex flex-col items-center gap-1 px-3 py-3.5 rounded-xl border border-stone-200/80 bg-white/70 backdrop-blur-sm hover:border-[#14532D] hover:bg-white transition-all"
|
||||
>
|
||||
<span className="text-sm font-semibold text-stone-900 group-hover:text-[#14532D] transition-colors">
|
||||
{link.label}
|
||||
</span>
|
||||
<span className="text-[10px] tracking-[0.12em] uppercase text-stone-500" style={{ fontFamily: "var(--font-fragment-mono)" }}>
|
||||
{link.desc}
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/pricing" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Pricing
|
||||
</Link>
|
||||
<Link href="/blog" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Blog
|
||||
</Link>
|
||||
<Link href="/roadmap" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Roadmap
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Report broken link */}
|
||||
<div className="mt-12 pt-8 border-t border-[#e5e5e5]">
|
||||
<p className="text-xs text-[#888]">
|
||||
Found a broken link?{" "}
|
||||
<a href="mailto:support@routecommerce.com" className="text-[#1a4d2e] hover:underline">
|
||||
Let us know
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p className="atelier-fineprint text-center mt-10">
|
||||
Lost? Reach us at{" "}
|
||||
<a href="mailto:support@routecommerce.com" className="text-[#14532D] hover:underline normal-case tracking-normal">
|
||||
support@routecommerce.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+69
-3
@@ -3,6 +3,63 @@ import LandingPageClient from "./LandingPageClient";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
// JSON-LD structured data for the platform landing page.
|
||||
// Schema.org SoftwareApplication + Organization + FAQPage so Google
|
||||
// can render rich results for the product.
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": `${BASE_URL}/#organization`,
|
||||
name: "Route Commerce",
|
||||
url: BASE_URL,
|
||||
logo: `${BASE_URL}/logo.png`,
|
||||
description:
|
||||
"Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
|
||||
sameAs: [
|
||||
// Add social profiles here as they come online
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": `${BASE_URL}/#software`,
|
||||
name: "Route Commerce",
|
||||
applicationCategory: "BusinessApplication",
|
||||
applicationSubCategory: "Wholesale Distribution Platform",
|
||||
operatingSystem: "Web",
|
||||
url: BASE_URL,
|
||||
description:
|
||||
"All-in-one platform for produce wholesale distribution: orders, stops, routes, customer communications, and Stripe/Square payments.",
|
||||
offers: {
|
||||
"@type": "AggregateOffer",
|
||||
priceCurrency: "USD",
|
||||
lowPrice: 49,
|
||||
highPrice: 399,
|
||||
priceSpecification: [
|
||||
{ "@type": "UnitPriceSpecification", name: "Starter", price: 49, priceCurrency: "USD", description: "$49/month" },
|
||||
{ "@type": "UnitPriceSpecification", name: "Farm", price: 149, priceCurrency: "USD", description: "$149/month" },
|
||||
{ "@type": "UnitPriceSpecification", name: "Enterprise", price: 399, priceCurrency: "USD", description: "Custom pricing" },
|
||||
],
|
||||
},
|
||||
aggregateRating: {
|
||||
"@type": "AggregateRating",
|
||||
// Placeholder — replace with real review data once collected.
|
||||
ratingValue: 4.9,
|
||||
reviewCount: 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${BASE_URL}/#website`,
|
||||
url: BASE_URL,
|
||||
name: "Route Commerce",
|
||||
publisher: { "@id": `${BASE_URL}/#organization` },
|
||||
inLanguage: "en-US",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Route Commerce — Fresh Produce Wholesale Platform",
|
||||
description: "The all-in-one platform for produce wholesale distribution. Manage orders, stops, routes, and customer communications. Built for farms, Co-ops, and distributors.",
|
||||
@@ -19,7 +76,7 @@ export const metadata: Metadata = {
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/og-default.jpg",
|
||||
url: "/og-default.svg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Route Commerce Platform",
|
||||
@@ -32,7 +89,7 @@ export const metadata: Metadata = {
|
||||
description: "The all-in-one platform for produce wholesale distribution.",
|
||||
site: "@RouteCommerce",
|
||||
creator: "@RouteCommerce",
|
||||
images: ["/og-default.jpg"],
|
||||
images: ["/og-default.svg"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: BASE_URL,
|
||||
@@ -61,5 +118,14 @@ export const viewport = {
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
return <LandingPageClient />;
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<LandingPageClient />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Loading pricing...",
|
||||
description: "Loading...",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Nav skeleton */}
|
||||
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-xl bg-slate-200 animate-pulse" />
|
||||
<div className="h-6 w-36 rounded bg-slate-200 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="h-4 w-16 rounded bg-slate-200 animate-pulse" />
|
||||
<div className="h-4 w-20 rounded bg-slate-200 animate-pulse" />
|
||||
<div className="h-9 w-24 rounded-xl bg-slate-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero skeleton */}
|
||||
<section className="bg-gradient-to-b from-slate-50 to-white px-6 py-20 text-center">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="h-6 w-40 rounded-full bg-slate-200 mx-auto mb-4 animate-pulse" />
|
||||
<div className="h-16 w-80 rounded-lg bg-slate-200 mx-auto mb-6 animate-pulse" />
|
||||
<div className="h-6 w-96 rounded bg-slate-200 mx-auto animate-pulse" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Plan cards skeleton */}
|
||||
<section className="mx-auto max-w-6xl px-6 py-6">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-2xl border-2 border-slate-200 p-6">
|
||||
<div className="h-6 w-24 rounded bg-slate-200 mb-3 animate-pulse" />
|
||||
<div className="h-4 w-32 rounded bg-slate-200 mb-1 animate-pulse" />
|
||||
<div className="h-10 w-24 rounded bg-slate-200 mb-4 animate-pulse" />
|
||||
<div className="h-12 w-full rounded-xl bg-slate-200 animate-pulse" />
|
||||
<div className="mt-6 space-y-2.5">
|
||||
{[1, 2, 3, 4].map((j) => (
|
||||
<div key={j} className="h-4 w-full rounded bg-slate-100 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<span role="status" className="sr-only">Loading pricing page...</span>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { getSession, signOut } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// `getSession()` reads cookies(), which forces dynamic rendering.
|
||||
// Without this, Next.js tries to prerender this page at build time
|
||||
// and fails with "Dynamic server usage".
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* /protected-example
|
||||
*
|
||||
@@ -46,7 +51,7 @@ export default async function ProtectedExamplePage() {
|
||||
<header>
|
||||
<h1
|
||||
className="text-3xl font-semibold tracking-tight text-stone-900"
|
||||
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}
|
||||
style={{ fontFamily: "var(--font-fraunces)" }}
|
||||
>
|
||||
Protected example
|
||||
</h1>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function RoadmapPage() {
|
||||
{/* Hero */}
|
||||
<section className="bg-gradient-to-b from-white to-[#faf8f5] py-12 sm:py-16 md:py-20">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Product Roadmap
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||
@@ -189,7 +189,7 @@ export default function RoadmapPage() {
|
||||
<section id="suggest" className="py-16 bg-white border-t border-[#e5e5e5]">
|
||||
<div className="max-w-2xl mx-auto px-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Suggest a Feature
|
||||
</h2>
|
||||
<p className="text-[#666]">Have an idea? We'd love to hear it. Share your suggestion and vote on others.</p>
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function SecurityPage() {
|
||||
{/* Hero */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 text-center">
|
||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Your Data is Safe with Us
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||
@@ -122,7 +122,7 @@ export default function SecurityPage() {
|
||||
{/* Security Features */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-12 text-center" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-12 text-center" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Enterprise-Grade Security
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -144,7 +144,7 @@ export default function SecurityPage() {
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Built on Trusted Infrastructure
|
||||
</h2>
|
||||
<p className="text-[#666] mb-6">
|
||||
@@ -219,7 +219,7 @@ export default function SecurityPage() {
|
||||
{/* Privacy Compliance */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Privacy Compliance
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
@@ -242,7 +242,7 @@ export default function SecurityPage() {
|
||||
{/* Report Vulnerability */}
|
||||
<section className="py-16 bg-[#1a4d2e] text-white">
|
||||
<div className="max-w-2xl mx-auto px-6 text-center">
|
||||
<h2 className="text-3xl font-bold mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold mb-4" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Found a Security Issue?
|
||||
</h2>
|
||||
<p className="text-white/80 mb-6">
|
||||
|
||||
@@ -1,40 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-4xl px-6 py-20">
|
||||
{/* Hero skeleton */}
|
||||
<div className="mb-20 animate-pulse text-center">
|
||||
<div className="h-3 w-24 rounded bg-stone-200 mx-auto mb-5" />
|
||||
<div className="h-20 w-full max-w-xl mx-auto rounded-lg bg-stone-200 mb-5" />
|
||||
<div className="h-8 w-full max-w-2xl mx-auto rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Content sections skeleton */}
|
||||
<div className="space-y-20">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="grid md:grid-cols-2 gap-12 items-center"
|
||||
>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 w-20 rounded bg-stone-200" />
|
||||
<div className="h-8 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
||||
</div>
|
||||
<div className="h-64 rounded-3xl bg-stone-200" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,62 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-4xl px-6 py-20">
|
||||
{/* Header skeleton */}
|
||||
<div className="mb-16 animate-pulse text-center">
|
||||
<div className="h-3 w-20 rounded bg-emerald-100 mx-auto mb-5" />
|
||||
<div className="h-16 w-64 mx-auto rounded-lg bg-stone-200 mb-5" />
|
||||
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Contact cards skeleton */}
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-16">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center"
|
||||
>
|
||||
<div className="h-12 w-12 rounded-2xl bg-emerald-50 mx-auto mb-5" />
|
||||
<div className="h-5 w-24 mx-auto rounded bg-stone-200 mb-3" />
|
||||
<div className="h-4 w-full max-w-[160px] mx-auto rounded bg-stone-100" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form skeleton */}
|
||||
<div className="rounded-3xl bg-white p-10 shadow-sm ring-1 ring-stone-200/60">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 w-32 rounded bg-stone-200" />
|
||||
<div className="h-8 w-48 rounded bg-stone-200" />
|
||||
<div className="grid gap-6 md:grid-cols-2 mt-8">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
<div className="h-12 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
<div className="h-12 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-16 rounded bg-stone-100" />
|
||||
<div className="h-12 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-stone-100" />
|
||||
<div className="h-32 rounded-xl bg-stone-100" />
|
||||
</div>
|
||||
<div className="h-14 w-40 rounded-xl bg-emerald-100 mt-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,41 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-3xl px-6 py-20">
|
||||
{/* Header skeleton */}
|
||||
<div className="mb-14 text-center animate-pulse">
|
||||
<div className="h-3 w-16 rounded bg-emerald-100 mx-auto mb-4" />
|
||||
<div className="h-16 w-48 mx-auto rounded-lg bg-stone-200 mb-5" />
|
||||
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Search skeleton */}
|
||||
<div className="mb-12">
|
||||
<div className="h-14 rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60" />
|
||||
</div>
|
||||
|
||||
{/* FAQ items skeleton */}
|
||||
<div className="space-y-4">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-5">
|
||||
<div className="h-5 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-7 w-7 rounded-full bg-stone-100" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
+43
-16
@@ -1,23 +1,45 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
// BreadcrumbList schema for SEO
|
||||
const tuxedoBreadcrumbSchema = {
|
||||
// Structured data for the Tuxedo Corn storefront. Combining a
|
||||
// BreadcrumbList with a LocalBusiness (the farm stand) gives Google
|
||||
// enough context to show rich results for the brand.
|
||||
const tuxedoStructuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": BASE_URL,
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Home", item: BASE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Tuxedo Corn", item: `${BASE_URL}/tuxedo` },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Tuxedo Corn",
|
||||
"item": `${BASE_URL}/tuxedo`,
|
||||
"@type": "LocalBusiness",
|
||||
"@id": `${BASE_URL}/tuxedo/#localbusiness`,
|
||||
name: "Tuxedo Corn",
|
||||
description:
|
||||
"Premium sweet corn and seasonal produce, delivered fresh from our Colorado farm to pickup stops near you.",
|
||||
url: `${BASE_URL}/tuxedo`,
|
||||
image: `${BASE_URL}/og-default.svg`,
|
||||
priceRange: "$$",
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressRegion: "CO",
|
||||
addressCountry: "US",
|
||||
},
|
||||
openingHoursSpecification: [
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
dayOfWeek: ["Saturday", "Sunday"],
|
||||
description: "Pickup stops scheduled seasonally — see stops page",
|
||||
},
|
||||
],
|
||||
sameAs: [
|
||||
// Social profiles can be added here
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -41,7 +63,7 @@ export const metadata: Metadata = {
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/og-tuxedo.jpg",
|
||||
url: "/og-default.svg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Tuxedo Corn - Olathe Sweet Sweet Corn",
|
||||
@@ -54,7 +76,7 @@ export const metadata: Metadata = {
|
||||
description: "Premium sweet corn and seasonal produce delivered fresh from our Colorado farm to pickup stops near you.",
|
||||
site: "@TuxedoCorn",
|
||||
creator: "@TuxedoCorn",
|
||||
images: ["/og-tuxedo.jpg"],
|
||||
images: ["/og-default.svg"],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${BASE_URL}/tuxedo`,
|
||||
@@ -68,7 +90,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
},
|
||||
other: {
|
||||
"application/ld+json": JSON.stringify(tuxedoBreadcrumbSchema),
|
||||
"application/ld+json": JSON.stringify(tuxedoStructuredData),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -86,7 +108,12 @@ export default function TuxedoLayout({ children }: { children: React.ReactNode }
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" />
|
||||
</div>
|
||||
<div className="relative">{children}</div>
|
||||
{/* The storefront backdrop and ambient orbs are persistent. The
|
||||
page body itself crossfades between routes via the View
|
||||
Transitions API. */}
|
||||
<div id="page-content" className="relative outline-none">
|
||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,8 @@
|
||||
"use client";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-6xl px-6 py-16">
|
||||
{/* Header skeleton */}
|
||||
<div className="mb-16 animate-pulse">
|
||||
<div className="h-4 w-32 rounded bg-stone-200 mb-4" />
|
||||
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
|
||||
<div className="h-6 w-96 max-w-full rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Cards skeleton */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="rounded-3xl bg-white ring-1 ring-stone-200/60 overflow-hidden"
|
||||
>
|
||||
{/* Image skeleton */}
|
||||
<div className="h-48 bg-gradient-to-br from-stone-100 to-stone-50 relative overflow-hidden">
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-stone-200/60 to-transparent" />
|
||||
</div>
|
||||
{/* Content skeleton */}
|
||||
<div className="p-7 space-y-4">
|
||||
<div className="h-6 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
||||
<div className="pt-4 flex items-center justify-between">
|
||||
<div className="h-8 w-20 rounded-lg bg-stone-200" />
|
||||
<div className="h-11 w-28 rounded-xl bg-stone-200" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stops section skeleton */}
|
||||
<div className="mt-20">
|
||||
<div className="animate-pulse mb-10">
|
||||
<div className="h-3 w-24 rounded bg-stone-200 mb-4" />
|
||||
<div className="h-10 w-64 rounded-lg bg-stone-200 mb-3" />
|
||||
<div className="h-4 w-48 rounded bg-stone-200" />
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="rounded-3xl bg-white p-7 ring-1 ring-stone-200/60"
|
||||
>
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
<div className="h-10 w-10 rounded-xl bg-stone-100" />
|
||||
<div className="flex-1">
|
||||
<div className="h-8 w-32 rounded bg-stone-200 mb-2" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pl-[2.75rem]">
|
||||
<div className="h-4 w-28 rounded bg-stone-100" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
/** Tuxedo storefront loading — the emerald backdrop and grain
|
||||
* texture persist in the layout, so we only need to signal that
|
||||
* navigation is in flight. */
|
||||
export default function TuxedoLoading() {
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -1,70 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="mx-auto max-w-5xl px-6 py-20">
|
||||
{/* Back navigation skeleton */}
|
||||
<div className="mb-10 animate-pulse">
|
||||
<div className="h-5 w-32 rounded bg-stone-200" />
|
||||
</div>
|
||||
|
||||
{/* Stop header skeleton */}
|
||||
<div className="mb-12 animate-pulse rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
|
||||
<div className="h-3 w-32 rounded bg-emerald-100 mb-4" />
|
||||
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
|
||||
<div className="h-5 w-full max-w-md rounded bg-stone-200 mb-4" />
|
||||
<div className="h-px w-12 bg-emerald-600" />
|
||||
</div>
|
||||
|
||||
{/* Stop info skeleton */}
|
||||
<div className="mb-14 rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
|
||||
>
|
||||
<div className="h-10 w-10 rounded-xl bg-emerald-50" />
|
||||
<div className="flex-1">
|
||||
<div className="h-3 w-12 rounded bg-stone-200 mb-2" />
|
||||
<div className="h-5 w-24 rounded bg-stone-200" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products section skeleton */}
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 w-20 rounded bg-emerald-100 mb-4" />
|
||||
<div className="h-10 w-48 rounded bg-stone-200 mb-4" />
|
||||
<div className="h-4 w-64 rounded bg-stone-200 mb-10" />
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="rounded-3xl bg-white overflow-hidden shadow-lg"
|
||||
>
|
||||
<div className="h-48 bg-gradient-to-br from-stone-100 to-stone-50" />
|
||||
<div className="p-6 space-y-3">
|
||||
<div className="h-6 w-3/4 rounded bg-stone-200" />
|
||||
<div className="h-4 w-full rounded bg-stone-100" />
|
||||
<div className="h-4 w-2/3 rounded bg-stone-100" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export default function WaitlistPage() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#1a1a1a] tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>Route Commerce</span>
|
||||
<span className="text-xl font-bold text-[#1a1a1a] tracking-tight" style={{ fontFamily: "var(--font-fraunces)" }}>Route Commerce</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
@@ -37,7 +37,7 @@ export default function WaitlistPage() {
|
||||
<span className="w-2 h-2 bg-[#c97a3e] rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium text-[#c97a3e]">Early Access</span>
|
||||
</div>
|
||||
<h1 className="text-5xl sm:text-6xl font-bold text-[#1a1a1a] leading-tight mb-6" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h1 className="text-5xl sm:text-6xl font-bold text-[#1a1a1a] leading-tight mb-6" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Join 500+ farms on the waitlist
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71] mb-8 leading-relaxed">
|
||||
@@ -76,7 +76,7 @@ export default function WaitlistPage() {
|
||||
{/* Right - Form */}
|
||||
<div>
|
||||
<div className="bg-white rounded-3xl p-8 sm:p-10 shadow-xl border border-[#e5e5e5]">
|
||||
<h2 className="text-2xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-2xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Reserve Your Spot
|
||||
</h2>
|
||||
<p className="text-[#888] mb-6">No credit card required. We'll notify you when it's your turn.</p>
|
||||
@@ -89,7 +89,7 @@ export default function WaitlistPage() {
|
||||
{/* FAQ Section */}
|
||||
<section className="border-t border-[#e5e5e5] bg-white">
|
||||
<div className="max-w-3xl mx-auto px-6 py-16">
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
|
||||
/** Subtle navigation indicator — see LoadingFade for rationale. */
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-100 px-4 py-6">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<div className="animate-pulse space-y-5">
|
||||
<div className="h-16 rounded-xl bg-slate-800" />
|
||||
<div className="h-8 w-48 rounded bg-slate-300" />
|
||||
<div className="h-12 w-full rounded-xl bg-white" />
|
||||
<div className="h-12 w-full rounded-xl bg-white" />
|
||||
<div className="h-12 w-full rounded-xl bg-white" />
|
||||
<div className="h-12 w-full rounded-xl bg-white" />
|
||||
<div className="h-10 w-full rounded-xl bg-slate-300" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <LoadingFade />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signOutAction } from "@/actions/auth-actions";
|
||||
import BrandSelector from "@/components/admin/BrandSelector";
|
||||
|
||||
// Elegant warm sidebar design
|
||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||
@@ -470,14 +471,24 @@ export default function AdminSidebar({
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Bottom: role + sign out */}
|
||||
{/* Bottom: brand picker + role + sign out */}
|
||||
<div
|
||||
className="px-4 py-5 border-t flex-shrink-0"
|
||||
className="px-4 py-5 border-t flex-shrink-0 space-y-3"
|
||||
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
||||
>
|
||||
{/* Brand selector — only show when admin has access to brands */}
|
||||
{brands && brands.length > 0 && (
|
||||
<BrandSelector
|
||||
brands={brands}
|
||||
activeBrandId={activeBrandId ?? null}
|
||||
showAllBrandsOption={userRole === "platform_admin"}
|
||||
isMultiBrandAdmin={(brandIds?.length ?? 0) > 1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{roleLabel && (
|
||||
<div
|
||||
className="px-3 py-2.5 mb-3 rounded-xl border"
|
||||
className="px-3 py-2.5 rounded-xl border"
|
||||
style={{
|
||||
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)"
|
||||
|
||||
@@ -15,7 +15,6 @@ type Stop = {
|
||||
time: string;
|
||||
location: string;
|
||||
active: boolean;
|
||||
deleted_at?: string | null;
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
brands: { name: string } | { name: string }[];
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
|
||||
import dynamic from "next/dynamic";
|
||||
import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge } from "@/components/admin/design-system";
|
||||
import { getDashboardStats, type DashboardStats } from "@/actions/dashboard";
|
||||
import type { DashboardStats } from "@/actions/dashboard";
|
||||
|
||||
// Lazy-load the upgrade modal — only needed when starter tier + brandId present
|
||||
const UpgradePlanModal = dynamic(
|
||||
() => import("@/components/admin/UpgradePlanModal"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
type Section = {
|
||||
title: string;
|
||||
@@ -17,112 +23,21 @@ type Section = {
|
||||
};
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: "Orders",
|
||||
href: "/admin/orders",
|
||||
description: "View orders, pickup status, fulfillment, and customer details.",
|
||||
group: "operations",
|
||||
prominent: true,
|
||||
},
|
||||
{
|
||||
title: "Products",
|
||||
href: "/admin/products",
|
||||
description: "Manage products, pricing, shipping type, and availability.",
|
||||
group: "operations",
|
||||
prominent: true,
|
||||
},
|
||||
{
|
||||
title: "Tours & Stops",
|
||||
href: "/admin/stops",
|
||||
description: "Manage routes, pickup locations, dates, and cutoff times.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Driver Pickup",
|
||||
href: "/admin/pickup",
|
||||
description: "Mobile pickup lookup, QR scanning, and completion tools.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Shipping",
|
||||
href: "/admin/shipping",
|
||||
description: "FedEx integration, label creation, and shipment tracking.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
href: "/admin/reports",
|
||||
description: "Sales, route, product, pickup, and customer reports.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Tax Dashboard",
|
||||
href: "/admin/taxes",
|
||||
description: "Sales tax collected, breakdown by state, and exportable reports.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
href: "/admin/settings",
|
||||
description: "Users, billing, brand, integrations, payments, and shipping.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Harvest Reach",
|
||||
href: "/admin/communications",
|
||||
description: "Email campaigns, stop blast, templates, and audience segments.",
|
||||
group: "tools",
|
||||
addonKey: "harvest_reach",
|
||||
upgradeText: "Enable to access email & SMS marketing",
|
||||
},
|
||||
{
|
||||
title: "Wholesale Portal",
|
||||
href: "/admin/wholesale",
|
||||
description: "Standalone B2B portal with custom pricing, credit limits, and net-30.",
|
||||
group: "tools",
|
||||
addonKey: "wholesale_portal",
|
||||
upgradeText: "Enable to unlock B2B buyer portal",
|
||||
},
|
||||
{
|
||||
title: "Import Center",
|
||||
href: "/admin/import",
|
||||
description: "AI-powered import for products, orders, contacts, and stops.",
|
||||
group: "tools",
|
||||
addonKey: "ai_tools",
|
||||
upgradeText: "Enable AI import with smart column mapping",
|
||||
},
|
||||
{
|
||||
title: "AI Intelligence",
|
||||
href: "/admin/settings/ai",
|
||||
description: "Campaign writer, pricing advisor, and report explainer.",
|
||||
group: "tools",
|
||||
addonKey: "ai_tools",
|
||||
upgradeText: "Enable AI tools for marketing and pricing",
|
||||
},
|
||||
{
|
||||
title: "Time Tracking",
|
||||
href: "/admin/time-tracking",
|
||||
description: "Worker clock-in/out, hours tracking, and overtime management.",
|
||||
group: "tools",
|
||||
addonKey: "time_tracking",
|
||||
upgradeText: "Enable for field worker time tracking",
|
||||
},
|
||||
{
|
||||
title: "Water Log",
|
||||
href: "/admin/water-log",
|
||||
description: "Irrigation tracking and water usage reporting.",
|
||||
group: "tools",
|
||||
addonKey: "water_log",
|
||||
upgradeText: "Enable for agricultural water tracking",
|
||||
},
|
||||
{
|
||||
title: "Route Trace",
|
||||
href: "/admin/route-trace",
|
||||
description: "Lot tracking, QR stickers, hauling board, and supply chain traceability.",
|
||||
group: "tools",
|
||||
addonKey: "route_trace",
|
||||
upgradeText: "Enable for field-to-delivery traceability",
|
||||
},
|
||||
{ title: "Orders", href: "/admin/orders", description: "View orders, pickup status, fulfillment, and customer details.", group: "operations", prominent: true },
|
||||
{ title: "Products", href: "/admin/products", description: "Manage products, pricing, shipping type, and availability.", group: "operations", prominent: true },
|
||||
{ title: "Tours & Stops", href: "/admin/stops", description: "Manage routes, pickup locations, dates, and cutoff times.", group: "fulfillment" },
|
||||
{ title: "Driver Pickup", href: "/admin/pickup", description: "Mobile pickup lookup, QR scanning, and completion tools.", group: "fulfillment" },
|
||||
{ title: "Shipping", href: "/admin/shipping", description: "FedEx integration, label creation, and shipment tracking.", group: "fulfillment" },
|
||||
{ title: "Reports", href: "/admin/reports", description: "Sales, route, product, pickup, and customer reports.", group: "management" },
|
||||
{ title: "Tax Dashboard", href: "/admin/taxes", description: "Sales tax collected, breakdown by state, and exportable reports.", group: "management" },
|
||||
{ title: "Settings", href: "/admin/settings", description: "Users, billing, brand, integrations, payments, and shipping.", group: "management" },
|
||||
{ title: "Harvest Reach", href: "/admin/communications", description: "Email campaigns, stop blast, templates, and audience segments.", group: "tools", addonKey: "harvest_reach", upgradeText: "Enable to access email & SMS marketing" },
|
||||
{ title: "Wholesale Portal", href: "/admin/wholesale", description: "Standalone B2B portal with custom pricing, credit limits, and net-30.", group: "tools", addonKey: "wholesale_portal", upgradeText: "Enable to unlock B2B buyer portal" },
|
||||
{ title: "Import Center", href: "/admin/import", description: "AI-powered import for products, orders, contacts, and stops.", group: "tools", addonKey: "ai_tools", upgradeText: "Enable AI import with smart column mapping" },
|
||||
{ title: "AI Intelligence", href: "/admin/settings/ai", description: "Campaign writer, pricing advisor, and report explainer.", group: "tools", addonKey: "ai_tools", upgradeText: "Enable AI tools for marketing and pricing" },
|
||||
{ title: "Time Tracking", href: "/admin/time-tracking", description: "Worker clock-in/out, hours tracking, and overtime management.", group: "tools", addonKey: "time_tracking", upgradeText: "Enable for field worker time tracking" },
|
||||
{ title: "Water Log", href: "/admin/water-log", description: "Irrigation tracking and water usage reporting.", group: "tools", addonKey: "water_log", upgradeText: "Enable for agricultural water tracking" },
|
||||
{ title: "Route Trace", href: "/admin/route-trace", description: "Lot tracking, QR stickers, hauling board, and supply chain traceability.", group: "tools", addonKey: "route_trace", upgradeText: "Enable for field-to-delivery traceability" },
|
||||
];
|
||||
|
||||
type Tab = "operations" | "fulfillment" | "management" | "tools";
|
||||
@@ -132,7 +47,7 @@ const TABS: { id: Tab; label: string; icon: ReactNode }[] = [
|
||||
id: "operations",
|
||||
label: "Operations",
|
||||
icon: (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
|
||||
</svg>
|
||||
),
|
||||
@@ -141,7 +56,7 @@ const TABS: { id: Tab; label: string; icon: ReactNode }[] = [
|
||||
id: "fulfillment",
|
||||
label: "Fulfillment",
|
||||
icon: (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16.5 9.4 7.55 4.24"/>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
@@ -153,7 +68,7 @@ const TABS: { id: Tab; label: string; icon: ReactNode }[] = [
|
||||
id: "management",
|
||||
label: "Management",
|
||||
icon: (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
@@ -163,7 +78,7 @@ const TABS: { id: Tab; label: string; icon: ReactNode }[] = [
|
||||
id: "tools",
|
||||
label: "Tools",
|
||||
icon: (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
),
|
||||
@@ -178,6 +93,7 @@ type Props = {
|
||||
enabledAddons: Record<string, boolean>;
|
||||
usage: { users: number; stops_this_month: number; products: number };
|
||||
limits: { max_users: number; max_stops_monthly: number; max_products: number };
|
||||
stats: DashboardStats;
|
||||
};
|
||||
|
||||
export default function DashboardClient({
|
||||
@@ -188,26 +104,13 @@ export default function DashboardClient({
|
||||
enabledAddons,
|
||||
usage,
|
||||
limits,
|
||||
stats,
|
||||
}: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("operations");
|
||||
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
setIsLoadingStats(true);
|
||||
try {
|
||||
const data = await getDashboardStats();
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load stats:", err);
|
||||
} finally {
|
||||
setIsLoadingStats(false);
|
||||
}
|
||||
};
|
||||
loadStats();
|
||||
}, []);
|
||||
// Stats are pre-fetched server-side — no loading state needed.
|
||||
// All values are guaranteed to be present before this component renders.
|
||||
|
||||
const usagePct = {
|
||||
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
|
||||
@@ -217,15 +120,10 @@ export default function DashboardClient({
|
||||
|
||||
const tabSections = sections.filter((s) => s.group === activeTab);
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
};
|
||||
|
||||
// Get status badge color
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, { bg: string; text: string }> = {
|
||||
pending: { bg: "var(--admin-warning-light)", text: "var(--admin-warning)" },
|
||||
@@ -235,7 +133,6 @@ export default function DashboardClient({
|
||||
return styles[status] || { bg: "var(--admin-bg)", text: "var(--admin-text-secondary)" };
|
||||
};
|
||||
|
||||
// Quick action buttons
|
||||
const quickActions = [
|
||||
{ label: "New Order", href: "/admin/orders?new=true", icon: "plus" },
|
||||
{ label: "Add Stop", href: "/admin/stops/new", icon: "map" },
|
||||
@@ -246,10 +143,10 @@ export default function DashboardClient({
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
{/* Page Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="px-4 sm:px-6 md:px-8 py-5 sm:py-6">
|
||||
<PageHeader
|
||||
icon={
|
||||
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
@@ -257,17 +154,14 @@ export default function DashboardClient({
|
||||
</svg>
|
||||
}
|
||||
title="Admin Dashboard"
|
||||
subtitle={`${brandName} Control Center`}
|
||||
subtitle={brandName}
|
||||
actions={
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="/admin/settings/billing" className="text-sm font-medium hover:underline" style={{ color: "var(--admin-text-muted)" }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<a href="/admin/settings/billing" className="hidden sm:block text-xs font-medium hover:underline" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Billing →
|
||||
</a>
|
||||
{planTier === "starter" && brandId && (
|
||||
<AdminButton
|
||||
onClick={() => setIsUpgradeOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
<AdminButton onClick={() => setIsUpgradeOpen(true)} size="sm">
|
||||
Upgrade Plan
|
||||
</AdminButton>
|
||||
)}
|
||||
@@ -277,338 +171,226 @@ export default function DashboardClient({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6 -mt-4 space-y-6">
|
||||
{/* Stats Cards Row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Main Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 pb-8 space-y-5">
|
||||
|
||||
{/* ── Stats Cards Row ────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 animate-fade-up" style={{ animationDelay: "0ms" }}>
|
||||
{/* Today's Orders */}
|
||||
<div
|
||||
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: "var(--admin-accent-light)" }}
|
||||
>
|
||||
<svg className="w-6 h-6" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-card-inner">
|
||||
<div className="admin-stat-icon" style={{ backgroundColor: "var(--admin-accent-light)" }}>
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Today's Orders
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{isLoadingStats ? "—" : stats?.todayOrders ?? 0}
|
||||
</p>
|
||||
<div className="admin-stat-body">
|
||||
<span className="admin-stat-label">Today's Orders</span>
|
||||
<span className="admin-stat-value">{stats.todayOrders}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today's Revenue */}
|
||||
<div
|
||||
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-50">
|
||||
<svg className="w-6 h-6 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-card-inner">
|
||||
<div className="admin-stat-icon" style={{ backgroundColor: "#fef3c7" }}>
|
||||
<svg className="w-4 h-4 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Today's Revenue
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{isLoadingStats ? "—" : formatCurrency(stats?.todayRevenue ?? 0)}
|
||||
</p>
|
||||
<div className="admin-stat-body">
|
||||
<span className="admin-stat-label">Today's Revenue</span>
|
||||
<span className="admin-stat-value ha-num">{formatCurrency(stats.todayRevenue)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Stops */}
|
||||
<div
|
||||
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-50">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-card-inner">
|
||||
<div className="admin-stat-icon" style={{ backgroundColor: "#dbeafe" }}>
|
||||
<svg className="w-4 h-4 text-blue-600" fill="none" viewBox="0 0 24 24" 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>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Pending Stops
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{isLoadingStats ? "—" : stats?.pendingStops ?? 0}
|
||||
</p>
|
||||
<div className="admin-stat-body">
|
||||
<span className="admin-stat-label">Pending Stops</span>
|
||||
<span className="admin-stat-value">{stats.pendingStops}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Products — uses plan-aware usage.products so this card
|
||||
always matches the "Products 0/25" usage bar below and the
|
||||
billing page's invoice/usage row. Previously this came from a
|
||||
separate query (`active=eq.true` only) and could disagree with
|
||||
the plan limit usage, producing e.g. "1 vs 0/25" in the same
|
||||
dashboard. */}
|
||||
<div
|
||||
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-50">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
{/* Active Products */}
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-card-inner">
|
||||
<div className="admin-stat-icon" style={{ backgroundColor: "#f3e8ff" }}>
|
||||
<svg className="w-4 h-4 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Active Products
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{/* usage.products comes from the server-rendered prop (via
|
||||
getBillingOverview), so it's available immediately and
|
||||
is guaranteed to match the "Products X/25" usage bar
|
||||
and the billing page. */}
|
||||
{usage.products}
|
||||
</p>
|
||||
<div className="admin-stat-body">
|
||||
<span className="admin-stat-label">Active Products</span>
|
||||
<span className="admin-stat-value">{usage.products}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions + Recent Orders Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* ── Quick Actions + Usage Row ─────────────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 animate-fade-up" style={{ animationDelay: "60ms" }}>
|
||||
{/* Quick Actions */}
|
||||
<div
|
||||
className="rounded-xl border p-5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-4" style={{ color: "var(--admin-text-primary)" }}>
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="admin-card-section">
|
||||
<div className="admin-section-header">
|
||||
<span className="admin-section-title">Quick Actions</span>
|
||||
</div>
|
||||
<div className="admin-quick-actions">
|
||||
{quickActions.map((action) => (
|
||||
<Link
|
||||
key={action.href}
|
||||
href={action.href}
|
||||
className="flex items-center gap-2 p-3 rounded-xl border transition-all hover:-translate-y-0.5 hover:shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border-light)",
|
||||
backgroundColor: "var(--admin-bg-subtle)"
|
||||
}}
|
||||
className="admin-quick-action"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg" style={{ backgroundColor: "var(--admin-accent-light)" }}>
|
||||
<div className="admin-quick-action-icon" style={{ backgroundColor: "var(--admin-accent-light)" }}>
|
||||
{action.icon === "plus" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="w-3.5 h-3.5" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
)}
|
||||
{action.icon === "map" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="w-3.5 h-3.5" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<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>
|
||||
)}
|
||||
{action.icon === "package" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="w-3.5 h-3.5" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
)}
|
||||
{action.icon === "mail" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="w-3.5 h-3.5" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--admin-text-secondary)" }}>
|
||||
{action.label}
|
||||
</span>
|
||||
<span className="admin-quick-action-label">{action.label}</span>
|
||||
<svg className="w-3.5 h-3.5 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Orders */}
|
||||
<div
|
||||
className="lg:col-span-2 rounded-xl border overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: "var(--admin-border-light)" }}>
|
||||
<h3 className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>
|
||||
Recent Orders
|
||||
</h3>
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="text-xs font-medium hover:underline"
|
||||
style={{ color: "var(--admin-accent)" }}
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
{/* Usage Stats */}
|
||||
<div className="lg:col-span-2 admin-card-section">
|
||||
<div className="admin-section-header">
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminBadge variant={planTier === "enterprise" ? "warning" : planTier === "farm" ? "success" : "default"}>
|
||||
{planTier.charAt(0).toUpperCase() + planTier.slice(1)}
|
||||
</AdminBadge>
|
||||
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>{brandId ? brandName : "All Brands"}</span>
|
||||
</div>
|
||||
<a href="/admin/settings/billing" className="text-xs font-medium hover:underline sm:hidden" style={{ color: "var(--admin-accent)" }}>
|
||||
Manage →
|
||||
</a>
|
||||
</div>
|
||||
<div className="divide-y" style={{ borderColor: "var(--admin-border-light)" }}>
|
||||
{stats?.recentOrders && stats.recentOrders.length > 0 ? (
|
||||
stats.recentOrders.map((order) => {
|
||||
const badge = getStatusBadge(order.status);
|
||||
return (
|
||||
<div key={order.id} className="flex items-center justify-between px-5 py-4 hover:bg-stone-50 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<svg className="w-5 h-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{order.customer_name}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||||
{order.created_at}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-usage-grid">
|
||||
{[
|
||||
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users, icon: "users" },
|
||||
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops, icon: "map" },
|
||||
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products, icon: "package" },
|
||||
].map(({ label, value, pct }) => (
|
||||
<div key={label} className="admin-usage-item">
|
||||
<div className="admin-usage-header">
|
||||
<span className="admin-usage-label">{label}</span>
|
||||
<span className="admin-usage-value ha-num">{value}</span>
|
||||
</div>
|
||||
<div className="admin-usage-bar">
|
||||
<div
|
||||
className="admin-usage-fill"
|
||||
style={{
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
backgroundColor: pct > 90 ? "var(--admin-danger)" : pct > 75 ? "#f59e0b" : "var(--admin-accent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{pct > 85 && (
|
||||
<span className="admin-usage-warning">Near limit</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Recent Orders ─────────────────────────────────────── */}
|
||||
<div className="admin-card-section animate-fade-up" style={{ animationDelay: "120ms" }}>
|
||||
<div className="admin-section-header">
|
||||
<span className="admin-section-title">Recent Orders</span>
|
||||
<Link href="/admin/orders" className="text-xs font-medium hover:underline" style={{ color: "var(--admin-accent)" }}>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
{stats.recentOrders && stats.recentOrders.length > 0 ? (
|
||||
<div className="admin-orders-list">
|
||||
{stats.recentOrders.slice(0, 6).map((order) => {
|
||||
const badge = getStatusBadge(order.status);
|
||||
return (
|
||||
<Link
|
||||
key={order.id}
|
||||
href={`/admin/orders`}
|
||||
className="admin-order-row"
|
||||
>
|
||||
<div className="admin-order-info">
|
||||
<div className="admin-order-icon">
|
||||
<svg className="w-4 h-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{formatCurrency(order.total)}
|
||||
</span>
|
||||
<span
|
||||
className="px-2.5 py-1 rounded-full text-xs font-medium capitalize"
|
||||
style={{ backgroundColor: badge.bg, color: badge.text }}
|
||||
>
|
||||
{order.status}
|
||||
</span>
|
||||
<div>
|
||||
<span className="admin-order-name">{order.customer_name}</span>
|
||||
<span className="admin-order-time">{order.created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="px-5 py-12 text-center">
|
||||
<svg className="w-12 h-12 mx-auto mb-3" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<p className="text-sm" style={{ color: "var(--admin-text-muted)" }}>
|
||||
No recent orders
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/orders?new=true"
|
||||
className="inline-block mt-3 text-xs font-medium hover:underline"
|
||||
style={{ color: "var(--admin-accent)" }}
|
||||
>
|
||||
Create your first order →
|
||||
<div className="admin-order-meta">
|
||||
<span className="admin-order-amount ha-num">{formatCurrency(order.total)}</span>
|
||||
<span className="admin-order-badge" style={{ backgroundColor: badge.bg, color: badge.text }}>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-orders-empty">
|
||||
<svg className="w-10 h-10 mb-2" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<p className="text-sm" style={{ color: "var(--admin-text-muted)" }}>No recent orders</p>
|
||||
<Link href="/admin/orders?new=true" className="text-xs font-medium hover:underline mt-1" style={{ color: "var(--admin-accent)" }}>
|
||||
Create your first order →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage stats bar */}
|
||||
<div
|
||||
className="rounded-xl border p-5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<AdminBadge variant={
|
||||
planTier === "enterprise" ? "warning" :
|
||||
planTier === "farm" ? "success" :
|
||||
"default"
|
||||
}>
|
||||
{planTier.charAt(0).toUpperCase() + planTier.slice(1)} Plan
|
||||
</AdminBadge>
|
||||
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||||
{brandId ? brandName : "All Brands"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 sm:gap-8">
|
||||
{[
|
||||
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users, icon: "users" },
|
||||
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops, icon: "map" },
|
||||
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products, icon: "package" },
|
||||
].map(({ label, value, pct }) => (
|
||||
<div key={label}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{label === "Users" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
)}
|
||||
{label === "Stops" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" 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>
|
||||
)}
|
||||
{label === "Products" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm font-medium" style={{ color: "var(--admin-text-secondary)" }}>{label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>{value}</span>
|
||||
</div>
|
||||
<div className="h-2.5 rounded-full overflow-hidden" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
backgroundColor: pct > 90 ? "var(--admin-danger)" : pct > 75 ? "#f59e0b" : "var(--admin-accent)"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{pct > 85 && (
|
||||
<p className="text-xs mt-1.5" style={{ color: "var(--admin-danger)" }}>
|
||||
Near limit - consider upgrading
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* ── Section Navigation Tabs ───────────────────────────── */}
|
||||
<div className="animate-fade-up" style={{ animationDelay: "180ms" }}>
|
||||
<AdminFilterTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={(value) => setActiveTab(value as Tab)}
|
||||
tabs={TABS.map((tab) => ({ value: tab.id, label: tab.label, icon: tab.icon }))}
|
||||
size="md"
|
||||
showCounts={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<AdminFilterTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={(value) => setActiveTab(value as Tab)}
|
||||
tabs={TABS.map((tab) => ({
|
||||
value: tab.id,
|
||||
label: tab.label,
|
||||
icon: tab.icon,
|
||||
}))}
|
||||
size="md"
|
||||
showCounts={false}
|
||||
/>
|
||||
|
||||
{/* Section Cards Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{/* ── Section Cards Grid ─────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3 animate-fade-up" style={{ animationDelay: "240ms" }}>
|
||||
{tabSections.map((section) => {
|
||||
if (section.title === "Water Log" && !isWaterLogVisible) return null;
|
||||
if (section.title === "Route Trace" && !enabledAddons["route_trace"]) return null;
|
||||
@@ -622,87 +404,64 @@ export default function DashboardClient({
|
||||
key={section.title}
|
||||
href={section.href}
|
||||
className={[
|
||||
"group relative flex flex-col rounded-xl border p-5 transition-all hover:-translate-y-0.5 hover:shadow-md",
|
||||
isAddon && !isEnabled
|
||||
? "border-stone-200 shadow-sm opacity-75 hover:opacity-100"
|
||||
: isProminent
|
||||
? "border-[var(--admin-accent)]/20 shadow-[0_2px_8px_rgba(0,0,0,0.04)]"
|
||||
: "border-stone-200 shadow-sm",
|
||||
].join(" ")}
|
||||
style={{ backgroundColor: "var(--admin-card-bg)" }}
|
||||
"admin-section-card",
|
||||
isAddon && !isEnabled ? "admin-section-card--locked" : "",
|
||||
isProminent ? "admin-section-card--prominent" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-xl transition-transform group-hover:scale-110 ${
|
||||
isAddon && !isEnabled
|
||||
? "bg-stone-100 text-stone-400"
|
||||
: isProminent
|
||||
? "bg-emerald-50 text-emerald-600"
|
||||
: "bg-stone-100 text-stone-600"
|
||||
}`}>
|
||||
<div className="admin-section-card-top">
|
||||
<div className={[
|
||||
"admin-section-card-icon",
|
||||
isAddon && !isEnabled ? "admin-section-card-icon--locked" : "",
|
||||
isProminent ? "admin-section-card-icon--prominent" : "",
|
||||
].filter(Boolean).join(" ")}>
|
||||
{section.title === "Orders" && (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
)}
|
||||
{section.title === "Products" && (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
)}
|
||||
{section.title === "Tours & Stops" && (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" 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>
|
||||
)}
|
||||
{section.title === "Driver Pickup" && (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||
</svg>
|
||||
)}
|
||||
{!["Orders", "Products", "Tours & Stops", "Driver Pickup"].includes(section.title) && (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{isAddon && !isEnabled && (
|
||||
<AdminBadge variant="warning">
|
||||
Add-on
|
||||
</AdminBadge>
|
||||
)}
|
||||
{isAddon && isEnabled && (
|
||||
<AdminBadge variant="success">
|
||||
Active
|
||||
</AdminBadge>
|
||||
)}
|
||||
{isProminent && (
|
||||
<AdminBadge variant="success">
|
||||
Core
|
||||
</AdminBadge>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isAddon && !isEnabled && <AdminBadge variant="warning">Add-on</AdminBadge>}
|
||||
{isAddon && isEnabled && <AdminBadge variant="success">Active</AdminBadge>}
|
||||
{isProminent && <AdminBadge variant="success">Core</AdminBadge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold leading-tight" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{section.title}
|
||||
</h3>
|
||||
|
||||
<p className={`mt-2 text-sm leading-relaxed ${
|
||||
isAddon && !isEnabled ? "text-stone-500" : "text-stone-600"
|
||||
}`}>
|
||||
{section.description}
|
||||
</p>
|
||||
<div className="admin-section-card-body">
|
||||
<h3 className="admin-section-card-title">{section.title}</h3>
|
||||
<p className={["admin-section-card-desc", isAddon && !isEnabled ? "text-stone-400" : ""].filter(Boolean).join(" ")}>
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAddon && !isEnabled && section.upgradeText && (
|
||||
<p className="mt-3 text-xs font-medium" style={{ color: "var(--admin-warning)" }}>
|
||||
{section.upgradeText}
|
||||
</p>
|
||||
<p className="admin-section-card-hint">{section.upgradeText}</p>
|
||||
)}
|
||||
|
||||
{/* Arrow indicator */}
|
||||
<div className="mt-4 flex items-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity" style={{ color: "var(--admin-accent)" }}>
|
||||
Open section
|
||||
<svg className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<div className="admin-section-card-arrow">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -712,7 +471,7 @@ export default function DashboardClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade Modal */}
|
||||
{/* Upgrade Modal — lazy-loaded via next/dynamic */}
|
||||
{planTier === "starter" && brandId && (
|
||||
<UpgradePlanModal
|
||||
isOpen={isUpgradeOpen}
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { createStop } from "@/actions/stops/create-stop";
|
||||
import { updateStop } from "@/actions/stops/update-stop";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
active: boolean;
|
||||
brand_id: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
brandId: string;
|
||||
stop?: Stop | null;
|
||||
onSuccess?: (stopId: string) => void;
|
||||
};
|
||||
|
||||
/* Pin icon for the modal header */
|
||||
const PinIcon = () => (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function EditStopModal({ isOpen, onClose, brandId, stop, onSuccess }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [city, setCity] = useState("");
|
||||
const [stateField, setStateField] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const [time, setTime] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [zip, setZip] = useState("");
|
||||
const [cutoffTime, setCutoffTime] = useState("");
|
||||
const [status, setStatus] = useState<"draft" | "active">("draft");
|
||||
|
||||
const cityRef = useRef<HTMLInputElement>(null);
|
||||
const isEditing = Boolean(stop);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (stop) {
|
||||
// Edit mode - populate from existing stop.
|
||||
// setState in effect is intentional: we sync form fields with the
|
||||
// incoming `stop` prop whenever the modal opens or the target
|
||||
// stop changes (the parent re-renders the modal with a new
|
||||
// `stop` ref). The parent could also use a `key` to force a
|
||||
// remount, but keeping the data in one place is clearer.
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
setCity(stop.city);
|
||||
setStateField(stop.state);
|
||||
setLocation(stop.location);
|
||||
setDate(stop.date);
|
||||
setTime(stop.time || "");
|
||||
setAddress(stop.address ?? "");
|
||||
setZip(stop.zip ?? "");
|
||||
setCutoffTime(stop.cutoff_time ? stop.cutoff_time.slice(0, 16) : "");
|
||||
setStatus(stop.active ? "active" : "draft");
|
||||
} else {
|
||||
// Add mode - reset form
|
||||
setCity("");
|
||||
setStateField("");
|
||||
setLocation("");
|
||||
setDate("");
|
||||
setTime("");
|
||||
setAddress("");
|
||||
setZip("");
|
||||
setCutoffTime("");
|
||||
setStatus("draft");
|
||||
}
|
||||
setError(null);
|
||||
requestAnimationFrame(() => cityRef.current?.focus());
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}
|
||||
}, [isOpen, stop]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
|
||||
setError("City, state, location, and date are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
let result;
|
||||
if (isEditing && stop) {
|
||||
result = await updateStop(stop.id, brandId, {
|
||||
city: city.trim(),
|
||||
state: stateField.trim(),
|
||||
location: location.trim(),
|
||||
date,
|
||||
time: time || "08:00",
|
||||
address: address || null,
|
||||
zip: zip || null,
|
||||
cutoff_time: cutoffTime || null,
|
||||
active: status === "active",
|
||||
});
|
||||
} else {
|
||||
result = await createStop(brandId, {
|
||||
city: city.trim(),
|
||||
state: stateField.trim(),
|
||||
location: location.trim(),
|
||||
date,
|
||||
time: time || "08:00",
|
||||
address: address || undefined,
|
||||
zip: zip || undefined,
|
||||
cutoff_time: cutoffTime || undefined,
|
||||
active: status === "active",
|
||||
});
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
const newStopId = isEditing ? stop!.id : (result as { success: true; id: string }).id;
|
||||
onSuccess?.(newStopId);
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error ?? `Failed to ${isEditing ? "update" : "create"} stop`);
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
brandId,
|
||||
city,
|
||||
stateField,
|
||||
location,
|
||||
date,
|
||||
time,
|
||||
address,
|
||||
zip,
|
||||
cutoffTime,
|
||||
status,
|
||||
isEditing,
|
||||
stop,
|
||||
onSuccess,
|
||||
onClose,
|
||||
]
|
||||
);
|
||||
|
||||
const title = isEditing ? "Edit Stop" : "Add Stop";
|
||||
const eyebrow = isEditing
|
||||
? `${stop?.city}, ${stop?.state}`
|
||||
: "New stop on the route";
|
||||
const submitLabel = loading
|
||||
? isEditing ? "Saving…" : "Creating…"
|
||||
: isEditing
|
||||
? "Save Changes"
|
||||
: status === "active"
|
||||
? "Create & Publish"
|
||||
: "Save as Draft";
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<GlassModal
|
||||
title={title}
|
||||
eyebrow={eyebrow}
|
||||
onClose={onClose}
|
||||
maxWidth="max-w-xl"
|
||||
compact
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-red-700"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Row 1 — Where: City + State */}
|
||||
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-city" className="ha-field-label">
|
||||
<PinIcon />
|
||||
<span>City</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
ref={cityRef}
|
||||
id="stop-city"
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Denver"
|
||||
className="ha-field-input"
|
||||
required
|
||||
autoComplete="address-level2"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-state" className="ha-field-label">
|
||||
<span>State</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-state"
|
||||
type="text"
|
||||
value={stateField}
|
||||
onChange={(e) => setStateField(e.target.value.toUpperCase())}
|
||||
placeholder="CO"
|
||||
maxLength={2}
|
||||
className="ha-field-input ha-field-input-mono text-center"
|
||||
style={{ textTransform: "uppercase" }}
|
||||
required
|
||||
autoComplete="address-level1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 — Where at: Location (full) */}
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-location" className="ha-field-label">
|
||||
<span>Location / Venue</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-location"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Whole Foods Market — Highlands"
|
||||
className="ha-field-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 3 — When: Date + Time */}
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-date" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Date</span>
|
||||
<span className="ha-field-label-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-time" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Pickup Time</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-time"
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4 — Address + ZIP + Cutoff */}
|
||||
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-address" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
|
||||
<path d="M9 22V12h6v10" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>Street Address</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-address"
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="ha-field-input"
|
||||
autoComplete="street-address"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-zip" className="ha-field-label">
|
||||
<span>ZIP</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-zip"
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80202"
|
||||
maxLength={10}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
autoComplete="postal-code"
|
||||
/>
|
||||
</div>
|
||||
<div className="ha-field">
|
||||
<label htmlFor="stop-cutoff" className="ha-field-label">
|
||||
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>Cutoff</span>
|
||||
</label>
|
||||
<input
|
||||
id="stop-cutoff"
|
||||
type="time"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(e.target.value)}
|
||||
className="ha-field-input ha-field-input-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5 — Status: segmented control */}
|
||||
<div className="ha-field pt-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="ha-field-label">
|
||||
<span>Visibility</span>
|
||||
</label>
|
||||
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
|
||||
Draft is hidden from customers
|
||||
</span>
|
||||
</div>
|
||||
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "draft"}
|
||||
onClick={() => setStatus("draft")}
|
||||
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
|
||||
>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Save as Draft</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === "active"}
|
||||
onClick={() => setStatus("active")}
|
||||
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
|
||||
>
|
||||
<span className="ha-segment-dot" />
|
||||
<span>Publish Now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="ha-modal-footer">
|
||||
<span className="ha-modal-footer-hint">
|
||||
<kbd>Esc</kbd>
|
||||
to close
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ha-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="ha-btn-primary"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isEditing ? (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : status === "active" ? (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
|
||||
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{submitLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook for easy integration
|
||||
export function useEditStopModal(brandId: string) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [editingStop, setEditingStop] = useState<{
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
active: boolean;
|
||||
brand_id: string;
|
||||
} | null>(null);
|
||||
|
||||
const open = useCallback((stop?: typeof editingStop) => {
|
||||
setEditingStop(stop ?? null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setEditingStop(null);
|
||||
}, []);
|
||||
|
||||
const Modal = useCallback(() => (
|
||||
<EditStopModal
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
brandId={brandId}
|
||||
stop={editingStop}
|
||||
/>
|
||||
), [isOpen, close, brandId, editingStop]);
|
||||
|
||||
return { open, close, Modal, editingStop };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,6 @@ export type StopForView = {
|
||||
time: string;
|
||||
location: string;
|
||||
active: boolean;
|
||||
deleted_at?: string | null;
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
address?: string | null;
|
||||
|
||||
@@ -8,6 +8,9 @@ import StopsList from "./StopsList";
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
page?: number;
|
||||
totalPages?: number;
|
||||
totalCount?: number;
|
||||
};
|
||||
|
||||
const TABS: { value: StopView; label: string; hint: string }[] = [
|
||||
@@ -16,7 +19,7 @@ const TABS: { value: StopView; label: string; hint: string }[] = [
|
||||
{ value: "list", label: "List", hint: "All stops in order" },
|
||||
];
|
||||
|
||||
export default function StopsDashboardClient({ stops }: Props) {
|
||||
export default function StopsDashboardClient({ stops, page = 1, totalPages = 1, totalCount = 0 }: Props) {
|
||||
const [view, setView] = useState<StopView>("calendar");
|
||||
|
||||
const stats = useMemo(() => {
|
||||
@@ -52,7 +55,7 @@ export default function StopsDashboardClient({ stops }: Props) {
|
||||
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>\")",
|
||||
"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 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">
|
||||
@@ -134,6 +137,55 @@ export default function StopsDashboardClient({ stops }: Props) {
|
||||
value={stats.venues}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="relative flex items-center justify-between border-t border-[var(--admin-border-light)] pt-4 mt-4">
|
||||
<p className="font-mono text-xs text-[var(--admin-text-muted)]">
|
||||
Showing {((page - 1) * 50) + 1}–{Math.min(page * 50, totalCount)} of {totalCount}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href={page > 1 ? `?page=${page - 1}` : "#"}
|
||||
aria-disabled={page <= 1}
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
|
||||
page <= 1
|
||||
? "text-[var(--admin-text-muted)] cursor-not-allowed pointer-events-none"
|
||||
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
}`}
|
||||
>
|
||||
‹
|
||||
</a>
|
||||
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||
const p = i + 1;
|
||||
return (
|
||||
<a
|
||||
key={p}
|
||||
href={`?page=${p}`}
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
|
||||
p === page
|
||||
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]"
|
||||
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<a
|
||||
href={page < totalPages ? `?page=${page + 1}` : "#"}
|
||||
aria-disabled={page >= totalPages}
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors ${
|
||||
page >= totalPages
|
||||
? "text-[var(--admin-text-muted)] cursor-not-allowed pointer-events-none"
|
||||
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
}`}
|
||||
>
|
||||
›
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Active view */}
|
||||
|
||||
@@ -8,12 +8,12 @@ export type Stop = {
|
||||
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;
|
||||
cutoff_date?: string | null;
|
||||
brands: { name: string } | { name: string }[];
|
||||
};
|
||||
|
||||
|
||||
@@ -231,22 +231,20 @@ export default function FeaturesAndStats() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
background: "#faf8f5",
|
||||
color: "#1a1a1a",
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-family: "Plus Jakarta Sans", sans-serif;
|
||||
font-family: var(--font-manrope);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
@@ -256,7 +254,7 @@ export default function FeaturesAndStats() {
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: "Cormorant Garamond", serif;
|
||||
font-family: var(--font-fraunces);
|
||||
font-size: clamp(2rem, 5vw, 3rem);
|
||||
font-weight: 600;
|
||||
color: #1a4d2e;
|
||||
@@ -265,7 +263,7 @@ export default function FeaturesAndStats() {
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-family: "Plus Jakarta Sans", sans-serif;
|
||||
font-family: var(--font-manrope);
|
||||
font-size: 1.125rem;
|
||||
color: #4a4a4a;
|
||||
line-height: 1.6;
|
||||
@@ -304,7 +302,7 @@ export default function FeaturesAndStats() {
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-family: "Cormorant Garamond", serif;
|
||||
font-family: var(--font-fraunces);
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
color: #1a4d2e;
|
||||
@@ -326,7 +324,7 @@ export default function FeaturesAndStats() {
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-family: "Cormorant Garamond", serif;
|
||||
font-family: var(--font-fraunces);
|
||||
font-size: clamp(2.5rem, 5vw, 4rem);
|
||||
font-weight: 700;
|
||||
color: #1a4d2e;
|
||||
@@ -570,7 +568,7 @@ export default function FeaturesAndStats() {
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontFamily: "'Plus Jakarta Sans', sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.15em",
|
||||
@@ -583,7 +581,7 @@ export default function FeaturesAndStats() {
|
||||
</span>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
fontSize: "clamp(2rem, 4vw, 2.75rem)",
|
||||
fontWeight: 600,
|
||||
color: "#faf8f5",
|
||||
@@ -620,7 +618,7 @@ export default function FeaturesAndStats() {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
fontSize: "clamp(2.25rem, 4.5vw, 3.75rem)",
|
||||
fontWeight: 700,
|
||||
color: "#faf8f5",
|
||||
@@ -660,7 +658,7 @@ export default function FeaturesAndStats() {
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
color: "#6b8f71",
|
||||
|
||||
@@ -310,7 +310,7 @@ export default function HeroSection() {
|
||||
<h1
|
||||
className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl xl:text-9xl font-bold leading-[0.9] tracking-tighter"
|
||||
style={{
|
||||
fontFamily: "'Playfair Display', 'Georgia', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
color: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
@@ -323,7 +323,7 @@ export default function HeroSection() {
|
||||
<p
|
||||
className="hero-reveal hero-subtitle text-lg sm:text-xl md:text-2xl max-w-xl leading-relaxed"
|
||||
style={{
|
||||
fontFamily: "'DM Sans', sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
color: "#4a6d56",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
@@ -393,7 +393,7 @@ export default function HeroSection() {
|
||||
<div
|
||||
className="text-2xl sm:text-3xl lg:text-4xl font-bold"
|
||||
style={{
|
||||
fontFamily: "'Playfair Display', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
color: "#1a4d2e",
|
||||
}}
|
||||
>
|
||||
@@ -611,7 +611,7 @@ export default function HeroSection() {
|
||||
<div className="mb-8 reveal-scale">
|
||||
<span
|
||||
className="inline-block text-xs font-bold tracking-[0.3em] uppercase"
|
||||
style={{ color: "#c97a3e", fontFamily: "'DM Sans', sans-serif" }}
|
||||
style={{ color: "#c97a3e", fontFamily: "var(--font-manrope)" }}
|
||||
>
|
||||
The Challenge
|
||||
</span>
|
||||
@@ -621,7 +621,7 @@ export default function HeroSection() {
|
||||
<h2
|
||||
className="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight mb-8"
|
||||
style={{
|
||||
fontFamily: "'Playfair Display', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
color: "#faf8f5",
|
||||
}}
|
||||
>
|
||||
@@ -636,7 +636,7 @@ export default function HeroSection() {
|
||||
className="text-xl max-w-2xl mx-auto"
|
||||
style={{
|
||||
color: "#86868b",
|
||||
fontFamily: "'DM Sans', sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
@@ -669,14 +669,14 @@ export default function HeroSection() {
|
||||
<div className="reveal-scale text-center mb-20">
|
||||
<span
|
||||
className="inline-block text-xs font-bold tracking-[0.2em] uppercase mb-6"
|
||||
style={{ color: "#6b8f71", fontFamily: "'DM Sans', sans-serif" }}
|
||||
style={{ color: "#6b8f71", fontFamily: "var(--font-manrope)" }}
|
||||
>
|
||||
Platform Features
|
||||
</span>
|
||||
<h2
|
||||
className="text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight"
|
||||
style={{
|
||||
fontFamily: "'Playfair Display', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
color: "#1a4d2e",
|
||||
}}
|
||||
>
|
||||
@@ -792,7 +792,7 @@ export default function HeroSection() {
|
||||
<h3
|
||||
className="text-2xl font-bold mb-4"
|
||||
style={{
|
||||
fontFamily: "'Playfair Display', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
color: "#1a4d2e",
|
||||
}}
|
||||
>
|
||||
@@ -802,7 +802,7 @@ export default function HeroSection() {
|
||||
className="text-base leading-relaxed"
|
||||
style={{
|
||||
color: "#555",
|
||||
fontFamily: "'DM Sans', sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
}}
|
||||
>
|
||||
{feature.description}
|
||||
@@ -845,7 +845,7 @@ export default function HeroSection() {
|
||||
<div className="text-center mb-16 reveal-scale">
|
||||
<span
|
||||
className="inline-block text-xs font-bold tracking-[0.2em] uppercase"
|
||||
style={{ color: "#c97a3e", fontFamily: "'DM Sans', sans-serif" }}
|
||||
style={{ color: "#c97a3e", fontFamily: "var(--font-manrope)" }}
|
||||
>
|
||||
Our Impact
|
||||
</span>
|
||||
@@ -863,7 +863,7 @@ export default function HeroSection() {
|
||||
<div
|
||||
className="text-5xl sm:text-6xl lg:text-7xl font-bold mb-4"
|
||||
style={{
|
||||
fontFamily: "'Playfair Display', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
color: "#faf8f5",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
@@ -874,7 +874,7 @@ export default function HeroSection() {
|
||||
</div>
|
||||
<p
|
||||
className="text-base font-medium"
|
||||
style={{ color: "#6b8f71", fontFamily: "'DM Sans', sans-serif" }}
|
||||
style={{ color: "#6b8f71", fontFamily: "var(--font-manrope)" }}
|
||||
>
|
||||
{stat.label}
|
||||
</p>
|
||||
@@ -914,7 +914,7 @@ export default function HeroSection() {
|
||||
style={{
|
||||
color: "#c97a3e",
|
||||
background: "rgba(201, 122, 62, 0.1)",
|
||||
fontFamily: "'DM Sans', sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
}}
|
||||
>
|
||||
Get Started Today
|
||||
@@ -926,7 +926,7 @@ export default function HeroSection() {
|
||||
<h2
|
||||
className="text-5xl sm:text-6xl lg:text-7xl font-bold leading-tight mb-8"
|
||||
style={{
|
||||
fontFamily: "'Playfair Display', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
color: "#1a4d2e",
|
||||
}}
|
||||
>
|
||||
@@ -941,7 +941,7 @@ export default function HeroSection() {
|
||||
className="text-xl mb-12 max-w-xl mx-auto"
|
||||
style={{
|
||||
color: "#555",
|
||||
fontFamily: "'DM Sans', sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
}}
|
||||
>
|
||||
Join hundreds of farms already using Route Commerce to deliver fresher produce faster.
|
||||
@@ -998,6 +998,25 @@ export default function HeroSection() {
|
||||
66% { transform: translate(10px, -10px); }
|
||||
}
|
||||
|
||||
@keyframes float-slow {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(25px, -30px) scale(1.02); }
|
||||
}
|
||||
|
||||
@keyframes float-slow-delayed {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(-30px, 20px) scale(1.03); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.parallax-float,
|
||||
.parallax-float *,
|
||||
[class*="animate-"] {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes draw-route {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function Header({ className = "" }: HeaderProps) {
|
||||
<span
|
||||
className="text-xl font-semibold tracking-tight"
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', Georgia, serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
color: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
@@ -71,7 +71,7 @@ export function Header({ className = "" }: HeaderProps) {
|
||||
href={link.href}
|
||||
className="text-sm font-medium transition-colors hover:opacity-70"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
color: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
@@ -86,7 +86,7 @@ export function Header({ className = "" }: HeaderProps) {
|
||||
href="/login"
|
||||
className="text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
color: "#1a4d2e",
|
||||
}}
|
||||
>
|
||||
@@ -96,7 +96,7 @@ export function Header({ className = "" }: HeaderProps) {
|
||||
href="/admin"
|
||||
className="px-5 py-2.5 rounded-full text-sm font-semibold transition-all hover:opacity-90 active:scale-95"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
background: "rgba(26, 77, 46, 0.9)",
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)",
|
||||
@@ -160,7 +160,7 @@ export function Header({ className = "" }: HeaderProps) {
|
||||
href={link.href}
|
||||
className="px-4 py-3 text-base font-medium rounded-lg transition-colors hover:bg-[#6b8f71]/10"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
color: "#1a1a1a",
|
||||
}}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
@@ -176,7 +176,7 @@ export function Header({ className = "" }: HeaderProps) {
|
||||
href="/login"
|
||||
className="py-3 text-base font-medium"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
color: "#1a4d2e",
|
||||
}}
|
||||
>
|
||||
@@ -186,7 +186,7 @@ export function Header({ className = "" }: HeaderProps) {
|
||||
href="/admin"
|
||||
className="py-3 rounded-full text-base font-semibold text-center"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
background: "rgba(26, 77, 46, 0.9)",
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
@@ -252,7 +252,7 @@ export function Footer({ className = "" }: FooterProps) {
|
||||
<span
|
||||
className="text-sm font-medium tracking-tight"
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', Georgia, serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
color: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
@@ -262,7 +262,7 @@ export function Footer({ className = "" }: FooterProps) {
|
||||
<span
|
||||
className="text-xs mt-1"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
color: "#9a9590",
|
||||
}}
|
||||
>
|
||||
@@ -278,7 +278,7 @@ export function Footer({ className = "" }: FooterProps) {
|
||||
href={link.href}
|
||||
className="text-xs font-medium uppercase tracking-wider transition-colors hover:text-[#1a4d2e]"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
color: "#6b8f71",
|
||||
letterSpacing: "0.08em",
|
||||
}}
|
||||
@@ -292,11 +292,11 @@ export function Footer({ className = "" }: FooterProps) {
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
color: "#b5b0a8",
|
||||
}}
|
||||
>
|
||||
© 2025
|
||||
© {new Date().getFullYear()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,15 +319,11 @@ interface WrapperProps {
|
||||
export function LandingPageWrapper({ children, className = "" }: WrapperProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Google Fonts Import */}
|
||||
<style jsx global>{`
|
||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
||||
`}</style>
|
||||
|
||||
{/* Fonts loaded via next/font in app/layout.tsx — no external @import needed */}
|
||||
<div
|
||||
className={`min-h-screen flex flex-col ${className}`}
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
scrollBehavior: "smooth",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function TestimonialsAndCTA() {
|
||||
<section
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #faf8f5 0%, #f5f2ed 100%)",
|
||||
fontFamily: "'Plus Jakarta Sans', sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
}}
|
||||
>
|
||||
{/* Decorative botanical accent */}
|
||||
@@ -132,7 +132,7 @@ export default function TestimonialsAndCTA() {
|
||||
</span>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
fontSize: "clamp(2.5rem, 5vw, 3.5rem)",
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.1,
|
||||
@@ -193,7 +193,7 @@ export default function TestimonialsAndCTA() {
|
||||
top: "-8px",
|
||||
right: "20px",
|
||||
fontSize: "80px",
|
||||
fontFamily: "'Cormorant Garamond', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
color: "rgba(26, 77, 46, 0.06)",
|
||||
lineHeight: 1,
|
||||
pointerEvents: "none",
|
||||
@@ -295,7 +295,7 @@ export default function TestimonialsAndCTA() {
|
||||
<div key={i} style={{ textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
fontSize: "2.5rem",
|
||||
fontWeight: 600,
|
||||
color: "#1a4d2e",
|
||||
@@ -326,7 +326,7 @@ export default function TestimonialsAndCTA() {
|
||||
background: "linear-gradient(180deg, #f5f2ed 0%, #faf8f5 50%, #faf8f5 100%)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
fontFamily: "'Plus Jakarta Sans', sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
}}
|
||||
>
|
||||
{/* Decorative gradient orbs */}
|
||||
@@ -422,7 +422,7 @@ export default function TestimonialsAndCTA() {
|
||||
{/* Headline */}
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
fontSize: "clamp(2.75rem, 6vw, 4rem)",
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.1,
|
||||
@@ -626,8 +626,6 @@ export default function TestimonialsAndCTA() {
|
||||
</section>
|
||||
|
||||
<style jsx>{`
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap');
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -47,13 +47,8 @@ export default function SiteHeader() {
|
||||
borderColor: "rgba(107, 143, 113, 0.15)",
|
||||
}}
|
||||
>
|
||||
{/* Google Fonts */}
|
||||
<style jsx global>{`
|
||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
||||
`}</style>
|
||||
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||
{/* Logo */}
|
||||
{/* Logo — uses Fraunces (loaded via next/font) for a refined serif wordmark */}
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center transition-transform group-hover:scale-105"
|
||||
@@ -66,7 +61,7 @@ export default function SiteHeader() {
|
||||
<span
|
||||
className="text-lg sm:text-xl font-semibold tracking-tight"
|
||||
style={{
|
||||
fontFamily: "'Cormorant Garamond', Georgia, serif",
|
||||
fontFamily: "var(--font-fraunces)",
|
||||
color: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
@@ -74,7 +69,7 @@ export default function SiteHeader() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
{/* Navigation — uses Manrope (loaded via next/font) for consistent small caps */}
|
||||
<nav className="flex items-center gap-5 sm:gap-6">
|
||||
{/* Brand quick links */}
|
||||
{(!isAdminRoute || isStorefrontRoute) && (
|
||||
@@ -83,7 +78,7 @@ export default function SiteHeader() {
|
||||
href="/tuxedo"
|
||||
className="text-xs sm:text-sm font-medium uppercase tracking-wider transition-colors hover:opacity-70"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
color: "#6b8f71",
|
||||
letterSpacing: "0.06em",
|
||||
}}
|
||||
@@ -94,7 +89,7 @@ export default function SiteHeader() {
|
||||
href="/indian-river-direct"
|
||||
className="text-xs sm:text-sm font-medium uppercase tracking-wider transition-colors hover:opacity-70"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
color: "#6b8f71",
|
||||
letterSpacing: "0.06em",
|
||||
}}
|
||||
@@ -110,7 +105,7 @@ export default function SiteHeader() {
|
||||
href="/admin"
|
||||
className="text-xs sm:text-sm font-medium uppercase tracking-wider transition-colors hover:opacity-70"
|
||||
style={{
|
||||
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||
fontFamily: "var(--font-manrope)",
|
||||
color: "#6b8f71",
|
||||
letterSpacing: "0.06em",
|
||||
}}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
<h3 className="text-2xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "var(--font-fraunces)" }}>
|
||||
You're on the list!
|
||||
</h3>
|
||||
<p className="text-[#6b8f71]">
|
||||
|
||||
@@ -54,6 +54,10 @@ export default function TuxedoVideoHero({
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !sectionRef.current) return;
|
||||
|
||||
// Respect reduced motion preference
|
||||
const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
||||
if (prefersReducedMotion) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
// Scroll progress tracking
|
||||
ScrollTrigger.create({
|
||||
@@ -466,7 +470,6 @@ export default function TuxedoVideoHero({
|
||||
animation: bounce 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Performance optimizations */
|
||||
.parallax-float {
|
||||
will-change: transform;
|
||||
}
|
||||
@@ -475,10 +478,20 @@ export default function TuxedoVideoHero({
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
button, a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.parallax-float, .animate-bounce, .hero-reveal {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
.hero-reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { ViewTransition } from "react";
|
||||
|
||||
/**
|
||||
* Subtle loading placeholder used in place of skeleton `loading.tsx`
|
||||
* files. Instead of a flashy "skeleton of the page you're waiting for",
|
||||
* this renders a single thin animated bar that crossfades into the
|
||||
* real content via the View Transitions API. The previous page stays
|
||||
* visible underneath until the new page is ready, so the user never
|
||||
* feels the gap.
|
||||
*
|
||||
* Drop this in a `loading.tsx` file:
|
||||
*
|
||||
* // app/admin/loading.tsx
|
||||
* import { LoadingFade } from "@/components/transitions/LoadingFade";
|
||||
* export default LoadingFade;
|
||||
*/
|
||||
export function LoadingFade() {
|
||||
return (
|
||||
<ViewTransition name="page-content" update="default">
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 z-50 h-0.5 overflow-hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="h-full w-1/3 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, transparent 0%, #14532D 50%, transparent 100%)",
|
||||
animation: "transition-shimmer 1.4s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ViewTransition>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingFade;
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Announce route changes to assistive tech. Without this, screen-reader
|
||||
* users see no signal that navigation completed because the view
|
||||
* transition is intentionally seamless for sighted users.
|
||||
*
|
||||
* The announcer also restores the keyboard focus to the top of the new
|
||||
* page after navigation, so tab order picks up where the user expects
|
||||
* (not stuck on the link they just clicked).
|
||||
*/
|
||||
export function RouteAnnouncer() {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
// Reset focus to the page wrapper so keyboard nav continues from
|
||||
// the top, not from the link in the sidebar.
|
||||
const target = document.getElementById("page-content") ?? document.body;
|
||||
if (target && "tabIndex" in target) {
|
||||
(target as HTMLElement).tabIndex = -1;
|
||||
(target as HTMLElement).focus({ preventScroll: true });
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{/* Text is intentionally empty — the live region is the
|
||||
announcement channel; the actual title is set per page via
|
||||
the <h1> rendered inside the layout. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { ViewTransition } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Smooth view-transition wrapper for any page-section content.
|
||||
*
|
||||
* Drop this around the main content area of a route (or around an
|
||||
* individual <Suspense> chunk) to opt that subtree into a soft
|
||||
* crossfade on navigation. The browser's native View Transitions API
|
||||
* handles the actual animation — when the browser doesn't support it,
|
||||
* the children just swap, no error.
|
||||
*
|
||||
* `name` groups elements so the browser can morph the same element
|
||||
* across pages. Use the same name on the source and destination (e.g.
|
||||
* the page header) for shared-element morphing. Use the default
|
||||
* `"page-content"` for plain crossfades.
|
||||
*
|
||||
* The default `update="default"` plus a CSS `::view-transition-old/new`
|
||||
* rule (see globals.css) gives us a 220ms crossfade with a tiny
|
||||
* downward shift on the incoming page — fast enough to feel like a
|
||||
* single app, slow enough to soften the cut between routes.
|
||||
*
|
||||
* Usage:
|
||||
* <main>
|
||||
* <SmoothViewTransition>{children}</SmoothViewTransition>
|
||||
* </main>
|
||||
*/
|
||||
export function SmoothViewTransition({
|
||||
children,
|
||||
name = "page-content",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
name?: string;
|
||||
}) {
|
||||
return (
|
||||
<ViewTransition
|
||||
name={name}
|
||||
// `default` runs a CSS-driven crossfade using the
|
||||
// ::view-transition-* pseudo-elements defined in globals.css.
|
||||
// Other options: "none" (instant cut), or a custom string your
|
||||
// CSS can match.
|
||||
update="default"
|
||||
>
|
||||
{children}
|
||||
</ViewTransition>
|
||||
);
|
||||
}
|
||||
@@ -41,31 +41,25 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
let sessionEmail: string | null = null;
|
||||
try {
|
||||
const { data: session } = await getSession();
|
||||
console.log("[admin-permissions] Full session:", JSON.stringify(session));
|
||||
sessionEmail = session?.user?.email ?? null;
|
||||
console.log("[admin-permissions] Session email:", sessionEmail);
|
||||
} catch (err) {
|
||||
console.error("[admin-permissions] getSession() failed:", err);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!sessionEmail) {
|
||||
console.log("[admin-permissions] No session email - returning null");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await withPlatformAdmin(async (db) => {
|
||||
console.log("[admin-permissions] Looking for user with email:", sessionEmail.toLowerCase());
|
||||
const userRows = await db
|
||||
.select()
|
||||
.from(adminUsers)
|
||||
.where(eq(adminUsers.email, sessionEmail.toLowerCase()))
|
||||
.limit(1);
|
||||
console.log("[admin-permissions] User rows found:", userRows.length, userRows);
|
||||
const user = userRows[0];
|
||||
if (!user) {
|
||||
console.log("[admin-permissions] User not found in admin_users");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -83,13 +77,10 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
|
||||
.where(eq(adminUserBrands.adminUserId, user.id));
|
||||
|
||||
console.log("[admin-permissions] Membership rows found:", membershipRows.length, membershipRows);
|
||||
|
||||
// Brand-scoped roles (brand_admin, store_employee) require at least one brand link.
|
||||
// Platform admins may have zero or more explicit links; they get cross-brand access via role.
|
||||
if (membershipRows.length === 0 && role !== "platform_admin") {
|
||||
// Signed in but not provisioned for any brand.
|
||||
console.log("[admin-permissions] No brand memberships for non-platform user — denying");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ function buildPool(): Pool {
|
||||
max: parseInt(process.env.PG_POOL_MAX ?? "10", 10),
|
||||
idleTimeoutMillis: parseInt(process.env.PG_POOL_IDLE_MS ?? "30000", 10),
|
||||
connectionTimeoutMillis: parseInt(
|
||||
process.env.PG_POOL_CONN_TIMEOUT_MS ?? "10000",
|
||||
process.env.PG_POOL_CONN_TIMEOUT_MS ?? "30000",
|
||||
10,
|
||||
),
|
||||
// Vercel/serverless recycling: keep the pool hot for warm invocations.
|
||||
|
||||
@@ -381,10 +381,10 @@
|
||||
font-feature-settings: "ss01", "ss02";
|
||||
}
|
||||
.font-sans-ui {
|
||||
font-family: var(--font-geist), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-family: var(--font-manrope), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
.font-mono-ui {
|
||||
font-family: var(--font-jetbrains-mono), ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-family: var(--font-fragment-mono), ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-feature-settings: "ss01", "tnum";
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@
|
||||
|
||||
/* Eyebrow — small caps tracking label for editorial headers */
|
||||
.ha-eyebrow {
|
||||
font-family: var(--font-geist), ui-sans-serif, system-ui, sans-serif;
|
||||
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.625rem; /* 10px */
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
@@ -416,7 +416,7 @@
|
||||
|
||||
/* Tabular numerals for prices/dates */
|
||||
.ha-num {
|
||||
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
|
||||
font-family: var(--font-fragment-mono), ui-monospace, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: "tnum", "ss01";
|
||||
}
|
||||
@@ -465,7 +465,7 @@
|
||||
height: 2.25rem; /* 36px */
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.875rem; /* 14px */
|
||||
font-family: var(--font-geist), ui-sans-serif, system-ui, sans-serif;
|
||||
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||
color: var(--admin-text-primary);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid var(--admin-border);
|
||||
@@ -488,11 +488,84 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ha-field-input-mono {
|
||||
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
|
||||
font-family: var(--font-fragment-mono), ui-monospace, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.8125rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.ha-field-textarea {
|
||||
width: 100%;
|
||||
min-height: 4.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: var(--admin-text-primary);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
transition: all 160ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.ha-field-textarea:hover {
|
||||
border-color: rgba(22, 163, 74, 0.4);
|
||||
background: rgba(22, 163, 74, 0.015);
|
||||
}
|
||||
.ha-field-textarea:focus,
|
||||
.ha-field-textarea:focus-visible {
|
||||
border-color: var(--admin-accent);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.12);
|
||||
}
|
||||
.ha-field-textarea::placeholder {
|
||||
color: var(--admin-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Subtle scrollbar inside admin scroll containers */
|
||||
.ha-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--admin-border) transparent;
|
||||
}
|
||||
.ha-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.ha-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.ha-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--admin-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ha-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
/* Skeleton placeholder — soft pulse for loading rows in admin tables */
|
||||
.ha-skeleton {
|
||||
display: block;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.04) 0%,
|
||||
rgba(0, 0, 0, 0.07) 50%,
|
||||
rgba(0, 0, 0, 0.04) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: ha-skeleton-pulse 1.4s ease-in-out infinite;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
@keyframes ha-skeleton-pulse {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ha-skeleton {
|
||||
animation: none;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Segmented control for status (replaces giant buttons) */
|
||||
.ha-segment {
|
||||
@@ -580,7 +653,7 @@
|
||||
min-width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
padding: 0 0.25rem;
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
font-family: var(--font-fragment-mono), monospace;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text-secondary);
|
||||
@@ -599,7 +672,7 @@
|
||||
padding: 0 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-geist), ui-sans-serif, system-ui, sans-serif;
|
||||
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(180deg, #16a34a 0%, #15803d 100%);
|
||||
border: 1px solid #14532d;
|
||||
@@ -639,7 +712,7 @@
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-geist), ui-sans-serif, system-ui, sans-serif;
|
||||
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||
color: var(--admin-text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
@@ -701,7 +774,7 @@
|
||||
height: 2.25rem;
|
||||
padding: 0 0.75rem 0 2.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: var(--font-geist), ui-sans-serif, system-ui, sans-serif;
|
||||
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||
color: var(--admin-text-primary);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid var(--admin-border);
|
||||
@@ -764,7 +837,7 @@
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
.ha-picker-chip-count {
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
font-family: var(--font-fragment-mono), monospace;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.3rem;
|
||||
@@ -898,7 +971,7 @@
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.ha-product-card-price {
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
font-family: var(--font-fragment-mono), monospace;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -1061,7 +1134,7 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
.ha-row-num {
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
font-family: var(--font-fragment-mono), monospace;
|
||||
font-size: 0.625rem;
|
||||
color: var(--admin-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -1103,7 +1176,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--font-geist), sans-serif;
|
||||
font-family: var(--font-manrope), sans-serif;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
@@ -1135,7 +1208,7 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
.ha-calendar-title-year {
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
font-family: var(--font-fragment-mono), monospace;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--admin-text-muted);
|
||||
@@ -1179,7 +1252,7 @@
|
||||
padding: 0 0.75rem;
|
||||
background: var(--admin-text-primary);
|
||||
color: var(--admin-bg);
|
||||
font-family: var(--font-geist), sans-serif;
|
||||
font-family: var(--font-manrope), sans-serif;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -1201,7 +1274,7 @@
|
||||
}
|
||||
.ha-calendar-dow-cell {
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-family: var(--font-geist), sans-serif;
|
||||
font-family: var(--font-manrope), sans-serif;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
@@ -1272,7 +1345,7 @@
|
||||
}
|
||||
|
||||
.ha-calendar-daynum {
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
font-family: var(--font-fragment-mono), monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text-secondary);
|
||||
@@ -1317,7 +1390,7 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
color: var(--admin-text-primary);
|
||||
font-family: var(--font-geist), sans-serif;
|
||||
font-family: var(--font-manrope), sans-serif;
|
||||
}
|
||||
.ha-calendar-event:hover {
|
||||
background: rgba(22, 163, 74, 0.14);
|
||||
@@ -1343,7 +1416,7 @@
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.ha-calendar-event-time {
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
font-family: var(--font-fragment-mono), monospace;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text-secondary);
|
||||
@@ -1365,7 +1438,7 @@
|
||||
font-size: 0.625rem;
|
||||
color: var(--admin-text-muted);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
font-family: var(--font-fragment-mono), monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.125rem 0.25rem;
|
||||
text-align: left;
|
||||
@@ -1431,7 +1504,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--font-geist), sans-serif;
|
||||
font-family: var(--font-manrope), sans-serif;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
@@ -1474,7 +1547,7 @@
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
.ha-event-popover-field-value {
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
font-family: var(--font-fragment-mono), monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text-primary);
|
||||
@@ -1498,7 +1571,7 @@
|
||||
padding: 0 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-geist), sans-serif;
|
||||
font-family: var(--font-manrope), sans-serif;
|
||||
background: #ffffff;
|
||||
color: var(--admin-text-primary);
|
||||
border: 1px solid var(--admin-border);
|
||||
@@ -1569,7 +1642,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--font-geist), sans-serif;
|
||||
font-family: var(--font-manrope), sans-serif;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
@@ -1688,7 +1761,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
font-family: var(--font-fragment-mono), monospace;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--admin-accent-text);
|
||||
@@ -1721,7 +1794,7 @@
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
.ha-route-stop-time {
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
font-family: var(--font-fragment-mono), monospace;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text-muted);
|
||||
@@ -1778,7 +1851,7 @@
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-geist), sans-serif;
|
||||
font-family: var(--font-manrope), sans-serif;
|
||||
background: transparent;
|
||||
color: var(--admin-text-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
@@ -1844,7 +1917,7 @@
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-geist), sans-serif;
|
||||
font-family: var(--font-manrope), sans-serif;
|
||||
color: var(--admin-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -1863,3 +1936,392 @@
|
||||
0 1px 2px rgba(60, 56, 37, 0.06),
|
||||
0 0 0 1px rgba(60, 56, 37, 0.06);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* === Admin Dashboard — Compact Card System ================== */
|
||||
/* ============================================================ */
|
||||
|
||||
/* Entrance animation — staggered fade-up */
|
||||
@keyframes dashboard-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-fade-up {
|
||||
animation: dashboard-fade-up 0.35s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
|
||||
/* ── Stat Card ─────────────────────────────────────────────── */
|
||||
.admin-stat-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 150ms, transform 150ms;
|
||||
}
|
||||
.admin-stat-card:hover {
|
||||
box-shadow: var(--admin-shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.admin-stat-card-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
}
|
||||
.admin-stat-icon {
|
||||
flex-shrink: 0;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.625rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.admin-stat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-stat-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--admin-text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
.admin-stat-value {
|
||||
font-family: var(--font-fraunces), ui-serif, Georgia, serif;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
font-variation-settings: "opsz" 144;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--admin-text-primary);
|
||||
line-height: 1;
|
||||
font-variant-numeric: lining-nums tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Card Section (Quick Actions, Usage, Orders) ────────────── */
|
||||
.admin-card-section {
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--admin-border-light);
|
||||
}
|
||||
.admin-section-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Quick Actions ──────────────────────────────────────────── */
|
||||
.admin-quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.admin-quick-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none;
|
||||
transition: background 150ms;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
.admin-quick-action:hover {
|
||||
background: var(--admin-bg-subtle);
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
.admin-quick-action-icon {
|
||||
flex-shrink: 0;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.admin-quick-action-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Usage Grid ─────────────────────────────────────────────── */
|
||||
.admin-usage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.admin-usage-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
.admin-usage-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.admin-usage-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.admin-usage-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
.admin-usage-value {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
.admin-usage-bar {
|
||||
height: 0.375rem;
|
||||
background: var(--admin-bg);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-usage-fill {
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.admin-usage-warning {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--admin-danger);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── Recent Orders ──────────────────────────────────────────── */
|
||||
.admin-orders-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.admin-order-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--admin-border-light);
|
||||
text-decoration: none;
|
||||
transition: background 150ms;
|
||||
}
|
||||
.admin-order-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.admin-order-row:hover {
|
||||
background: var(--admin-bg-subtle);
|
||||
}
|
||||
.admin-order-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-order-icon {
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--admin-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.admin-order-name {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 12rem;
|
||||
}
|
||||
.admin-order-time {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--admin-text-muted);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.admin-order-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-order-amount {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
.admin-order-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.admin-orders-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2.5rem 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Section Navigation Cards ───────────────────────────────── */
|
||||
.admin-section-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 1rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 0.75rem;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 180ms, transform 180ms, border-color 180ms;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-section-card:hover {
|
||||
box-shadow: var(--admin-shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.admin-section-card--locked {
|
||||
opacity: 0.65;
|
||||
border-style: dashed;
|
||||
}
|
||||
.admin-section-card--locked:hover {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.admin-section-card--prominent {
|
||||
border-color: rgba(22, 163, 74, 0.25);
|
||||
background: linear-gradient(180deg, #fafffe 0%, #ffffff 60%);
|
||||
}
|
||||
.admin-section-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.admin-section-card-icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.625rem;
|
||||
background: var(--admin-bg);
|
||||
color: var(--admin-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 180ms;
|
||||
}
|
||||
.admin-section-card:hover .admin-section-card-icon {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.admin-section-card-icon--prominent {
|
||||
background: var(--admin-accent-light);
|
||||
color: var(--admin-accent-text);
|
||||
}
|
||||
.admin-section-card-icon--locked {
|
||||
background: var(--admin-bg);
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
.admin-section-card-body {
|
||||
flex: 1;
|
||||
}
|
||||
.admin-section-card-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--admin-text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.admin-section-card-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--admin-text-secondary);
|
||||
line-height: 1.4;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.admin-section-card-hint {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-warning);
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.3;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.admin-section-card-arrow {
|
||||
position: absolute;
|
||||
bottom: 0.75rem;
|
||||
right: 0.75rem;
|
||||
color: var(--admin-accent);
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition: opacity 180ms, transform 180ms;
|
||||
}
|
||||
.admin-section-card:hover .admin-section-card-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* ── Mobile optimizations ───────────────────────────────────── */
|
||||
@media (max-width: 480px) {
|
||||
.admin-stat-card-inner {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.admin-stat-value {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.admin-quick-actions {
|
||||
gap: 0;
|
||||
}
|
||||
.admin-order-name {
|
||||
max-width: 8rem;
|
||||
}
|
||||
.admin-section-card {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
.admin-section-card-title {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.admin-section-card-desc {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user