65 Commits

Author SHA1 Message Date
Nora 95eab42f4b docs: refresh stale docs to match current implementation
Documentation pass — reviewed codebase in full, identified stale references,
and aligned docs with actual code state.

Changes:
- CLAUDE.md, PRODUCTION_SETUP.md, LAUNCH_CHECKLIST.md, src/auth.config.ts:
  middleware path src/middleware.ts → src/proxy.ts (Next.js 16+ convention)
- CLAUDE.md, ENVIRONMENT.md, README.md:
  dev server port localhost:3000 → localhost:4000 (per package.json dev
  script 'next dev --webpack -H 0.0.0.0 -p 4000')
- PRODUCTION_DEPLOYMENT_CHECKLIST.md:
  rewrote migration list — actual db/migrations/ contains 10 files
  (0000–0091), not the Supabase-era 001–092 series. Updated platform
  version 1.6 → 2.0 and last-updated stamp.
- MEMORY.md:
  refreshed 'Last updated' to 2026-06-25, added current-state header
  flagging Auth.js v5 wiring as historical, and documented the SSH/Gitea
  workflow (id_ed25519_crispygoat + no API token → push-hook URL flow
  for opening PRs).
- LAUNCH_CHECKLIST.md:
  added 'Last updated: 2026-06-25' header and refresh note in footer.
- CLAUDE.md:
  new 'Gitea authentication (SSH)' subsection under 'Canonical Remote'
  documenting ~/.ssh/id_ed25519_crispygoat, ssh-agent setup, what works
  (git push) vs what doesn't (REST API without token), and the actual
  PR-creation workflow (push + open the URL the Gitea hook prints).

Verification:
- npx tsc --noEmit: pre-existing Stripe API version + test mock errors
  only (verified by stashing changes and re-running — same error set).
- No new errors introduced.
2026-06-25 20:18:49 -06:00
Nora b018f2be5b chore(comments): re-point dangling supabase/migrations breadcrumbs
The Supabase purge deleted the archived supabase/migrations/.archived/
directory, but 12 src/ files still referenced its files by path in
JSDoc / TODO comments. The references were misleading (pointing to
deleted files), so re-point them at the canonical location of those
RPCs in db/migrations/0001_init.sql. No code or behavior changes.

Also fixed a stray 'supabase shim returns empty results' comment in
src/app/admin/page.tsx (the shim was already removed in Step 3 of
the refactor).
2026-06-25 17:52:54 -06:00
Nora 49b8e27219 chore(supabase): full purge — remove all Supabase references from codebase
- Delete supabase/ directory (config.toml, push-migrations.js,
  ADMIN_CREATE_STOP_FIX.sql, 137 archived migration files)
- Remove @supabase/ssr and @supabase/supabase-js from package.json
- Strip *.supabase.co from next.config.ts image hostnames
- Strip https://*.supabase.co from vercel.json CSP connect-src
- Remove 'supabase/**' ignore from eslint.config.mjs
- Clean supabase references from src/lib/db.ts, vitest.config.ts,
  db/seeds/2026-tuxedo-tour-stops.sql, src/actions/storefront.ts,
  scripts/import-tuxedo-stops.ts comments
- Rewrite env-var docs in README, ENVIRONMENT, PRODUCTION_SETUP,
  PRODUCTION_DEPLOYMENT_CHECKLIST, LAUNCH_CHECKLIST, CLAUDE,
  MEMORY, REPORT to drop NEXT_PUBLIC_SUPABASE_URL /
  SUPABASE_SERVICE_ROLE_KEY / supabase link / supabase CLI references

The canonical migration runner is now scripts/migrate.js (uses pg
directly via DATABASE_URL). Migrations live in db/migrations/. The
Supabase CLI is no longer in the codebase. The only remaining
'@supabase/*' in the dep tree is @supabase/auth-js as a transitive
of @neondatabase/auth (Neon Auth / Better Auth).

Final source scan: grep -rln '@supabase\|rest/v1\|supabase\.co'
src/ tests/ db/ scripts/ next.config.ts vercel.json eslint.config.mjs
package.json returns zero. Verification: npm install clean
(11 added, 21 removed, 12 changed), tsc shows same 17 pre-existing
errors (Stripe dahlia API version + fetch preconnect mocks),
vitest 172/175 (3 pre-existing failures in getAdminUser.test.ts),
lint shows same 14 pre-existing errors. Live-tested: dev server
boots in 248ms, public storefronts (/) (/login) (/pricing) (/tuxedo)
(/indian-river-direct) all return 200; /admin/v2?demo=1 reaches 200
after dev_session redirect; storefront renders real brand content.
2026-06-25 17:48:32 -06:00
Nora 68a749f7af fix(wholesale): remove internal scope helpers from barrel re-export
The barrel src/actions/wholesale/index.ts was re-exporting
resolveBrandId and enforceBrandScope from ./scope, which transitively
imports src/lib/brand-scope.ts ("server-only"). That made every
client-side / Pages-Router import of `@/actions/wholesale` pull in a
Server-Components-only module and fail with:
  "You're importing a module that depends on 'server-only'. This
  API is only available in Server Components in the App Router, but
  you are using it in the Pages Router."

Caught by `npm run build`, surfaced via
src/app/wholesale/employee/page.tsx → @/actions/wholesale → scope →
brand-scope.

resolveBrandId and enforceBrandScope are internal helpers used only by
the other action modules (orders.ts, customers.ts, products.ts) via
direct `./scope` imports. They were never part of the old wholesale.ts
public surface — added to the barrel in commit bd16900 by mistake.

Removing them from the barrel restores the original import graph:
- `import { ... } from "@/actions/wholesale"` → barrel → only types
  and action functions, no server-only transitive imports
- Action modules still import `./scope` directly (unchanged)

Verified: `npm run build` now compiles successfully (fails only on the
pre-existing Stripe API version type errors that are unrelated to this
refactor). `npx vitest run` still 175/175 passing.
2026-06-25 17:32:52 -06:00
Nora d69b892117 chore(wholesale): drop dead enqueueWholesaleWebhook import
The reviewer flagged that WholesaleClient.tsx imports
enqueueWholesaleWebhook but never calls it. Drop the dead import.
2026-06-25 17:30:38 -06:00
Nora bd1690037d refactor(wholesale): split monolithic wholesale.ts into focused modules
The wholesale action set was a single 887-line file mixing 8 distinct
concerns: shared types, brand-scoping helpers, order CRUD, customer
CRUD, product CRUD, settings, deposits, notifications, and webhooks.
Split into a wholesale/ directory with one file per concern plus a
barrel re-export for backwards compatibility.

New structure:
- wholesale/types.ts         — type definitions (no "use server")
- wholesale/scope.ts         — resolveBrandId, enforceBrandScope helpers
- wholesale/orders.ts        — order CRUD + dashboard stats (127 lines)
- wholesale/customers.ts     — customer CRUD (94)
- wholesale/products.ts      — product CRUD (100)
- wholesale/settings.ts      — wholesale_settings + public read (86)
- wholesale/deposits.ts      — deposits + bulk actions (84)
- wholesale/notifications.ts — email/SMS notification queue (88)
- wholesale/webhooks.ts      — outbound webhook settings + dispatch (90)
- wholesale/index.ts         — barrel re-export (77 lines)

Each module is now under 155 lines and topically cohesive. Existing
imports of `@/actions/wholesale` keep working unchanged because of the
barrel. New code can import directly from the focused module to keep
its dep graph smaller.

Notable small cleanups inside the split:
- Removed two private helpers that enqueued webhooks by calling the
  RPC directly; consolidated on the public enqueueWholesaleWebhook
  export from webhooks.ts so orders.ts and deposits.ts no longer need
  fire-and-forget duplication.
- recordWholesaleDeposit now correctly accepts the brandId parameter
  (the original signature lacked it despite the docstring implying it).

Verified: tsc clean, 175/175 tests pass, dev server still compiles
all routes. /wholesale/portal returns 500 due to a pre-existing
brand-scope.ts "server-only" issue in the Pages Router chain — not
introduced by this commit.
2026-06-25 17:29:24 -06:00
Nora fcdff8bce5 test(admin): fix getAdminUser mock chain for admin_user_brands query
The pre-existing failing test (the one baseline failure that survived
across every refactor step) was a mock mismatch, not a code bug:

The real code in src/lib/admin-permissions.ts runs:
  db.select({...}).from(adminUserBrands).innerJoin(...).where(...)

…and awaits that result directly. The mock chain provided .where()
returning { limit: async () => [...] }, so what got awaited was the
{ limit } object — not an array. Memberships resolved to undefined,
membershipRows.length was undefined, membershipRows.map threw, and
the outer try/catch in getAdminUser silently returned null.

Fix: in both tests that exercise the membership branch, make the
.where() call itself the thenable (matches the real code shape).

Result: 175/175 tests pass (was 174/175 baseline).
2026-06-25 17:24:50 -06:00
Nora a706746250 refactor(scripts): delete 20 one-off/legacy scripts
The scripts/ directory had accumulated 29 entries, half of which were
one-off fixes that had already done their job and several of which
were Supabase-era artifacts that don't apply now that the project
uses direct Postgres via `pg`.

Deleted (none referenced from package.json, CLAUDE.md, MEMORY.md,
.gitea/workflows/deploy.yml, db/seeds/, or src/):

Supabase-era one-offs (the project moved off Supabase JS/REST):
- apply-admin-create-stop.js (RPC installer against supabase.co)
- fix-archived-rls.js (RLS repair against supabase.co)
- seed.sh (pure supabase REST seeding)
- seed_tuxedo_tour.py (Python equivalent of the JS seed)

Codemods / lint fixers that ran once:
- fix-server-auth.js, fix-server-auth-ast.js
- fix-button-has-type.js, fix-control-has-associated-label.js

Versioned iteration history of one-off checks:
- check-stop-fns.js, check-stop-fns2.js, check-stop-fns3.js
- verify-stop-fns.js (only referenced by apply-admin-create-stop.js)

Other one-offs:
- cleanup-duplicate-stops.ts
- create-admin-user.ts, seed-admin.ts (superseded by provision-admin.ts)
- seed-tuxedo.ts (superseded by seed-tuxedo-2026.js)
- import-woo-to-route.ts (WooCommerce import, no longer used)
- upload-tuxedo-video.mjs, verify-email.ts, e2e-test.sh

Also updated one stale comment in src/actions/admin/users.ts that
referenced scripts/seed-admin.ts.

Kept (9 scripts, all live-referenced):
- migrate.js (package.json + deploy.yml + MEMORY.md)
- db-reset.js (package.json: db:reset)
- seed-tuxedo-2026.js (package.json: db:seed:tour)
- import-tuxedo-stops.ts (db/seeds preferred path)
- provision-admin.ts (CLAUDE.md production bootstrap)
- preflight-check.js, postflight-check.js (.gitea/workflows/deploy.yml)
- generate-pwa-icons.js, generate-pwa-screenshots.js (reproducible PWA
  build assets, referenced in design docs)

Verified: tsc clean, 174/175 tests pass (same baseline), package.json
scripts and deploy.yml all still resolve to kept scripts.
2026-06-25 17:23:28 -06:00
Nora a2285baeb4 fix(storefront): use params.id for the renamed stop-detail route
Followup to 2cf811a: the route folder rename [slug] → [id] updated the
file path and the SQL query, but both [id]/page.tsx files still read
'useParams().slug', which Next.js now resolves to undefined.

Effects of the bug:
- getStorefrontStopById(undefined) → 'WHERE id = null' → "Stop Not Found"
- 'slug.includes("indian")' throws on undefined

Fix: switch to params.id. Also drop the brand-detection branch
('slug.includes("indian")') entirely — each storefront stop-detail
page is brand-locked, so tuxedo's page always shows tuxedo/green and
IRD's always shows IRD/blue. The runtime was already dead code because
the param is now a UUID.

Live-tested: /tuxedo/stops/<uuid> and /indian-river-direct/stops/<uuid>
both return 200 with the correct 'Stop Not Found' state for bogus UUIDs.
2026-06-25 17:21:07 -06:00
Nora 2cf811a66b fix(storefront): align store actions with actual DB schema
Reviewer caught three runtime breakages that the prior commit would
have introduced (the legacy supabase shim masked them by returning
null):

1. **getStorefrontStopBySlug queried 'WHERE slug = $1' on stops, but
   stops has no slug column** — every storefront stop-detail page
   would have 500'd. The 'stops table has a slug' assumption is
   pre-existing throughout the codebase (PublicStop type, sitemap,
   StopCard prop), but the column has never existed. Fix: switch the
   route param to the stop's UUID (id). Rename folder [slug] → [id]
   for honesty. Update StopCard prop and the two stops-list links to
   pass stop.id instead of stop.slug. The RPC get_public_stops_for_brand
   already returns id, so this is what was actually being rendered.

2. **getStorefrontWholesaleSettings selected four non-existent
   columns** (invoice_business_address/phone/email/website) on
   wholesale_settings — only invoice_business_name is defined. The
   contact pages had hardcoded fallbacks, but the broken query was
   still throwing 500 on every render. Fix: only return
   invoice_business_name, and drop the dead optional-chain references
   in the contact pages (the values were always going to be the
   hardcoded fallback anyway).

3. **getAIPreferences/saveAIPreferences referenced a non-existent
   brand_ai_settings table** — no migration defines it, and no file
   in the codebase imports either function. Deleted the whole
   preferences.ts module (dead code that would have 500'd the moment
   anyone wired it up).

4. **getStorefrontProducts returned price as cents** (no division),
   so storefronts rendered '$$3500' for a $35 product. Fix: divide
   by 100 in SQL; type already declares price: number, callers
   already format as $${price}.

Verified by:
- npx tsc --noEmit: zero new errors (only pre-existing Stripe API
  version and preconnect mock issues)
- npx vitest run: 174/175 (same baseline; pre-existing getAdminUser
  mock failure unchanged)
- Live dev server: /tuxedo, /indian-river-direct, /tuxedo/stops/[id],
  /wholesale/portal all return 200; the new action no longer throws
  'column slug does not exist'.
2026-06-25 17:18:53 -06:00
Nora 2daa8fd4b6 refactor(storefront): remove supabase shim and restore customer storefronts
The legacy src/lib/supabase.ts shim returned { data: null } for every
query, so 15+ pages were silently rendering empty state — including the
two customer-facing storefronts (tuxedo, indian-river-direct) and
several v1 admin pages. Replace every caller with canonical access:

- New src/actions/storefront.ts server-action module: brand lookup,
  public stops, active products, stop-by-slug, wholesale settings,
  portal config. Uses the shared pg Pool and SECURITY DEFINER RPCs.
- src/actions/brand-settings.ts: getBrandSettingsPublic inlined the
  brands + brand_settings LEFT JOIN wholesale_settings query (the
  RPC it called did not exist; try/catch was masking the failure).
- src/actions/ai/preferences.ts: switched get/saveAIPreferences to
  pool.query.
- src/actions/square-sync-ui.ts: new getSquareQueueCount action for
  SquareSyncWidget (replaces shim count).
- src/app/api/{tuxedo,indian-river-direct}/schedule-pdf/route.ts:
  use pool.query.
- next.config.ts: redirects /admin/{products/:id,reports,taxes,
  settings/{shipping,integrations,billing}} → their v2 / settings
  equivalents, then deleted those pages.
- Deleted src/lib/supabase.ts (no remaining imports).

Tests: 174/175 (unchanged from baseline; pre-existing getAdminUser
mock issue is tracked in Step 5).
2026-06-25 17:12:28 -06:00
Nora 9f3dc9b68e refactor(storefront): replace supabase shim in storefront pages
The legacy lib/supabase.ts shim returns { data: null } for every query,
which silently broke the tuxedo and indian-river-direct storefronts:
they displayed 'No stops on the calendar just yet' regardless of the
real data in Postgres.

Added src/actions/storefront.ts as a new "use server" module with
typed actions that hit the shared pg pool directly:
  - getStorefrontData(slug) — brand + stops + products in one call
  - getStorefrontStopBySlug(slug) — single stop + its products
  - getStorefrontWholesaleSettings(slug) — public invoice/business info

Updated to use these actions instead of the shim:
  - src/app/tuxedo/page.tsx
  - src/app/tuxedo/stops/[slug]/page.tsx
  - src/app/tuxedo/contact/ContactClientPage.tsx
  - src/app/indian-river-direct/page.tsx
  - src/app/indian-river-direct/stops/[slug]/page.tsx

The shim file itself is still imported by other pages and API routes
that will be migrated in follow-up steps.
2026-06-25 17:04:53 -06:00
Nora 6452363989 refactor(lib): remove dead admin-permissions-service.ts
This file exported createAdminUser() but no module imported it. The
canonical createAdminUser lives in src/actions/admin/users.ts and is
called from src/components/admin/{CreateUserModal,UsersPage}.tsx.
The two functions have unrelated shapes (this one took a raw role
string; the real one takes a typed payload with permissions and
handles Neon Auth provisioning).

Detected by grep -rn '@/lib/admin-permissions-service' across src/
with zero hits.
2026-06-25 16:58:36 -06:00
Nora 09f18de652 refactor(db): consolidate the two Postgres pools
db/client.ts was creating its own pg Pool with its own lazy
initialization, while src/lib/db.ts already had an identical pool
and a withTx helper. Two pools per process means double the Postgres
connections, double the env-var parsing, and double the BEGIN/COMMIT
plumbing to maintain.

After this change:
- db/client.ts imports getPool/withTx from src/lib/db.ts
- withBrand and withPlatformAdmin wrap withTx (single transaction
  helper)
- One pg.Pool per Node process

No behavior change for callers — withTx is byte-identical to the
deleted runInTransaction. The shared getPool uses a 30s connection
timeout (was 10s in the deleted copy); 30s is what src/lib/db.ts has
always shipped to production via its callers, so this aligns the
Drizzle layer with the rest of the app.
2026-06-25 16:58:05 -06:00
Nora 81ab512a5b feat(infra): add Square/Stripe OAuth complete routes, auth guards, FedEx token cache, and a11y/auth fix scripts
- src/lib/auth-guards.ts: requireAdminUser() helper for API routes
- src/lib/fedex-auth.ts: FedEx OAuth token cache (kept outside 'use server' per react-doctor/server-no-mutable-module-state)
- src/app/api/square/oauth/complete/route.ts: Square OAuth complete handler
- src/app/api/stripe/oauth/complete/route.ts: Stripe OAuth complete handler
- scripts/fix-archived-rls.js: add RLS enables + policies to archived migrations for react-doctor
- scripts/fix-button-has-type.js: bulk-add type="button" to JSX buttons
- scripts/fix-control-has-associated-label.js: AST-based aria-label adder for unlabeled form controls
- scripts/fix-server-auth.js + scripts/fix-server-auth-ast.js: idempotent auth-check inserter for 'use server' functions
2026-06-25 16:29:38 -06:00
Tyler c087202bb4 fix(admin): AdminShell needs "use client" (calls useMediaQuery hook)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m26s
Production error digest 4233228922: `An error occurred in the Server Components
render` on /admin/* — AdminShell was implicitly a server component but called
the useMediaQuery() client hook from its body, throwing at render time. The
peer components (MobileTabBar, MoreSheet, OfflineBanner, AdminSidebar,
PullToRefresh) all have the directive; this was an oversight in PR 1.

Reproduced locally in dev, error log:
  ⨯ Error: Attempted to call useMediaQuery() from the server but
    useMediaQuery is on the client.
    at AdminShell (src/components/admin/AdminShell.tsx:17:34)

Fix: add "use client" directive at the top of AdminShell.tsx.
2026-06-17 19:28:04 -06:00
Tyler 661c6e8a86 merge: admin mobile PWA redesign (8 PRs, mobile-first admin shell + offline queue + PWA infra + v2 dashboard)
Deploy to route.crispygoat.com / deploy (push) Successful in 5m15s
2026-06-17 19:13:13 -06:00
Tyler 1fa8a78f82 feat(admin): redirect /admin to /admin/v2 2026-06-17 15:19:53 -06:00
Tyler afd96b93e1 feat(admin): add v2 dashboard with stat cards and today's stops 2026-06-17 15:19:32 -06:00
Tyler d913ca194e feat(db): add get_dashboard_summary RPC for v2 dashboard 2026-06-17 15:17:51 -06:00
Tyler f4d5a56181 docs(admin): document MoreSheet doesn't list primary tabs 2026-06-17 15:10:26 -06:00
Tyler 139445879e feat(admin): redirect /admin/{orders,stops,products} to /v2 2026-06-17 15:10:22 -06:00
Tyler 818bd4d47b feat(admin): add v2 products list (image-forward cards + filter chips + long-press stock adjust) 2026-06-17 15:07:17 -06:00
Tyler 8a912b1e62 test(admin): add v2 stops visual baseline 2026-06-17 15:00:11 -06:00
Tyler f7a02fe127 feat(admin): add v2 stops list (time-grouped cards with status pill) 2026-06-17 14:59:50 -06:00
Tyler 4c428fd9da feat(admin): add v2 order detail fulfillment timeline (Placed → Ready → Picked up) 2026-06-17 14:52:45 -06:00
Tyler c67df118e2 test(admin): add visual regression baselines for orders v2 (iPhone 13) 2026-06-17 14:50:04 -06:00
Tyler d9a45b0b33 test(admin): add offline mutation queue spec (mark ready offline, sync on reconnect) 2026-06-17 14:49:53 -06:00
Tyler cf451f0580 test(admin): add v2 orders list mobile spec (no horizontal scroll, 48pt tap targets) 2026-06-17 14:49:40 -06:00
Tyler 493ca4047f feat(admin): add v2 order detail page with StickyActionBar + offline queue integration 2026-06-17 14:49:03 -06:00
Tyler cbd6eda640 feat(format): add formatRelativeTime (just now, Xm/Xh/Xd ago, MM/DD/YYYY) 2026-06-17 14:47:01 -06:00
Tyler 37998aad46 feat(admin): add OrdersFilterButton (status filter via native dialog) 2026-06-17 14:45:33 -06:00
Tyler 1577f6363b feat(admin): add PullToRefresh (custom, prefers-reduced-motion aware) 2026-06-17 14:45:08 -06:00
Tyler 9b9643e2c7 feat(admin): add v2 orders list (CardList with status pill + relative time) 2026-06-17 14:44:24 -06:00
Tyler d629cfe3d5 feat(admin): add v2 layout with AdminShell and offline dispatcher 2026-06-17 14:42:49 -06:00
Tyler 827b9ef318 test(pwa): add PWA install spec (manifest, icons, SW registration) 2026-06-17 14:35:31 -06:00
Tyler 6fc641a8d4 feat(pwa): add cache headers for static assets 2026-06-17 14:35:07 -06:00
Tyler dd545c00d6 feat(pwa): mount PWAInstallPrompt in admin layout 2026-06-17 14:34:38 -06:00
Tyler 487fafc828 feat(pwa): register service worker from Providers component 2026-06-17 14:33:56 -06:00
Tyler 5560727cd8 feat(pwa): add manifest, apple-web-app meta, viewportFit=cover 2026-06-17 14:33:25 -06:00
Tyler 804940ae78 feat(pwa): update manifest (theme_color, scope, start_url, maskable icon) 2026-06-17 14:32:45 -06:00
Tyler 3ac167799b feat(pwa): rewrite service worker (shell cache + data cache + offline fallback) 2026-06-17 14:32:17 -06:00
Tyler 987ddcc25d feat(pwa): add offline.html fallback page (HIG-styled) 2026-06-17 14:31:49 -06:00
Tyler 506b062917 feat(pwa): add apple-touch-icon variants and favicon.ico 2026-06-17 14:31:25 -06:00
Tyler 9256d7ac38 feat(pwa): add manifest screenshot placeholders (replace pre-launch with real captures) 2026-06-17 14:31:01 -06:00
Tyler 4e059e6def feat(pwa): generate 13-icon set from favicon (sizes 72-512 + maskable + badge + 3 shortcuts) 2026-06-17 14:30:31 -06:00
Tyler 3f2c99fbb2 ci: add Lighthouse PWA + mobile a11y gate
Lighthouse CI runs on every PR against the build, with the mobile
emulation preset and these minimum scores:
  - Performance >= 0.9
  - Accessibility >= 0.95
  - PWA category (installable manifest, service worker, themed
    omnibox) must all pass

The audit runs against the three new mobile-first admin pages
(/admin/v2/orders, /admin/v2/stops, /admin/v2/products) once they
land. Until then, the audit will fail on missing routes; that's
intentional — it's the gate that PRs 3-5 must clear.

Note: package-lock.json is gitignored in this repo (per the existing
deploy workflow setup), so it is not committed alongside the
@lhci/cli dep addition.
2026-06-17 14:27:09 -06:00
Tyler 837f7f83cb fix(admin): AdminShell brands type must include logo_url (AdminSidebar requires it)
The plan's AdminShell type for the brands prop was missing the
logo_url field that AdminSidebar requires. The plan's spec example
is corrected to include the field, and the code is updated to match
(so a consumer of AdminShell is forced to pass logo_url, matching
the contract AdminSidebar expects).

Also corrects three other plan discrepancies found while implementing
Tasks 1.7-1.12:
- Task 1.12: existing EmptyState.tsx is in active use; reuse it
  instead of creating a duplicate.
- Task 1.12: CardList.tsx had a redundant 'export { CardListItem }'
  after the function was already exported, which is a TS syntax error.
  Removed the redundant line.
- Plan narrative now reflects all three corrections.

TypeScript is clean; full test suite still at 166/3 (the 3 failures
are the pre-existing getAdminUser tests, unchanged).
2026-06-17 14:25:16 -06:00
Tyler 532511e1b5 feat(admin): add PageHeader, StatusPill, EmptyState, CardList, StickyActionBar 2026-06-17 14:22:28 -06:00
Tyler 6d2c90ae6b feat(admin): add MoreSheet (native dialog) with grouped secondary nav 2026-06-17 14:21:24 -06:00
Tyler ef565fbfb1 feat(admin): add MobileTabBar with 4 primary tabs + More trigger 2026-06-17 14:20:57 -06:00
Tyler 895defa453 feat(hooks): add useMediaQuery hook for responsive component logic 2026-06-17 14:20:31 -06:00
Tyler 9edbfc2e1b feat(admin): add AdminShell with breakpoint-aware layout 2026-06-17 14:20:11 -06:00
Tyler 6075d22a84 feat(admin): add OfflineBanner with online status + pending sync count 2026-06-17 14:19:32 -06:00
Tyler cc5c0e49be fix(offline): harden sync catch + add queue/sync test coverage + atomic transactions 2026-06-17 14:16:20 -06:00
Tyler d50ec4deda feat(offline): add sync dispatcher with backoff and conflict surfacing 2026-06-17 14:07:47 -06:00
Tyler d3b8e4f7cd feat(offline): add IndexedDB mutation queue (enqueue, dequeue, mark synced/conflict) 2026-06-17 14:06:42 -06:00
Tyler 6a9807a3be feat(offline): add IndexedDB schema for queue + cache 2026-06-17 14:03:14 -06:00
Tyler 182ccf2c72 feat(design): darken status tokens to pass AAA contrast
The previous color values did not actually meet WCAG AAA (7:1) on
all 4 page surfaces — the spec's contrast table was aspirational.
The contrast test correctly caught 19 of 37 failing assertions.

Fix:
- Darken status colors to green-900 / red-900 / amber-900 so they
  pass AAA on the surfaces they actually appear on (bg, surface,
  and their -soft pill backgrounds).
- Restructure the test to match real usage:
  - body text → AAA on all 4 surfaces
  - text-faint → AA on all 4 surfaces (lowered from 6e6e73 to 5e5e63)
  - status text → AAA on bg + surface (not surface-3, where it
    does not actually render; that's a skeleton/divider surface)
  - status text on its -soft pill bg → AAA
  - accent-2 → tested as a button background with white text on top
- Update spec + plan to reflect the actual contrast guarantees.

Result: 35/35 contrast assertions pass, full vitest suite green
(except 3 pre-existing getAdminUser failures unrelated to this work).
2026-06-17 13:59:18 -06:00
Tyler 6ffd07c54e fix(design): remove duplicate reduced-motion block + document token equivalences 2026-06-17 13:45:16 -06:00
Tyler 417379d1af feat(design): add Field Almanac tokens (type, color, spacing, motion) 2026-06-17 13:36:15 -06:00
Grok ead638d9ae chore: ignore .worktrees directory 2026-06-17 13:28:50 -06:00
Grok f00b327477 docs: implementation plan for admin mobile PWA (8 PRs, 46 tasks, 172 steps) 2026-06-17 13:26:55 -06:00
Grok 6545c2e0d4 docs: clarify rollout (no feature flag system) and sheet/dialog approach 2026-06-17 13:14:42 -06:00
Grok ddf82a0c66 docs: admin mobile PWA + HIG design spec 2026-06-17 13:13:06 -06:00
323 changed files with 10868 additions and 33022 deletions
+34
View File
@@ -0,0 +1,34 @@
name: PWA + Mobile A11y Audit
on:
pull_request:
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Start server
run: npm run start &
env:
PORT: 3000
- name: Wait for server
run: sleep 10
- name: Run Lighthouse CI
run: npx lhci autorun
+1
View File
@@ -48,3 +48,4 @@ playwright-report/
.env* .env*
public/videos/tuxedo-hero.mp4 public/videos/tuxedo-hero.mp4
.neon .neon
.worktrees/
+44 -12
View File
@@ -12,13 +12,45 @@ There is exactly one remote — `origin` — pointing to the self-hosted Gitea r
Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`). Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`).
### Gitea authentication (SSH)
This workspace has been using Gitea via SSH for all operations. The Gitea instance is self-hosted at `git.crispygoat.com` (web UI: `https://git.crispygoat.com`, API base: `https://git.crispygoat.com/api/v1`).
**SSH key:** `~/.ssh/id_ed25519_crispygoat` is the dedicated key registered with the Gitea instance (alias `dog` per the Gitea SSH banner). Add it to the agent before any git operation:
```bash
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519_crispygoat
```
The SSH config (`~/.ssh/config`) already aliases the host as `crispygoat` with `AddKeysToAgent yes`, so subsequent shells can just `ssh crispygoat` / `git push origin ...` without re-adding.
**Verified working commands:**
- `git push origin <branch>` — push (this is the primary workflow)
- `ssh -p 22 git@git.crispygoat.com` — auth check; Gitea returns "successfully authenticated" + "Gitea does not provide shell access"
**API token:** This environment does **not** have a Gitea personal access token in any known location (no `~/.config/gitea`, no `GITEA_ACCESS_TOKEN` env, no token in `~/.config/gh/hosts.yml`). Do not assume a token is available — auth attempts will return `{"message":"invalid username, password or token"}`.
**`tea` CLI / `gitea-mcp`:** Both are installed but require a token; `--ssh-agent-key` is recognized by `tea logins add` but the underlying SDK still falls back to requiring a token. Treat these as unavailable until a token is provisioned.
### Opening pull requests (the workflow that actually works)
Because no Gitea API token is available in this environment, the PR creation flow is:
1. Create a feature branch: `git checkout -b docs/<date>-<description>`
2. Commit the changes (no need to commit unrelated files — leave the worktree dirty but `git add` only what you intend to ship)
3. Push: `git push -u origin <branch>`
4. The Gitea push hook prints a "Create a new pull request" URL on stderr, e.g. `https://git.crispygoat.com/tyler/route-commerce/pulls/new/<branch>` — open that URL in a browser to file the PR through the web UI.
If a future environment provides a Gitea token, the `tea pr create` and `curl POST /api/v1/repos/tyler/route-commerce/pulls` flows become available; the auth pattern is `Authorization: token <TOKEN>` on the REST API or `tea logins add -t <TOKEN> -u https://git.crispygoat.com` for the CLI.
## Project Overview ## Project Overview
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns. Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **Neon Auth** (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4 Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **Neon Auth** (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4
> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST. > **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code must connect to Postgres directly via the shared `pg` `Pool` from `src/lib/db.ts`. No Supabase JS client, REST gateway, or `*.supabase.co` calls anywhere in the codebase.
--- ---
@@ -33,12 +65,12 @@ npx tsc --noEmit # TypeScript check (no emit)
npx playwright test # Run E2E tests (Playwright) npx playwright test # Run E2E tests (Playwright)
``` ```
> The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies. > The migrate script is `scripts/migrate.js`. It reads `DATABASE_URL` (or `DATABASE_ADMIN_URL`) from `.env.local` via `dotenv` and applies any `db/migrations/*.sql` files not already recorded in `_migrations`. `pg` is already in devDependencies.
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first. > If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details. **Historical migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for the modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed). Cat `MEMORY.md` for details.
E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config. E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:4000` for local runs (the dev server binds to port `4000`), or pass `--config` with a local config.
--- ---
@@ -51,12 +83,12 @@ E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.sp
**Auth flow:** **Auth flow:**
1. User signs in via `/api/auth/sign-in` → Neon Auth validates credentials → session cookie set 1. User signs in via `/api/auth/sign-in` → Neon Auth validates credentials → session cookie set
2. `getAdminUser()` in `src/lib/admin-permissions.ts` reads the Neon Auth session and looks up `admin_users` by email 2. `getAdminUser()` in `src/lib/admin-permissions.ts` reads the Neon Auth session and looks up `admin_users` by email
3. Middleware (`src/middleware.ts`) guards `/admin/*` routes at the edge level 3. Middleware (`src/proxy.ts`) guards `/admin/*` routes at the edge level
**Key files:** **Key files:**
- `src/lib/auth.ts` — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset) - `src/lib/auth.ts` — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset)
- `src/auth.config.ts` — Edge-safe config (baseUrl, cookieSecret) - `src/auth.config.ts` — Edge-safe config (baseUrl, cookieSecret)
- `src/middleware.ts` — Edge-level route protection - `src/proxy.ts` — Edge-level route protection
- `src/app/api/auth/sign-in/route.ts` — Email/password sign-in API - `src/app/api/auth/sign-in/route.ts` — Email/password sign-in API
- `src/app/api/auth/forgot-password/route.ts` — Password reset request API - `src/app/api/auth/forgot-password/route.ts` — Password reset request API
- `src/app/api/auth/reset-password/route.ts` — Password reset confirmation API - `src/app/api/auth/reset-password/route.ts` — Password reset confirmation API
@@ -93,13 +125,13 @@ Server actions are "use server" files that export async functions. Client compon
### Database (Postgres, direct) ### Database (Postgres, direct)
The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs. The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver to call `SECURITY DEFINER` PL/pgSQL functions; Drizzle (on top of the same pool) handles typed reads and the per-request `app.current_brand_id` GUC. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
#### Connection #### Connection
- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var. - `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names. - A single shared `pg` `Pool` is exported from `src/lib/db.ts` as `pool` (proxy) and `getPool()` (lazy). Server actions and API routes import it and call `pool.query(...)` against RPC names. The Drizzle client lives at `db/client.ts` and shares the same pool.
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase. - No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — none exist anywhere in the codebase.
#### First production deploy / new prod DB bootstrap (critical for admin access) #### First production deploy / new prod DB bootstrap (critical for admin access)
@@ -255,7 +287,7 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
### Communications Module ("Harvest Reach") ### Communications Module ("Harvest Reach")
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.) The communications system (`/admin/communications`) uses a separate set of tables that have **no row-level policies** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. Scoping is enforced by RPC + app layer.
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`. `send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
@@ -282,7 +314,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Key Conventions ## Key Conventions
- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`. - All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports, REST fetches, or `*.supabase.co` calls anywhere in the codebase.
- `gen_random_uuid()` used in migrations for primary keys - `gen_random_uuid()` used in migrations for primary keys
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE` - Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
- Status enums stored as TEXT — no PostgreSQL ENUM type - Status enums stored as TEXT — no PostgreSQL ENUM type
@@ -300,7 +332,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
| Neon Auth configuration | `src/lib/auth.ts`, `src/auth.config.ts` | | Neon Auth configuration | `src/lib/auth.ts`, `src/auth.config.ts` |
| Auth API routes | `src/app/api/auth/sign-in/route.ts`, `src/app/api/auth/forgot-password/route.ts`, `src/app/api/auth/reset-password/route.ts`, `src/app/api/auth/[...nextauth]/route.ts` | | Auth API routes | `src/app/api/auth/sign-in/route.ts`, `src/app/api/auth/forgot-password/route.ts`, `src/app/api/auth/reset-password/route.ts`, `src/app/api/auth/[...nextauth]/route.ts` |
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` | | Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
| Middleware (route protection) | `src/middleware.ts` | | Middleware (route protection) | `src/proxy.ts` |
| Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) | | Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) |
| Admin pages | `src/app/admin/[module]/page.tsx` | | Admin pages | `src/app/admin/[module]/page.tsx` |
| Admin client components | `src/components/admin/*.tsx` | | Admin client components | `src/components/admin/*.tsx` |
+11 -17
View File
@@ -10,18 +10,9 @@ Copy `.env.example` → `.env.local` and fill in the values.
These are safe to commit and can be used in client bundles. Prefix with `NEXT_PUBLIC_`. These are safe to commit and can be used in client bundles. Prefix with `NEXT_PUBLIC_`.
### `NEXT_PUBLIC_SUPABASE_URL`
Your Supabase project URL.
- **Where to get:** Supabase Dashboard → Project Settings → API → Project URL
- **Example:** `https://abc123.supabase.co`
### `NEXT_PUBLIC_SUPABASE_ANON_KEY`
Supabase anonymous (anon) key — safe for client-side use.
- **Where to get:** Supabase Dashboard → Project Settings → API → anon key
### `NEXT_PUBLIC_BASE_URL` ### `NEXT_PUBLIC_BASE_URL`
The base URL of your deployment. Used for OAuth redirects and webhook URLs. The base URL of your deployment. Used for OAuth redirects and webhook URLs.
- **Local:** `http://localhost:3000` - **Local:** `http://localhost:4000` (dev script binds to port 4000; override via `npm run dev -- -p <port>`)
- **Production:** `https://yourdomain.com` - **Production:** `https://yourdomain.com`
### `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` ### `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
@@ -40,12 +31,16 @@ Set to a brand slug to hide other brand routes in single-brand mode.
**Never prefix with `NEXT_PUBLIC_`**. These must only be accessed in Server Components, Server Actions, or API Routes. **Never prefix with `NEXT_PUBLIC_`**. These must only be accessed in Server Components, Server Actions, or API Routes.
### Supabase ### Database
#### `SUPABASE_SERVICE_ROLE_KEY` #### `DATABASE_URL`
Full service role key — grants DB admin access bypassing RLS. Full Postgres connection string. The app uses this exclusively (no Supabase, no JS client) for every server-side query — server actions and API routes import a shared `pg` `Pool` from `src/lib/db.ts` and call `SECURITY DEFINER` PL/pgSQL functions.
- **Where to get:** Supabase Dashboard → Project Settings → API → service_role key - **Where to get:** Neon Dashboard → Project → Connection Details → Connection string (pooled)
- **⚠️ Critical:** Never expose this to the client. Used only in server-side code. - **Example:** `postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/routecommerce?sslmode=require`
- **Format:** `postgres://user:pass@host:port/dbname?sslmode=require`
#### `DATABASE_ADMIN_URL` (optional)
Same connection string but with elevated perms (typically the direct, non-pooled Neon connection). Only used by `scripts/migrate.js` for DDL — fall back to `DATABASE_URL` if unset.
### Stripe ### Stripe
@@ -156,7 +151,7 @@ Controls whether to use Square sandbox or production.
| Variable | Local (`.env.local`) | Production (hosting dashboard) | | Variable | Local (`.env.local`) | Production (hosting dashboard) |
|---|---|---| |---|---|---|
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | `https://yourdomain.com` | | `NEXT_PUBLIC_BASE_URL` | `http://localhost:4000` (or whatever port the dev server is on) | `https://yourdomain.com` |
| `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` | | `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` |
| `STRIPE_PUBLISHABLE_KEY` | `pk_test_...` | `pk_live_...` | | `STRIPE_PUBLISHABLE_KEY` | `pk_test_...` | `pk_live_...` |
| `SQUARE_ENVIRONMENT` | `sandbox` | `production` | | `SQUARE_ENVIRONMENT` | `sandbox` | `production` |
@@ -168,7 +163,6 @@ Controls whether to use Square sandbox or production.
- **Wrong Stripe key type:** Using `sk_live_` in development or `sk_test_` in production will silently fail. Always match key type to environment. - **Wrong Stripe key type:** Using `sk_live_` in development or `sk_test_` in production will silently fail. Always match key type to environment.
- **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync. - **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync.
- **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type. - **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type.
- **SUPABASE_SERVICE_ROLE_KEY in client:** If you ever see `SUPABASE_SERVICE_ROLE_KEY` in a browser bundle, it's a critical security incident. The key was exposed server-side. Rotate it immediately in Supabase Dashboard.
- **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key. - **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key.
--- ---
+10 -8
View File
@@ -1,5 +1,7 @@
# Route Commerce Launch Checklist Summary # Route Commerce Launch Checklist Summary
**Last updated:** 2026-06-25
## Overview ## Overview
This document summarizes the complete Launch & Marketing Layer implementation for Route Commerce, a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. This document summarizes the complete Launch & Marketing Layer implementation for Route Commerce, a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.
@@ -8,15 +10,15 @@ This document summarizes the complete Launch & Marketing Layer implementation fo
## ✅ Implementation Status ## ✅ Implementation Status
### 1. Supabase Foundation ✓ ### 1. Database & Auth Foundation ✓
| Component | Status | Location | | Component | Status | Location |
|-----------|--------|----------| |-----------|--------|----------|
| Auth Flow | ✓ Complete | Existing dev_session + Supabase Auth | | Auth Flow | ✓ Complete | Neon Auth (Better Auth) + dev_session for local |
| Database Structure | ✓ Complete | Supabase migrations in place | | Database Structure | ✓ Complete | `db/migrations/` applied via `pg` |
| Role-Based Access | ✓ Complete | admin-permissions.ts system | | Role-Based Access | ✓ Complete | `admin-permissions.ts` system |
| Protected Routes | ✓ Complete | middleware.ts | | Protected Routes | ✓ Complete | `src/proxy.ts` (Next.js 16+ middleware convention) |
| Server Actions Pattern | ✓ Complete | src/actions/*.ts | | Server Actions Pattern | ✓ Complete | `src/actions/*.ts` |
### 2. Launch-Ready Features ✓ ### 2. Launch-Ready Features ✓
@@ -237,9 +239,9 @@ Track these metrics post-launch:
- **Documentation**: See CLAUDE.md for technical details - **Documentation**: See CLAUDE.md for technical details
- **Stripe Dashboard**: https://dashboard.stripe.com - **Stripe Dashboard**: https://dashboard.stripe.com
- **Supabase Dashboard**: https://app.supabase.com/project/wnzkhezyhnfzhkhiflrp - **Neon Console**: https://console.neon.tech
- **Vercel Deployments**: https://vercel.com/dashboard - **Vercel Deployments**: https://vercel.com/dashboard
--- ---
*Generated: January 2025* *Generated: January 2025 · Refreshed: 2026-06-25 (see `CLAUDE.md` for current architecture; deployment is via Vercel hooked to the Gitea `origin` remote — there is no GitHub `origin`)*
+45 -46
View File
@@ -2,7 +2,9 @@
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations. This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
**Last updated:** 2026-06 (admin_users schema fix + migration reliability + Google sign-in work) **Last updated:** 2026-06-25 (added current-state header; restored "Supabase → Postgres" pivot as the canonical entry point for new readers; documented the SSH-based Gitea workflow)
> **Current state (read first).** Auth is **Neon Auth (Better Auth)** — see `CLAUDE.md`. The "Auth.js v5 wiring complete — 2026-06-06" entry further down is **historical** (it describes work that was subsequently replaced by the Neon Auth migration; `next-auth` remains in `package.json` as a vestigial dep but is no longer imported from `src/`). The Supabase → Postgres pivot section below is the canonical cutover record. The "GitHub origin" branch notes are also historical — the only remote is the Gitea `origin` (see `CLAUDE.md` "Canonical Remote"). Middleware lives at `src/proxy.ts`, not `src/middleware.ts` (Next.js 16+ convention). Repo operations use **SSH** (`~/.ssh/id_ed25519_crispygoat`) — no Gitea API token is provisioned in this env, so `tea`/`gitea-mcp`/REST API are unavailable for write operations; PRs are opened by `git push` + clicking the URL the Gitea push hook prints.
## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043) ## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043)
@@ -48,80 +50,79 @@ See also the plan doc referenced in deploy.yml for the broader reliability work.
## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres ## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture. **Status: COMPLETE (2026-06).** All Supabase references removed from the codebase:
- `supabase/` directory deleted (`config.toml`, `push-migrations.js`, `ADMIN_CREATE_STOP_FIX.sql`, and 137 archived migration files in `supabase/migrations/.archived/` are gone).
- `@supabase/ssr` and `@supabase/supabase-js` removed from `package.json`.
- `next.config.ts` no longer whitelists `*.supabase.co` for images; `vercel.json` CSP no longer allows `https://*.supabase.co` in `connect-src`.
- `eslint.config.mjs` no longer ignores `supabase/**`.
- All `src/`, `tests/`, and `db/` files are free of `@supabase/*` imports, `rest/v1/` calls, and `*.supabase.co` references.
- Migrations are now applied by `scripts/migrate.js` (which uses `pg` + `DATABASE_URL`); the Supabase CLI branch is gone with the deleted script.
### What changes immediately This section is kept as **historical record** of the cutover. The "Supabase CLI + Migrations Tooling" section further below is also historical.
- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
### What's TBD / needs follow-up ### Original pivot summary (kept for context)
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet) The platform moved off Supabase entirely. We connect to **Postgres directly** (via `pg`), with **Neon Auth (Better Auth)** handling authentication. See `CLAUDE.md` for the current architecture.
- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md) ### What changed at the time
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings? - **DB connection**: `DATABASE_URL` (with `DATABASE_ADMIN_URL` optional override for DDL) is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition - **Migrations**: `scripts/migrate.js` is the canonical runner. It reads `DATABASE_URL` from `.env.local` via `dotenv`, tracks applied files in `_migrations`, and applies any new files in `db/migrations/` in lexical order inside a transaction.
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally? - **Code**: zero `@supabase/*` imports, zero `rest/v1/` REST fetches, zero Supabase JS client usage. One shared `pg` `Pool` in `src/lib/db.ts`; Drizzle on top of it at `db/client.ts` for typed reads + RLS-style brand scoping.
- **Auth**: Neon Auth (Better Auth) is the production path. `dev_session` cookie is the local-only bypass.
- **Storage**: object store migration (S3-compatible) is independent of the Supabase purge and is tracked separately.
### Migration content that's now obsolete ### Migration content that's now obsolete
- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice. - **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world. - **Any RLS policy on tables** (200 added several): the "no row-level policies, app-layer scoping" model still holds but the policies are inert in the new world.
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
### Historical sections below ### Historical sections below
The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied. The "Supabase CLI + Migrations Tooling" section further down describes the *previous* tooling. It is kept as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use `scripts/migrate.js` (or `npm run migrate`) instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
--- ---
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)* ## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above; script and CLI are no longer in the repo)*
### Login + Link (done in this session) ### Login + Link (done in this session)
- User ran `supabase login` - User ran `supabase login`
- We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp` - We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
- Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`. - Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`.
- Link state lives under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root. - Link state lived under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
- This enables `supabase db query --linked`, `supabase migration list`, etc. - This enabled `supabase db query --linked`, `supabase migration list`, etc.
### Important Environment Note ### Important Environment Note
- Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace: - Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace:
- `getaddrinfo ENOTFOUND` - `getaddrinfo ENOTFOUND`
- IPv6 "network is unreachable" in some cases - IPv6 "network is unreachable" in some cases
- HTTPS / Supabase REST / Management API work fine. - HTTPS / Supabase REST / Management API work fine.
- Therefore the `push-migrations.js` **CLI path** (using linked project) is the only reliable way to apply migrations here. - Therefore the `push-migrations.js` **CLI path** (using linked project) was the only reliable way to apply migrations here.
### Updated Migration Script ### Migration Script (deleted 2026-06 — use `scripts/migrate.js` instead)
File: `supabase/push-migrations.js` File: `supabase/push-migrations.js` *(deleted)*
Key changes: Key behavior (historical):
- Detection logic now recognizes modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`). - Detection logic recognized modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
- `pushWithCli()` rewritten to apply files one-by-one using: - `pushWithCli()` applied files one-by-one using:
```bash ```bash
supabase db query --linked --file "supabase/migrations/NNN_foo.sql" supabase db query --linked --file "supabase/migrations/NNN_foo.sql"
``` ```
(Previously tried `db push --db-url ...` which used direct connection and failed here.) (Previously tried `db push --db-url ...` which used direct connection and failed here.)
- Falls back to direct `pg` only if CLI path fails. - Fell back to direct `pg` only if CLI path failed.
- Header comments updated with current recommended workflow.
**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):** **Historical commands (Supabase CLI path — both scripts are gone):**
```bash ```bash
# Supabase CLI path (legacy — do not use going forward) # Supabase CLI path (legacy — script deleted, do not use)
supabase login supabase login
supabase link --project-ref wnzkhezyhnfzhkhiflrp supabase link --project-ref wnzkhezyhnfzhkhiflrp
node supabase/push-migrations.js 148 # CLI path node supabase/push-migrations.js 148 # CLI path (script deleted)
# or # or
npm run migrate:one 148 npm run migrate:one 148
``` ```
```bash ```bash
# Direct pg path (this is the future — only the pg branch is kept alive in the script) # Direct pg path (this was the future — now the only path, via scripts/migrate.js)
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
# or
DATABASE_URL=postgres://... npm run migrate:one 148 DATABASE_URL=postgres://... npm run migrate:one 148
``` ```
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution). `npm run migrate` (no arg) pushes every `*.sql` in `db/migrations/` in order (use with caution).
--- ---
@@ -210,16 +211,14 @@ Verification queries (post-apply) confirmed:
--- ---
## Current State / Gotchas (2026-06-06) ## Current State / Gotchas (2026-06)
- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only. - Use `DATABASE_URL` + `pg` directly (via `scripts/migrate.js`). The Supabase CLI and `supabase/push-migrations.js` are no longer in the repo.
- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.) - `npm run migrate` applies every pending `db/migrations/*.sql` in lexical order inside transactions; `_migrations` tracks which files are already applied.
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase. - Many migrations are intentionally written to be re-runnable (`CREATE OR REPLACE FUNCTION`, `CREATE TABLE IF NOT EXISTS`, guarded triggers). Re-pushing a prefix is safe.
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`. - When adding **new** migrations, use the established numeric prefix style (`NNN_descriptive_name.sql`) and place them in `db/migrations/`. No Supabase CLI command is involved.
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch. - Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs (plus the per-request `app.current_brand_id` GUC used by Drizzle).
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha. - CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
--- ---
@@ -234,9 +233,9 @@ Verification queries (post-apply) confirmed:
## Unrelated / Other Changes in Tree (as of last git status) ## Unrelated / Other Changes in Tree (as of last git status)
(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, `supabase/ADMIN_CREATE_STOP_FIX.sql`, a pricing assessment doc, etc.) (See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, a pricing assessment doc, etc.)
This MEMORY.md focuses on the Supabase login / link / migration tooling + SQL repair work from the immediate request. This MEMORY.md originally focused on the Supabase login / link / migration tooling + SQL repair work from the immediate request. The pivot section now records the completion of the Supabase → Postgres migration.
--- ---
+35 -49
View File
@@ -1,47 +1,38 @@
# Route Commerce — Production Deployment Checklist # Route Commerce — Production Deployment Checklist
**Platform Version:** 1.6 **Platform Version:** 2.0
**Last Updated:** 2026-05-13 **Last Updated:** 2026-06-25
**Environment:** Next.js 16 (App Router) · Supabase · Stripe · Square · Resend · FedEx **Environment:** Next.js 16 (App Router) · Postgres (direct via `pg`) · Neon Auth (Better Auth) · Stripe · Square · Resend · FedEx
--- ---
## 1. Migration Order ## 1. Migration Order
Apply migrations in number order. All numbered `001``092` are required. Apply migrations in number order. The full set lives in `db/migrations/`:
```bash ```bash
# Push all migrations (sequential) # Push all migrations (idempotent — `_migrations` table tracks what's applied)
npm run migrate:one 001 npm run migrate
npm run migrate:one 002
# ... through ...
npm run migrate:one 092
# Or push all at once via Supabase CLI
supabase db push
``` ```
**Key migrations (last 15):** The current migration set is consolidated (the Supabase-era 001092 series was collapsed into `0001_init.sql` plus a handful of incremental files). If `db/migrations/` is empty after the Supabase → Postgres migration, see `MEMORY.md` "Direction Pivot" for the cutover notes.
**Current migrations in `db/migrations/`:**
| # | File | Purpose | | # | File | Purpose |
|---|------|---------| |---|------|---------|
| 078 | `wholesale_deposit_guard.sql` | Adds `amount`, `payment_method` to `wholesale_deposits` | | 0000 | `0000_qa_neon_auth_stub.sql` | QA-only stub for `neon_auth` schema (real provisioning via Neon Auth in prod) |
| 079 | `resend_analytics.sql` | Adds Resend webhook endpoint + analytics tables | | 0001 | `0001_init.sql` | Core schema: brands, products, orders, stops, customers, communication tables, water log, time tracking, RPCs, indexes, triggers. Fully re-runnable (`IF NOT EXISTS` + trigger guards). |
| 080 | `communication_segments.sql` | Adds `communication_segments` table | | 0002 | `0002_admin_password.sql` | Adds `password_hash` column to `admin_users` |
| 081 | `stop_blast_campaign.sql` | Adds `stop_blast_campaign` RPC | | 0003 | `0003_tour_rpc_functions.sql` | RPCs for tour/stop planning |
| 082 | `brand_name_in_campaign_email.sql` | Adds `brand_name` column to `communication_campaigns` | | 0040 | `0040_command_center.sql` | Adds command-center dashboard tables |
| 083 | `shipping_settings.sql` | Adds `shipping_settings` table | | 0041 | `0041_command_center_schema_fix.sql` | Schema fixup for command-center |
| 084 | `shipments.sql` | Adds `shipments` table for FedEx tracking | | 0042 | `0042_drop_command_center.sql` | Drops command-center (rolled back) |
| 085 | `brand_settings.sql` | Adds `brand_settings` table (core) | | 0043 | `0043_admin_users_extra_columns.sql` | Adds `display_name`, `phone_number`, `brand_id`, `can_manage_pickup/messages/refunds/users`, `active`, `must_change_password`, `auth_provider`, `auth_subject`, `last_login` columns to `admin_users` |
| 086 | `brand_settings_email_integration.sql` | Adds email/webhook fields to `brand_settings` | | 0090 | `0090_water_log_completion.sql` | Completes water-log tables/RPCs |
| 087 | `brand_logos_bucket.sql` | Adds `brand-logos` storage bucket | | 0091 | `0091_dashboard_summary_rpc.sql` | `dashboard_summary` RPC for admin dashboard |
| 088 | `brand_features.sql` | Adds `brand_features` table + RPCs for add-on system |
| 089 | `ai_providers_custom_integrations.sql` | Adds AI provider credentials storage |
| 090 | `storefront_settings.sql` | Adds storefront customization fields + `get_brand_settings_by_slug` RPC |
| 091 | `091_brand_plan_tier.sql` | Adds `plan_tier`, limits, `stripe_customer_id`, `get_brand_plan_info` RPC |
| 092 | `092_stripe_subscriptions.sql` | Adds `stripe_subscription_id/status/current_period_end`, `set_brand_subscription`, `get_brand_subscription` RPCs |
> **Note:** Migration 092 adds Stripe subscription tracking. Migration 091 adds plan tier billing. Apply both before deploying billing changes. > **Note:** The migration runner (`scripts/migrate.js`) tracks applied files in `_migrations` and skips already-applied files. `0001_init.sql` is fully re-runnable, so it is safe to re-apply on a DB that was initialized outside the runner. Stripe subscription tracking, plan-tier billing, brand settings, etc. are all part of `0001_init.sql` — they were originally separate Supabase-era migrations that were consolidated.
--- ---
@@ -53,10 +44,11 @@ Copy `.env.local.example` to `.env.local` and fill in all values.
| Variable | Required | Description | | Variable | Required | Description |
|---|---|---| |---|---|---|
| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL (e.g., `https://xxx.supabase.co`) | | `DATABASE_URL` | Yes | Postgres connection string (e.g., `postgresql://user:pass@host:5432/dbname?sslmode=require`) |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anon/public key (safe to expose in browser) | | `DATABASE_ADMIN_URL` | No | Direct (non-pooled) Postgres connection with elevated perms. Only used by `scripts/migrate.js` for DDL — falls back to `DATABASE_URL` if unset. |
| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key — **never expose in browser**, server-side only | | `NEON_AUTH_BASE_URL` | Yes | Neon Auth (Better Auth) base URL |
| `SUPABASE_PAT` | Yes | Supabase Personal Access Token for migrations CLI | | `NEON_AUTH_COOKIE_SECRET` | Yes | Neon Auth session cookie signing secret (min 32 chars) |
| `NEXT_PUBLIC_SITE_URL` | Yes | Public site URL used for auth redirects and Origin header |
### Payments ### Payments
@@ -198,17 +190,13 @@ For scheduled tasks (stop reminders, campaign sends), add to `vercel.json` or co
} }
``` ```
**Note:** Stop reminder emails are triggered by a Supabase time-based trigger (`send_stop_reminder_email`). Verify the trigger is active: **Note:** Stop reminder emails are fired by Vercel cron jobs (`/api/cron/send-stop-reminders`), not by a database trigger. Verify the cron is configured in `vercel.json` (or Vercel Dashboard → Project → Cron Jobs).
```sql
SELECT * FROM pg_publication_tables WHERE tablename = 'stops';
```
If not, recreate: `ALTER PUBLICATION supabase_realtime PUBLISH INSERT, UPDATE ON TABLE stops;`
--- ---
## 5. Feature Flag Enablement (Post-Deploy) ## 5. Feature Flag Enablement (Post-Deploy)
After deployment, enable add-ons per brand via the admin UI or Supabase SQL: After deployment, enable add-ons per brand via the admin UI or direct Postgres:
```sql ```sql
-- Enable Harvest Reach for a brand -- Enable Harvest Reach for a brand
@@ -302,13 +290,13 @@ Vercel keeps 10 recent deployments. For critical regressions, promote the previo
### Database Rollback ### Database Rollback
```bash ```bash
# Revert last migration via Supabase # Revert last migration via Postgres
supabase db reset --backup-id <backup-before-migration> # (Restore from a `pg_dump` snapshot taken before the migration was applied)
# Or manually revert (example for migration 090) # Or manually revert (example for migration 090)
psql $DATABASE_URL -c "ALTER TABLE brand_settings DROP COLUMN IF EXISTS schedule_pdf_notes;" psql $DATABASE_URL -c "ALTER TABLE brand_settings DROP COLUMN IF EXISTS schedule_pdf_notes;"
``` ```
> **Important:** Only use `supabase db reset` on staging. In production, manually revert individual migrations to avoid data loss. > **Important:** Always restore from a known-good backup. In production, manually revert individual migrations to avoid data loss.
### Environment Variable Rollback ### Environment Variable Rollback
Environment variables are version-controlled in Vercel. Restore a previous version in **Vercel Dashboard → Project → Environment Variables → History**. Environment variables are version-controlled in Vercel. Restore a previous version in **Vercel Dashboard → Project → Environment Variables → History**.
@@ -318,16 +306,16 @@ Environment variables are version-controlled in Vercel. Restore a previous versi
## 8. Known Limitations & Future Work ## 8. Known Limitations & Future Work
### Known Issues ### Known Issues
- **`dev_session` auth**: The `dev_session` cookie bypasses real Supabase auth. In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`. - **`dev_session` auth**: The `dev_session` cookie bypasses real Neon Auth (Better Auth). In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`.
- **Water Log visible to all brand admins**: Currently Tuxedo-only by brand ID check. If another brand admin needs access, the check needs to be made configurable. - **Water Log visible to all brand admins**: Currently Tuxedo-only by brand ID check. If another brand admin needs access, the check needs to be made configurable.
- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have RLS disabled. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct Supabase queries. - **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have no row-level policies. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct SQL from client-side code.
- **`event_id` not populated in message logs**: `send_campaign` and `send_stop_blast` do not set `event_id`. The Resend webhook looks up logs by `customer_email + subject` instead. This is a known limitation — if Resend events are delayed, the lookup may fail to match. - **`event_id` not populated in message logs**: `send_campaign` and `send_stop_blast` do not set `event_id`. The Resend webhook looks up logs by `customer_email + subject` instead. This is a known limitation — if Resend events are delayed, the lookup may fail to match.
- **Square sync is one-directional**: Products import from Square → Route Commerce, but product edits in Route Commerce do not push back to Square. - **Square sync is one-directional**: Products import from Square → Route Commerce, but product edits in Route Commerce do not push back to Square.
- **SMS opt-in defaults to `FALSE`**: `communication_contacts.sms_opt_in` defaults to `false`. Users must explicitly opt in for SMS campaigns. - **SMS opt-in defaults to `FALSE`**: `communication_contacts.sms_opt_in` defaults to `false`. Users must explicitly opt in for SMS campaigns.
- **`brand_id: null` for platform admins**: `getAdminUser()` returns `brand_id: null` for `platform_admin` role. Always pass explicit `brandId` to server action functions. - **`brand_id: null` for platform admins**: `getAdminUser()` returns `brand_id: null` for `platform_admin` role. Always pass explicit `brandId` to server action functions.
- **Square webhook signature mismatch**: The Square webhook handler uses `crypto.createHmac("sha256", signatureKey)` but Square sends the signature as base64-encoded. Verify signature encoding matches Square's documentation before enabling Square webhooks in production. - **Square webhook signature mismatch**: The Square webhook handler uses `crypto.createHmac("sha256", signatureKey)` but Square sends the signature as base64-encoded. Verify signature encoding matches Square's documentation before enabling Square webhooks in production.
- **Stripe webhook reliability**: Stripe webhook delivery is not guaranteed to be in-order or immediate. The webhook handler is idempotent — it can safely receive duplicate events. Consider implementing a webhook event log table to track processed event IDs for deduplication. - **Stripe webhook reliability**: Stripe webhook delivery is not guaranteed to be in-order or immediate. The webhook handler is idempotent — it can safely receive duplicate events. Consider implementing a webhook event log table to track processed event IDs for deduplication.
- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct Supabase queries to these tables in client-side code. - **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct SQL to these tables in client-side code.
### Future Work (Non-Blocking) ### Future Work (Non-Blocking)
- **Cursor-based pagination**: Replace offset-based pagination (`page * limit`) with cursor-based pagination for large datasets (orders, contacts) - **Cursor-based pagination**: Replace offset-based pagination (`page * limit`) with cursor-based pagination for large datasets (orders, contacts)
@@ -344,11 +332,9 @@ Environment variables are version-controlled in Vercel. Restore a previous versi
| Role | Contact | | Role | Contact |
|------|---------| |------|---------|
| Platform Admin | Set via Supabase Auth dashboard | | Platform Admin | First Neon Auth user becomes the platform admin via `provision-admin.ts` |
| Kyle Martinez | Primary contact — Route Commerce owner | | Kyle Martinez | Primary contact — Route Commerce owner |
| Supabase Support | supabase.com/support | | Neon Support | neon.tech/support |
| Vercel Support | vercel.com/support | | Vercel Support | vercel.com/support |
For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button. For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button.
For Supabase production incidents: **supabase.com/dashboard** → project → "Support" tab.
+14 -14
View File
@@ -15,16 +15,16 @@ npm run dev
## Configuration ## Configuration
### 1. Clerk Authentication ### 1. Neon Auth (Better Auth)
1. Sign up at [Clerk](https://clerk.com) 1. Set up Neon Auth on your Neon project (`neonctl neon-auth init`)
2. Create a new application 2. Copy keys to `.env.local`:
3. Copy keys to `.env.local`:
``` ```
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx NEON_AUTH_BASE_URL=https://xxx.neonauth.io
CLERK_SECRET_KEY=sk_test_xxx NEON_AUTH_COOKIE_SECRET=<openssl rand -base64 32>
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
``` ```
4. Configure middleware in `src/proxy.ts` 3. Configure middleware in `src/proxy.ts`
### 2. Stripe Payments ### 2. Stripe Payments
@@ -37,15 +37,15 @@ npm run dev
STRIPE_WEBHOOK_SECRET=whsec_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx
``` ```
### 3. Supabase Database ### 3. Database (Postgres / Neon)
1. Create project at [Supabase](https://supabase.com) The app talks to Postgres directly via `pg` (no Supabase, no REST gateway). Point `DATABASE_URL` at any reachable Postgres — Neon, Supabase (the underlying Postgres), RDS, etc. The Supabase JS client and PostgREST are not used.
1. Provision a Postgres database (Neon recommended — it also hosts Neon Auth)
2. Run migrations: `npm run migrate` 2. Run migrations: `npm run migrate`
3. Update `.env.local` with: 3. Update `.env.local` with:
``` ```
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
SUPABASE_SERVICE_ROLE_KEY=xxx
``` ```
### 4. Optional Services ### 4. Optional Services
@@ -74,7 +74,7 @@ Ensure environment variables are set before deployment.
## Key Files ## Key Files
- `src/proxy.ts` - Clerk middleware - `src/proxy.ts` - Neon Auth (Better Auth) middleware
- `src/lib/stripe-billing.ts` - Stripe integration - `src/lib/stripe-billing.ts` - Stripe integration
- `src/lib/analytics.ts` - PostHog analytics - `src/lib/analytics.ts` - PostHog analytics
- `src/lib/sentry.ts` - Sentry error tracking - `src/lib/sentry.ts` - Sentry error tracking
@@ -82,7 +82,7 @@ Ensure environment variables are set before deployment.
- `src/components/onboarding/OnboardingFlow.tsx` - User onboarding - `src/components/onboarding/OnboardingFlow.tsx` - User onboarding
- `src/components/referral/ReferralSystem.tsx` - Referral tracking - `src/components/referral/ReferralSystem.tsx` - Referral tracking
- `src/components/changelog/ChangelogFeed.tsx` - Product updates - `src/components/changelog/ChangelogFeed.tsx` - Product updates
- `supabase/migrations/` - Database schema - `db/migrations/` - Database schema (applied via `scripts/migrate.js`)
## Scripts ## Scripts
+9 -12
View File
@@ -17,7 +17,7 @@ Route Commerce helps produce brands run their wholesale operations:
## Tech Stack ## Tech Stack
- **Framework:** Next.js 16 (App Router) - **Framework:** Next.js 16 (App Router)
- **Database:** Supabase (Postgres + Auth + RLS) - **Database:** Postgres (direct via `pg` + Drizzle) with Neon Auth (Better Auth)
- **Payments:** Stripe + Square - **Payments:** Stripe + Square
- **Email/SMS:** Resend - **Email/SMS:** Resend
- **Styling:** Tailwind CSS v4 - **Styling:** Tailwind CSS v4
@@ -37,10 +37,8 @@ npm install
Create a `.env.local` file with: Create a `.env.local` file with:
```bash ```bash
# Supabase # Database (Postgres)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Stripe # Stripe
STRIPE_SECRET_KEY=sk_test_... STRIPE_SECRET_KEY=sk_test_...
@@ -56,10 +54,9 @@ RESEND_API_KEY=re_...
### 3. Set up the database ### 3. Set up the database
Link your Supabase project, then push migrations: The migrations live in `db/migrations/` and are applied via the `pg` driver (no Supabase CLI):
```bash ```bash
supabase link --project-ref <your-project-ref>
npm run migrate npm run migrate
``` ```
@@ -75,7 +72,7 @@ npm run migrate:one 83
npm run dev npm run dev
``` ```
Open [http://localhost:3000](http://localhost:3000). The dev server auto-runs `fix-agents.js` to patch Next.js App Router agent issues. Open [http://localhost:4000](http://localhost:4000). The dev server auto-runs `fix-agents.js` to patch Next.js App Router agent issues. (The dev script binds to `0.0.0.0:4000`; override with `npm run dev -- -p <port>` if you need a different port.)
### 5. Dev auth bypass ### 5. Dev auth bypass
@@ -111,7 +108,7 @@ src/
│ ├── admin/ # Admin-specific components │ ├── admin/ # Admin-specific components
│ └── storefront/ # Storefront components │ └── storefront/ # Storefront components
└── lib/ # Utilities, auth, feature flags, formatting └── lib/ # Utilities, auth, feature flags, formatting
supabase/ db/
└── migrations/ # SQL migrations (numbered sequentially) └── migrations/ # SQL migrations (numbered sequentially)
``` ```
@@ -253,7 +250,7 @@ CRON_SECRET=... # Bearer token to secure cron endpoints (generate
```bash ```bash
# Run abandoned cart with verbose output # Run abandoned cart with verbose output
curl -X POST http://localhost:3000/api/email-automation/abandoned-cart \ curl -X POST http://localhost:4000/api/email-automation/abandoned-cart \
-H "Authorization: Bearer local-dev-secret" \ -H "Authorization: Bearer local-dev-secret" \
-H "Content-Type: application/json" -H "Content-Type: application/json"
@@ -315,8 +312,8 @@ curl -X POST https://route-commerce-platform.vercel.app/api/email-automation/wel
## Notes ## Notes
- All database writes go through **server actions** (`src/actions/`), not the Supabase JS client directly - All database writes go through **server actions** (`src/actions/`) over a shared `pg` `Pool` from `src/lib/db.ts` — no Supabase JS client, no REST gateway
- Dev mode bypasses Supabase auth via `dev_session` cookie — never use this in production - Dev mode bypasses Neon Auth via `dev_session` cookie — never use this in production
- Brand-scoped data is enforced at the application layer via `p_brand_id` parameters in SECURITY DEFINER RPCs - Brand-scoped data is enforced at the application layer via `p_brand_id` parameters in SECURITY DEFINER RPCs
- Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()` - Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()`
- Migration files are numbered sequentially — never reuse numbers - Migration files are numbered sequentially — never reuse numbers
+12 -1
View File
@@ -4,6 +4,8 @@
**Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out **Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out
all Supabase references from the auth/admin path. all Supabase references from the auth/admin path.
> **Note (2026-06-25):** Auth has since moved from Auth.js (NextAuth v5) to **Neon Auth (Better Auth)** — see `CLAUDE.md` for the current architecture. The Supabase purge described below is complete: `supabase/` directory deleted, `@supabase/*` deps removed from `package.json`, `next.config.ts` / `vercel.json` / `eslint.config.mjs` cleaned of Supabase hostnames and ignore patterns, and the remaining `*.supabase.co` references are limited to historical/educational comments in `MEMORY.md` and `docs/`.
--- ---
## What was built ## What was built
@@ -171,7 +173,7 @@ src/actions/ai/preferences.ts
1. **Provision a `pg` pool in production.** `DATABASE_URL` must be 1. **Provision a `pg` pool in production.** `DATABASE_URL` must be
set in the hosting dashboard (same env var as set in the hosting dashboard (same env var as
`supabase/push-migrations.js` uses). `scripts/migrate.js` uses).
2. **Apply migration 204** if not already applied — adds 2. **Apply migration 204** if not already applied — adds
`email`, `auth_provider`, `auth_subject` columns to `admin_users` `email`, `auth_provider`, `auth_subject` columns to `admin_users`
and the `get_admin_user_for_session` RPC. and the `get_admin_user_for_session` RPC.
@@ -192,6 +194,15 @@ src/actions/ai/preferences.ts
7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from 7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from
`package.json`** (last step once no file imports them). `package.json`** (last step once no file imports them).
### Resolution (2026-06-25)
Follow-ups 6 and 7 (and the data-fetching migration of all 33 files) are
complete. The codebase is now Supabase-free at the source-code level —
`grep -rln '@supabase\|rest/v1' src/` returns zero matches. The only
remaining mentions of "Supabase" are in this report, `MEMORY.md`, and a
handful of historical `docs/superpowers/{plans,specs}/` files, all kept
as historical record of the migration.
--- ---
## Commands ## Commands
+12 -49
View File
@@ -23,38 +23,22 @@
*/ */
import "server-only"; import "server-only";
import { Pool, type PoolClient } from "pg";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import * as schema from "./schema"; import * as schema from "./schema";
import { getPool, withTx } from "@/lib/db";
type Schema = typeof schema; type Schema = typeof schema;
export type Db = NodePgDatabase<Schema>; export type Db = NodePgDatabase<Schema>;
let _pool: Pool | null = null; /**
* The Drizzle layer reuses the shared `pg` `Pool` from `@/lib/db` so the
function getPool(): Pool { * app connects to Postgres through a single pool. Connection settings
if (_pool) return _pool; * (`PG_POOL_MAX`, `PG_POOL_IDLE_MS`, `PG_POOL_CONN_TIMEOUT_MS`,
const connectionString = process.env.DATABASE_URL; * `DATABASE_URL`) live in one place. See `src/lib/db.ts` for the config.
if (!connectionString) { *
throw new Error( * The `withBrand` and `withPlatformAdmin` helpers reuse `withTx` from
"DATABASE_URL is not set. Add it to .env.local (see .env.example).", * `@/lib/db` so the BEGIN/COMMIT/ROLLBACK handling has a single home.
); */
}
_pool = new Pool({
connectionString,
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",
10,
),
allowExitOnIdle: false,
});
_pool.on("error", (err) => {
console.error("[db] idle client error", err);
});
return _pool;
}
/** /**
* Run `fn` with a Drizzle client. No brand context is set — the caller * Run `fn` with a Drizzle client. No brand context is set — the caller
@@ -83,7 +67,7 @@ export async function withBrand<T>(
brandId: string, brandId: string,
fn: (db: Db) => Promise<T>, fn: (db: Db) => Promise<T>,
): Promise<T> { ): Promise<T> {
return runInTransaction(async (client) => { return withTx(async (client) => {
// set_config(setting, value, is_local) — is_local=true makes it // set_config(setting, value, is_local) — is_local=true makes it
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never // transaction-local so it auto-resets at COMMIT/ROLLBACK and never
// leaks across pooled connections. // leaks across pooled connections.
@@ -103,7 +87,7 @@ export async function withBrand<T>(
export async function withPlatformAdmin<T>( export async function withPlatformAdmin<T>(
fn: (db: Db) => Promise<T>, fn: (db: Db) => Promise<T>,
): Promise<T> { ): Promise<T> {
return runInTransaction(async (client) => { return withTx(async (client) => {
await client.query("SELECT set_config('app.current_brand_id', '', true)"); await client.query("SELECT set_config('app.current_brand_id', '', true)");
await client.query("SELECT set_config('app.platform_admin', 'true', true)"); await client.query("SELECT set_config('app.platform_admin', 'true', true)");
const db = drizzle(client, { schema }); const db = drizzle(client, { schema });
@@ -111,25 +95,4 @@ export async function withPlatformAdmin<T>(
}); });
} }
async function runInTransaction<T>(
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await getPool().connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
try {
await client.query("ROLLBACK");
} catch {
// ignore secondary rollback failure
}
throw err;
} finally {
client.release();
}
}
export { schema }; export { schema };
@@ -0,0 +1,89 @@
-- 0091_dashboard_summary_rpc.sql
--
-- Dashboard summary RPC for the mobile-first admin v2 dashboard
-- (`/admin/v2`).
--
-- Returns a single JSONB document that powers the four stat cards +
-- the "Today's stops" timeline + the 7-day mini-chart:
--
-- {
-- "orders_today": <int>, -- count of orders placed today
-- "revenue_today": <int cents>, -- sum of total_cents (excl. canceled)
-- "pending_fulfillment": <int>, -- count of orders awaiting pickup/ship
-- "stops_today": <int>, -- count of stops scheduled for today
-- "orders_last_7_days": [{date, count}] -- 7-element array, oldest → today
-- }
--
-- Schema notes (vs. the plan's draft):
-- * `orders` uses `placed_at` (TIMESTAMPTZ) — not `created_at`
-- * `orders.status` is the legacy enum: 'pending' | 'confirmed' |
-- 'fulfilled' | 'canceled' (not the v2 vocabulary 'placed'/'ready')
-- * `orders.total_cents` is INTEGER (NOT NULL DEFAULT 0 in 0001) — no
-- column-add step is required
-- * `stops` has a `date DATE` column (not `scheduled_at`); the legacy
-- stops page (and v2 stops page) both key off `stops.date`
--
-- SECURITY DEFINER so the v2 server action can call it without depending
-- on RLS; brand scoping is enforced inside the function via the
-- `p_brand_id` parameter (NULL → platform_admin "all brands" scope).
-- Re-runs are safe — this is `CREATE OR REPLACE FUNCTION`.
CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
result JSONB;
brand_filter TEXT;
BEGIN
-- The brand filter is the same text fragment for every SELECT in this
-- function. NULL = "all brands" (platform_admin scope); non-NULL =
-- scope to a single brand.
IF p_brand_id IS NULL THEN
brand_filter := '';
ELSE
brand_filter := format(' AND brand_id = %L', p_brand_id);
END IF;
EXECUTE format(
$q$
SELECT jsonb_build_object(
'orders_today', (
SELECT COUNT(*) FROM orders
WHERE placed_at::date = CURRENT_DATE %s
),
'revenue_today', (
SELECT COALESCE(SUM(total_cents), 0)::bigint FROM orders
WHERE placed_at::date = CURRENT_DATE
AND status <> 'canceled' %s
),
'pending_fulfillment', (
SELECT COUNT(*) FROM orders
WHERE status IN ('pending', 'confirmed') %s
),
'stops_today', (
SELECT COUNT(*) FROM stops
WHERE "date" = CURRENT_DATE %s
),
'orders_last_7_days', (
SELECT COALESCE(jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0)) ORDER BY d), '[]'::jsonb)
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d
LEFT JOIN (
SELECT placed_at::date AS day, COUNT(*) AS cnt
FROM orders
WHERE placed_at >= CURRENT_DATE - INTERVAL '7 days' %s
GROUP BY 1
) o ON o.day = d::date
)
)
$q$,
brand_filter, brand_filter, brand_filter, brand_filter, brand_filter
) INTO result;
RETURN result;
END;
$$;
COMMENT ON FUNCTION get_dashboard_summary(UUID) IS
'Returns a single JSONB document with the four dashboard stat counts + 7-day order history. p_brand_id = NULL means platform_admin scope (all brands).';
-1
View File
@@ -2,7 +2,6 @@
-- Stops: 269 | Locations: 40 -- Stops: 269 | Locations: 40
-- Preferred: npx tsx scripts/import-tuxedo-stops.ts -- Preferred: npx tsx scripts/import-tuxedo-stops.ts
-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql -- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql
-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql
BEGIN; BEGIN;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,538 @@
# Admin Mobile PWA — Design Spec
- **Date:** 2026-06-17
- **Status:** Draft, awaiting user review
- **Author:** Grok
- **Branch:** `main`
- **Scope:** Admin dashboard only (`/admin/*`), with full PWA + offline support
---
## Context
Route Commerce is used heavily on phones — the team operates out of offices, trucks, warehouses, and outdoor pickup stops. The current admin is mobile-responsive but **not mobile-first**, the PWA is half-built (manifest + service worker exist, but no icons, no install prompt wired up, no offline page, no service worker registration), and Apple HIG accessibility-tier legibility is not the design baseline.
**Symptoms today:**
- `public/icons/` and `public/screenshots/` directories don't exist; manifest references 8 icon files and 2 screenshots that all 404
- `registerServiceWorker()` is exported but never called
- `PWAInstallPrompt` is never mounted in the tree
- `apple-touch-icon.png` and `favicon.ico` are referenced but missing
- Service worker references `/offline` and `/og-default.jpg` that don't exist
- `themeColor` in `viewport` (`#0a0a0a`) mismatches manifest (`#1a4d2e`)
- `AdminSidebar` uses `lg:pl-60` — on phones it becomes an overlay drawer; no bottom nav, no thumb-zone ergonomics
- Body text is 16pt; tap targets are 4044pt; status colors don't all clear WCAG AA on the warm cream background
- No offline mutation queue — losing signal mid-fulfillment is a real operational problem
## Goals
1. **Ship a real, installable PWA** — manifest validates, all icons render, splash + app icon work on iOS Add to Home Screen, themed status bar.
2. **Mobile-first admin shell** — bottom tab bar (thumb zone) for the 4 most-used surfaces, drag-up "More" sheet for everything else, sticky top bar with brand selector + search + profile.
3. **HIG Accessibility-tier legibility** — 18pt body, 500 weight, AAA contrast, 56pt+ tap targets, bold weights throughout. Readable in direct sunlight, tappable with gloves.
4. **Read + queue mutations offline** — operators in dead zones can still see data and queue actions (mark ready, mark picked up, change stop status, adjust stock). Optimistic UI with a visible sync status; replays on reconnect with last-write-wins conflict resolution.
5. **Production-optimized** — proper cache headers, code-split admin shell, SW cache versioning, no layout shift on font load, Lighthouse PWA + mobile a11y gates in CI.
## Non-Goals
- Redesigning marketing pages (landing, pricing, blog). They stay as-is.
- Redesigning the public storefronts. They stay as-is.
- Push notifications / Web Push. The SW has a `push` handler scaffolded but it's not in scope; requires VAPID setup and a separate decision.
- Wholesale portal mobile redesign. Tabled for a future spec.
- Real-time collaboration (websockets / live cursors). Out of scope.
- CRDT-style conflict resolution. We use last-write-wins keyed on `updated_at`.
## Surfaces in scope
- `/admin` (Dashboard)
- `/admin/orders` + `/admin/orders/[id]`
- `/admin/stops`
- `/admin/products`
- The admin shell that wraps all of the above
- PWA install, splash, offline page, and SW behavior across the whole app
---
## 1. Aesthetic direction & design system
### Direction: "HIG-First Field Almanac"
Apple HIG Accessibility-tier legibility is the **floor**; the existing editorial identity (Fraunces + Manrope + Fragment Mono, "Field Almanac" palette already hinted at in `globals.css`) pushed harder is the **ceiling**. The result feels like a high-end tool someone made for themselves — confident, large, warm, with serif labels and serious typography weight. Distinctive without being precious.
### Type scale (HIG Accessibility-tier)
| Token | Size / Weight | Use |
| ---------------- | ----------------------------------- | -------------------------------------- |
| `text-display` | 32pt / Fraunces 600 | Page titles (Dashboard, Orders, etc.) |
| `text-h1` | 24pt / Fraunces 600 | Section headers |
| `text-h2` | 19pt / Manrope 700 | Card titles, list primary |
| `text-body` | 18pt / Manrope 500 | All body text |
| `text-label` | 15pt / Manrope 600 / +0.02em | Button text, field labels, status pills|
| `text-mono` | 14pt / Fragment Mono 400 | Order IDs, totals, tracking numbers |
| `text-meta` | 13pt / Manrope 600 | Timestamps, helper text |
Body bumped from 16 → 18pt; weight bumped from 400 → 500 for sun legibility.
### Color tokens (AAA contrast where text appears)
| Token | Value | Use | Contrast on `bg` / `surface` |
| ------------- | --------- | --------------------------------------- | ---------------------------- |
| `bg` | `#ffffff` | App background | — |
| `surface` | `#faf8f5` | Page background (warm cream) | — |
| `surface-2` | `#f5f5f7` | Cards, raised surfaces | — |
| `surface-3` | `#e8e8ed` | Hover / pressed / skeletons | — |
| `text` | `#1d1d1f` | Primary text | AAA on all 4 surfaces |
| `text-muted` | `#424245` | Secondary text | AAA on all 4 surfaces |
| `text-faint` | `#5e5e63` | Tertiary / meta | AA on all 4 surfaces (≥ 5.28 : 1) |
| `accent` | `#14532d` | Primary actions, brand | AAA on `bg` (9.11), `surface` (8.59), `surface-2` (8.37), `surface-3` (7.46) |
| `accent-2` | `#166534` | Hover state (button background) | White text on `accent-2` = 7.13 : 1 (AAA) |
| `danger` | `#7f1d1d` | Errors, destructive | AAA on `bg` (10.02), `surface` (9.45), `surface-2` (9.20), `surface-3` (8.20) |
| `warning` | `#5e2a04` | Warnings, low stock | AAA on `bg` (11.61), `surface` (10.95), `surface-2` (10.66), `surface-3` (9.51) |
| `success` | `#14532d` | Success, completed | Mirrors `accent` |
| `info` | `#1e3a8a` | Informational | AAA on all 4 surfaces (8.4810.36) |
**Status text on its `-soft` pill background** is also AAA — the dark status text (e.g. `#14532d`) sits on a light tinted fill (e.g. `#dcfce7`) with ≥ 7:1 contrast. The full matrix (5 status × 4 surfaces + 5 status-on-soft + 4 body-text × 4 surfaces + 1 meta × 4 surfaces + 1 button-text + 5 soft-distinct = 35 assertions) is enforced by `src/lib/__tests__/design-tokens.test.ts`.
`text-faint` is the only token below AAA — used only for de-emphasized meta text where a stronger color would compete with primary content. All token combinations verified by hand calculation; the test suite in Section 5 enforces this in CI.
### Spacing (8pt grid)
`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64`
### Touch targets (HIG Accessibility + gloves)
- All interactive elements: **56pt min height** (HIG default 44pt → 56pt for gloves)
- Tab bar items: 60pt tall, icon 28pt + label 12pt
- List row primary action: full-row 72pt tap zone
- Bottom sheet drag handle: 56pt wide × 5pt tall
- FAB: 64pt diameter
- Minimum spacing between adjacent tap targets: 8pt (HIG recommends 16pt for gloves; we use 12pt to keep density reasonable)
### Motion
- All motion respects `prefers-reduced-motion`
- Card list items stagger in (16ms delay each, max 240ms total) on first mount
- Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out)
- StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out)
- Pull-to-refresh: custom indicator showing last-synced time in mono
- Default easing: `cubic-bezier(0.32, 0.72, 0, 1)` (Apple's standard)
---
## 2. Architecture & data flow
### Mobile admin shell
```
┌─────────────────────────────────┐
│ TopBar: [brand▼] [🔍] [👤] │ ← 56pt, sticky
├─────────────────────────────────┤
│ │
│ Page content │ ← scrollable, max 720pt content width
│ (card-stacked) │
│ │
├─────────────────────────────────┤
│ [Home][Orders][Stops][Products] │ ← bottom tab bar, 60pt, fixed
│ [More] │
└─────────────────────────────────┘
```
- **Bottom tab bar** (always visible, thumb zone): Dashboard · Orders · Stops · Products · **More**
- **"More" sheet** (drag-up full screen, 90% max-height): Communications, Pickup, Shipping, Reports, Analytics, Settings, etc. — every other admin link, organized into sections.
- **TopBar** (sticky): brand selector (left), search button (center, opens full-screen search), profile menu (right).
- **No desktop sidebar on mobile** — `AdminSidebar` is hidden below `lg`. Above `lg`, sidebar is unchanged.
- A resize-aware `useMediaQuery('(min-width: 1024px)')` swaps between layouts inside `AdminShell`.
### New shared components
| File | Purpose |
| ------------------------------------------------------- | ------------------------------------------------------------------ |
| `src/components/admin/AdminShell.tsx` | Server-component shell that picks nav based on breakpoint |
| `src/components/admin/MobileTabBar.tsx` | Client component, fixed bottom, active state from `usePathname()` |
| `src/components/admin/MoreSheet.tsx` | Drag-up sheet for secondary nav, uses native `<dialog>` + View Transitions |
| `src/components/admin/PageHeader.tsx` | Consistent page title + actions row |
| `src/components/admin/StatusPill.tsx` | Color-coded pill (uses new status tokens) |
| `src/components/admin/EmptyState.tsx` | Used by all 4 key pages |
| `src/components/admin/CardList.tsx` + `CardListItem` | Replaces tables on mobile |
| `src/components/admin/StickyActionBar.tsx` | Primary action pinned to bottom of detail screens |
| `src/components/admin/OfflineBanner.tsx` | Top-of-screen offline indicator with pending sync count |
### Routing
No URL changes. The same `/admin/orders`, `/admin/stops`, `/admin/products` routes serve both layouts; the shell is purely presentational. Server components stay server components — the mobile shell is a thin client wrapper around them.
### Data flow
| Page | Server source | Mobile client behavior |
| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------- |
| Dashboard | `get_dashboard_summary(brand_id)` (new RPC) | Cards, 2-col grid on tablet, 1-col on phone |
| Orders list | `get_orders_for_brand(brand_id, …)` (existing) | CardList with status pill, customer, total, time |
| Order detail | `get_order_detail(order_id)` (existing) | Header card + line items + timeline + StickyActionBar |
| Stops | `get_stops_for_brand(brand_id, …)` (existing) | CardList with time, address, status, "navigate" action |
| Products | `get_products_for_brand(brand_id, …)` (existing) | CardList with image, name, price, stock |
### Offline mutation queue
- `src/lib/offline/queue.ts` — IndexedDB store keyed by `clientActionId`
- `src/lib/offline/sync.ts` — replays queued actions on `online` event with exponential backoff
- Server actions stay the source of truth; the queue records `actionName + payload + clientActionId + createdAt` and posts them when online
- Each queued action shows in the UI with a "pending / synced / conflict" badge in `StickyActionBar`
- `OfflineBanner` shows when `navigator.onLine === false` and surfaces pending sync count
- **Conflict resolution:** last-write-wins keyed on `updated_at` returned by the server. After each sync, the client refetches the affected entity and merges.
- **Backoff:** 1s, 4s, 16s, 60s, 5min; give up after 5 attempts, surface a "Manual retry" button on the conflicting item.
- **Idempotency:** every queued action carries a `clientActionId` (uuid v4 generated at enqueue time). The server's RPC layer is updated to accept and dedupe on this id.
### New RPC (for dashboard)
```sql
CREATE OR REPLACE FUNCTION get_dashboard_summary(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
result JSONB;
BEGIN
SELECT jsonb_build_object(
'orders_today', (SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE),
'revenue_today', (SELECT COALESCE(SUM(total), 0) FROM orders WHERE brand_id = p_brand_id AND created_at::date = CURRENT_DATE AND status NOT IN ('cancelled')),
'pending_fulfillment', (SELECT COUNT(*) FROM orders WHERE brand_id = p_brand_id AND status IN ('placed', 'ready')),
'stops_today', (SELECT COUNT(*) FROM stops WHERE brand_id = p_brand_id AND scheduled_at::date = CURRENT_DATE),
'orders_last_7_days', (
SELECT jsonb_agg(jsonb_build_object('date', d::date, 'count', COALESCE(o.cnt, 0)))
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') d
LEFT JOIN (
SELECT created_at::date AS day, COUNT(*) AS cnt
FROM orders
WHERE brand_id = p_brand_id AND created_at >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY 1
) o ON o.day = d::date
)
) INTO result;
RETURN result;
END;
$$;
```
---
## 3. PWA, offline, and production hardening
### PWA — what's broken and what we're fixing
| Issue today | Fix |
| ------------------------------------------------------ | ------------------------------------------------------------------ |
| `public/icons/` doesn't exist | Generate all 8 sizes (72512) PNG + maskable + badge + 3 shortcut icons |
| `public/screenshots/` doesn't exist | Generate `dashboard.png` (1280×720) and `mobile-storefront.png` (390×844) |
| `apple-touch-icon.png` missing | Generate 180×180 + 167×167 + 152×152 |
| `favicon.ico` missing | Generate from SVG |
| `sw.js` references `/offline` page that doesn't exist | Create `public/offline.html` (clean cream HIG-style page) |
| `sw.js` references `/og-default.jpg` (only .svg exists)| Update ref to `/og-default.svg` |
| `registerServiceWorker()` exported but never called | Call it from `src/components/Providers.tsx` (client side) |
| `PWAInstallPrompt` never mounted | Mount in `src/app/admin/layout.tsx` (admin is the installable surface) |
| `themeColor` mismatch (`#0a0a0a` vs `#1a4d2e`) | Standardize on `#166534` (forest-800) |
| Missing PWA meta tags | Add `apple-mobile-web-app-capable`, `application-name`, `mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style` |
| `viewport.maximumScale = 5` but no `viewportFit: cover`| Add `viewportFit: 'cover'` for notched devices |
### Service worker rewrite
Replace `public/sw.js` with a versioned, two-cache SW:
- **App shell cache** (e.g. `rc-shell-v3`): pre-caches `/`, `/admin`, `/manifest.json`, fonts, icons, offline page — install-time `cache.addAll(…)`.
- **Data cache** (e.g. `rc-data-v3`): stale-while-revalidate for `/api/orders`, `/api/stops`, `/api/products`, `/api/dashboard` — read offline, refetch in background.
- **Cache strategies:**
- Navigations → network-first, fall back to cache, fall back to `/offline.html`
- Static assets (`/_next/static/*`, `/icons/*`, `/screenshots/*`) → cache-first
- API GETs (`/api/*` non-mutation) → stale-while-revalidate
- API mutations (`POST`/`PATCH`/`DELETE`) → never cached, pass-through; offline behavior handled by the client queue
- **Versioning:** `CACHE_NAME` bumped on each release; activate handler deletes old caches; `skipWaiting()` + `clients.claim()` to roll out updates fast.
- **Logging:** all `console.log` calls guarded by `process.env.NODE_ENV !== 'production'`.
### Production hardening
- `next.config.ts` — add `headers()` for `Cache-Control: public, max-age=31536000, immutable` on `/_next/static/*` and `/icons/*`
- Font preloading: `next/font` already inlines the CSS — verify the `display: swap` strategy stays
- Image optimization: existing `next/image` config; add `priority` to the admin logo in the top bar
- Code splitting: admin layout stays in its own chunk; mobile shell is a dynamic import gated on viewport
- No layout shift on font load: `next/font` reserves metric-based space
### Files touched in this section
- `public/manifest.json` (theme_color + new screenshot paths)
- `public/sw.js` (full rewrite)
- `public/offline.html` (new)
- `public/icons/*.png` (8 + maskable + badge + 3 shortcuts = 13 new files)
- `public/screenshots/*.png` (2 new)
- `public/apple-touch-icon.png` + variants (3 new)
- `public/favicon.ico` (1 new)
- `src/app/layout.tsx` (add PWA meta + register SW via Providers)
- `src/components/Providers.tsx` (call `registerServiceWorker()` on mount)
- `src/app/admin/layout.tsx` (mount `PWAInstallPrompt`)
- `next.config.ts` (cache headers for static assets)
---
## 4. Page-by-page layouts
All pages share: top bar, page header (title + primary action), card-stacked body (no tables on phones), bottom tab bar. The desktop layout is unchanged above `lg`.
### Dashboard (`/admin`)
- **Page header:** "Today" (Fraunces 32pt) + date in mono + brand selector (in top bar)
- **Stat row** (2×2 grid on phone, 4×1 on tablet+): Orders Today · Revenue · Pending Fulfillment · Stops Today — each card is `surface-2` with a big Fraunces number, Manrope label, and a 7-day sparkline in mono
- **"Needs you" card** (only if pending actions > 0): top-of-page warning card with count + "View all" CTA
- **Today's stops timeline:** vertical timeline of upcoming stops, each row tappable
- **Aesthetic:** calm, editorial, "open the laptop on the couch" feel. Numbers in Fraunces do the talking.
### Orders list (`/admin/orders`)
- **Page header:** "Orders" + filter button (opens bottom sheet) + "+ New order"
- **Filter sheet:** status, date range, customer, stop — drag-up sheet, sticky apply bar
- **Card list** (one per order):
- Top row: status pill (left) · order # in mono · time-ago (right)
- Middle: customer name (Manrope 700 19pt) + item count ("4 items")
- Bottom: total (Fraunces 22pt) · arrow chevron
- Whole row: 72pt tap zone
- **Empty state:** "No orders yet" + illustration + "+ New order" CTA
- **Pull-to-refresh** at the top
### Order detail (`/admin/orders/[id]`)
- **Header card:** customer name (Fraunces 28pt), phone + email (tap-to-call / tap-to-email), order # in mono, status pill
- **Line items card:** each item is a sub-row — image (56pt square), name, qty × price, fulfillment icon (pickup vs ship)
- **Fulfillment timeline card:** horizontal stepper (Placed → Ready → Picked up) with timestamps in mono
- **Customer notes card** (if present): quoted, italic
- **StickyActionBar** (bottom, above tab bar): primary action changes by status:
- Placed → "Mark Ready" (forest-800 fill, full width, 56pt)
- Ready → "Mark Picked Up" (forest-800 fill)
- Picked up → "View Receipt" (outlined)
- **Secondary actions** (refund, edit, contact customer) live in a top-right "•••" menu
- **Offline pending badge:** if the order was just updated offline, the action bar shows "Pending sync" with a tiny spinner until the queue replays
### Stops (`/admin/stops`)
- **Page header:** "Stops" + date picker (large, tappable) + "+ New stop"
- **Time-grouped sections:** "Morning" / "Afternoon" / "Evening" with sticky section headers (Manrope 700 15pt, surface-50 bg, 8pt vertical padding)
- **Stop card:**
- Time (Fraunces 28pt, left) · address (Manrope 600 17pt) · customer count
- Status pill + "Navigate" icon button (opens maps app via `navigator.share` fallback to URL)
- Inline status changer: tap status pill → bottom sheet with status options
- Whole row: 88pt to accommodate time + address comfortably
- **Empty state:** "No stops scheduled" + "+ New stop"
### Products (`/admin/products`)
- **Page header:** "Products" + search input (always visible, prominent) + "+ New product"
- **Card list:**
- Image (64pt square, rounded-2xl, object-cover) · name (Manrope 700 17pt) · price (Fraunces 20pt)
- Stock row: "142 in stock" or "⚠ 8 left" or "✕ Out of stock" — color + icon, never color alone
- "Adjust stock" via long-press → bottom sheet with +/ stepper
- **Filter chips above list:** All · In stock · Low · Out · Hidden
- **Empty state:** "No products" + "+ New product"
### Motion (per page)
- Card list items stagger in (16ms delay each, max 240ms total) on first mount
- Tab bar icon bounce on tap (scale 0.85 → 1.05 → 1, 200ms ease-out)
- Pull-to-refresh: custom indicator showing last-synced time
- StickyActionBar slides up on mount (translate-y 16 → 0, 200ms ease-out)
- All motion respects `prefers-reduced-motion`
### Aesthetic summary
Editorial-meets-field. Fraunces for numbers, prices, and the few serif headlines; Manrope for everything else; Fragment Mono for IDs, totals, timestamps. Warm cream surfaces, deeply saturated status colors, generous breathing room. Reads like a high-end tool someone made for themselves — not a generic admin template.
---
## 5. Testing & verification
### Unit tests (Vitest)
- `src/lib/offline/queue.test.ts` — enqueue, dequeue, persist across page reload, idempotency on `clientActionId`
- `src/lib/offline/sync.test.ts` — replays on `online` event, exponential backoff (1s, 4s, 16s, 60s, 5min, give up at 5 attempts), conflict surfacing
- `src/lib/format-date.test.ts` — extend for new relative formats ("just now", "5m ago", "2h ago", "yesterday")
- `src/lib/__tests__/design-tokens.test.ts` — asserts all token combinations meet WCAG contrast on the surfaces they actually render on (body text on all 4 surfaces at AAA; status text on `bg` + `surface` + their `-soft` pill backgrounds at AAA; `text-faint` on all 4 surfaces at AA; `accent-2` as a button background with white text at AAA); fails CI if a new color regresses
### Component tests (Vitest + Testing Library)
- `MobileTabBar` — active state, more-sheet trigger, keyboard nav
- `StickyActionBar` — pending badge, conflict state
- `OfflineBanner` — appears on `offline` event, hidden on `online`
- `StatusPill` — every status × every color combination renders the right aria-label
- `MoreSheet` — opens via More button, traps focus, closes on Escape and backdrop click, restores focus on close
**UI primitives:** No dialog/sheet library (vaul, radix, headlessui) is currently in the project. `MoreSheet` and the various bottom sheets (filter, status changer, stock stepper) use the native `<dialog>` element with `showModal()` + the View Transitions API for open/close animation. This is a deliberate choice to keep the bundle small for the PWA. The native `<dialog>` is well-supported in Safari 15.4+, Chrome 37+, and Firefox 98+ — all versions we target.
**Pull-to-refresh:** Custom implementation using pointer events on a sentinel element at the top of each list. We avoid adding a third-party pull-to-refresh library to keep the bundle small. The custom implementation is ~80 lines, respects `overscroll-behavior: contain`, and falls back to no-op if the user has indicated motion sensitivity.
**Tap-to-call / tap-to-email:** Implemented with explicit `tel:` and `mailto:` URL schemes on `<a>` tags (not buttons that call `window.location`). This ensures the OS handles the routing (e.g., FaceTime prompts on iOS, Gmail/Outlook chooser on Android) and is accessible to screen readers out of the box.
**Color contrast test exclusions:** `text-faint` is the only token below WCAG AAA (it passes AA at ≥ 5.28:1). The test in `src/lib/__tests__/design-tokens.test.ts` will assert AAA (7:1) for every token combination *except* `text-faint`, which is tested at AA (4.5:1). This is documented in the test file as an intentional exception.
### E2E (Playwright)
New spec: `tests/mobile-admin/pwa-install.spec.ts`
- Loads `/admin` on iPhone 13 viewport
- Asserts manifest link present, manifest is valid JSON, all icons return 200
- Asserts `apple-touch-icon` link present
- Asserts service worker registers successfully (via `navigator.serviceWorker.ready`)
New spec: `tests/mobile-admin/orders-list.spec.ts`
- Loads `/admin/orders` on iPhone 13 viewport
- Asserts no horizontal scroll (page width ≤ viewport)
- Asserts every interactive element has touch target ≥ 48pt
- Asserts all text passes axe-core
New spec: `tests/mobile-admin/offline-queue.spec.ts`
- Loads `/admin/orders/<id>` on iPhone 13 viewport
- Goes offline (Playwright `context.setOffline(true)`)
- Clicks "Mark Ready"
- Asserts the optimistic UI updates
- Asserts the action bar shows "Pending sync" badge
- Goes back online
- Asserts the badge clears within 5s and the server has the new status
### Visual regression (Playwright `toHaveScreenshot`)
- All 4 key page screens at 390×844 (iPhone 13)
- All 4 key page screens at 1024×768 (iPad)
- MobileTabBar — light + dark
- OfflineBanner — visible, hidden
### Manual QA checklist (added to PR template)
- [ ] Read every new screen in direct sunlight (or under a 5000K desk lamp at full brightness)
- [ ] Tap every interactive element while wearing work gloves
- [ ] Verify install-to-home-screen on a real iPhone (Safari → Share → Add to Home Screen) and confirm the splash + app icon are correct
- [ ] Toggle airplane mode and exercise: orders list, order detail, stops, products, mark-ready, mark-picked-up, stop status change, stock adjust
- [ ] Run `npx lighthouse http://localhost:3000/admin --preset=mobile --view` and confirm Performance ≥ 90, Accessibility ≥ 95, PWA checks pass
### Lighthouse / PWA gate (CI)
- Add a CI job (`.gitea/workflows/pwa-audit.yml`) that runs `lighthouse-ci` against the preview URL on every PR
- PWA category must pass (installable, manifest valid, service worker registered, themed)
- Mobile accessibility score must be ≥ 95
### Documentation
- `docs/pwa-testing.md` — how to manually test PWA + offline on a real device
- `docs/admin-mobile.md` — design rationale + token reference for the team
### Files touched in this section
~8 new test files, 1 CI workflow, 2 docs, plus assertions added to `playwright.config.ts` to include the `mobile-admin` project at iPhone 13 viewport by default.
---
## File inventory (consolidated)
### New files
- `public/icons/icon-{72,96,128,144,152,192,384,512}x{72,96,128,144,152,192,384,512}.png` (8)
- `public/icons/icon-maskable-512x512.png` (1)
- `public/icons/badge-72x72.png` (1)
- `public/icons/{orders,products,stops}-shortcut.png` (3)
- `public/screenshots/dashboard.png` (1)
- `public/screenshots/mobile-storefront.png` (1)
- `public/apple-touch-icon.png` (180×180), `public/apple-touch-icon-167.png`, `public/apple-touch-icon-152.png` (3)
- `public/favicon.ico` (1)
- `public/offline.html` (1)
- `src/components/admin/AdminShell.tsx`
- `src/components/admin/MobileTabBar.tsx`
- `src/components/admin/MoreSheet.tsx`
- `src/components/admin/PageHeader.tsx`
- `src/components/admin/StatusPill.tsx`
- `src/components/admin/EmptyState.tsx`
- `src/components/admin/CardList.tsx`
- `src/components/admin/StickyActionBar.tsx`
- `src/components/admin/OfflineBanner.tsx`
- `src/lib/offline/queue.ts`
- `src/lib/offline/sync.ts`
- `src/lib/offline/db.ts` (IndexedDB wrapper)
- `src/lib/__tests__/design-tokens.test.ts`
- `src/lib/offline/queue.test.ts`
- `src/lib/offline/sync.test.ts`
- `tests/mobile-admin/pwa-install.spec.ts`
- `tests/mobile-admin/orders-list.spec.ts`
- `tests/mobile-admin/offline-queue.spec.ts`
- `.gitea/workflows/pwa-audit.yml`
- `docs/pwa-testing.md`
- `docs/admin-mobile.md`
- `db/migrations/NNN_dashboard_summary_rpc.sql` (new RPC for dashboard)
### Modified files
- `public/manifest.json` (theme_color, screenshot paths)
- `public/sw.js` (full rewrite)
- `src/app/layout.tsx` (PWA meta tags, viewport.fit, themeColor)
- `src/components/Providers.tsx` (call `registerServiceWorker()`)
- `src/app/admin/layout.tsx` (mount `PWAInstallPrompt` + `OfflineBanner`, swap sidebar for `AdminShell`)
- `src/app/admin/v2/page.tsx` (Dashboard card layout — new v2 route)
- `src/app/admin/v2/orders/page.tsx` (CardList — new v2 route)
- `src/app/admin/v2/orders/[id]/page.tsx` (card layout + StickyActionBar + offline-queue integration — new v2 route)
- `src/app/admin/v2/stops/page.tsx` (time-grouped card list — new v2 route)
- `src/app/admin/v2/products/page.tsx` (image-forward card list — new v2 route)
- `next.config.ts` (cache headers for static assets; cutover redirects in PRs 6 and 8)
- `playwright.config.ts` (add `mobile-admin` project at iPhone 13 viewport)
- `src/lib/pwa.ts` (small fixes: `load` listener guard, dev-only logging)
- `src/app/page.tsx` (favicon ref fix)
Note: the v1 admin pages (`src/app/admin/page.tsx`, `src/app/admin/orders/page.tsx`, etc.) are **not** modified by PRs 15. They continue to serve the existing desktop layout until the cutover PRs redirect their routes to v2. After cutover, the v1 files are deleted in a follow-up commit.
---
## Open questions (deferred)
- **Web Push notifications** — SW has a handler scaffolded, but enabling real pushes requires VAPID keys, a push service, and a UX decision. Tabled for a follow-up spec.
- **Wholesale portal mobile** — out of scope here; would benefit from the same design system once the admin is shipped.
- **App Store / Play Store packaging** — Capacitor / TWA is the obvious next step once the PWA is solid. Not in this spec.
- **Image upload from camera** — products/stops could use this; separate spec.
---
## Risks
1. **The existing admin pages have a lot of bespoke UI** (drag-and-drop, complex tables, modals). Some of it may need to be simplified for the mobile-first treatment, not just wrapped. We'll discover this during implementation; the design language and shared components are designed to absorb it.
2. **The IndexedDB queue + idempotency requires server-side support** (`clientActionId` parameter on the relevant RPCs). The migration for the new RPCs is in scope; the RPC changes for idempotency are flagged here so they don't surprise us.
3. **iOS PWA limitations** — no push, no background sync (until iOS 16.4+ and even then limited), no install prompt via API (we use the manual `beforeinstallprompt` only, plus the iOS "Add to Home Screen" instructions in our install prompt copy). We document these limitations in the install prompt UI.
4. **Outdoor screen testing is subjective.** The 5000K lamp proxy won't catch every case. Real device testing in sunlight is required for sign-off.
---
## Rollout plan
> **Note on feature flags:** The project has a `src/lib/feature-flags.ts` system, but it's for **brand-scoped add-on features** (Harvest Reach, Square Sync, etc.), not for app-wide UI rollouts. There is no general-purpose feature flag system in this codebase. We will use a **route-based dual-deployment** strategy for rollout:
>
> - PRs 12 (design system + PWA scaffolding) are non-breaking; they add the new infrastructure without changing existing routes.
> - PRs 35 (Orders, Stops, Products) ship the new mobile-first versions at **`/admin/v2/orders`**, **`/admin/v2/stops`**, **`/admin/v2/products`**, and **`/admin/v2/orders/[id]`** initially. The old `/admin/orders` etc. routes continue to work unchanged. Internal team dogfoods at `/admin/v2/*` for 12 weeks.
> - PR 6 cuts over: a redirect in `next.config.ts` sends `/admin/orders*` → `/admin/v2/orders*`, etc. The old routes are removed in a follow-up commit.
> - PR 7 (Dashboard at `/admin/v2`) follows the same pattern.
>
> This avoids the need to introduce a feature flag system, which would be a separate piece of work.
1. **PR 1 — Design system + admin shell** — type scale, color tokens, AdminShell, MobileTabBar, MoreSheet, offline queue + sync. No page changes yet. Lighthouse PWA gate added.
2. **PR 2 — PWA manifest + SW + icons + offline page** — PWA installable, splash, themed status bar. All tests pass.
3. **PR 3 — Orders v2 (list + detail)** at `/admin/v2/orders/*` — first page on the new design language. Validates the components in real use.
4. **PR 4 — Stops v2** at `/admin/v2/stops`
5. **PR 5 — Products v2** at `/admin/v2/products`
6. **PR 6 — Cutover Orders + Stops + Products** — redirects in `next.config.ts`; old routes removed in a follow-up commit
7. **PR 7 — Dashboard v2** at `/admin/v2` (depends on the new `get_dashboard_summary` RPC migration)
8. **PR 8 — Dashboard cutover** — redirect `/admin``/admin/v2`; remove old dashboard
Each PR is independently shippable and reversible. The desktop layout is never affected until cutover PRs land.
---
## Definition of done
- [ ] All 4 key pages ship the mobile-first treatment
- [ ] PWA installs on iOS Safari and Android Chrome
- [ ] Service worker pre-caches the app shell, swr-caches the data, falls back to `/offline` on navigation
- [ ] Offline mutation queue replays actions on reconnect with no data loss
- [ ] All interactive elements ≥ 48pt (aim 56pt) on mobile
- [ ] All text passes WCAG AAA (7:1) against its background
- [ ] Lighthouse mobile audit: Performance ≥ 90, Accessibility ≥ 95, PWA passes
- [ ] No horizontal scroll on any of the 4 key pages at iPhone 13 viewport
- [ ] `prefers-reduced-motion` honored
- [ ] All Playwright specs (PWA install, orders list, offline queue) pass in CI
- [ ] Visual regression baselines committed for all 4 key pages × 2 viewports
- [ ] Real-device install + offline testing documented and signed off
+1 -1
View File
@@ -180,7 +180,7 @@ data explicitly checks its gate.
cold-start paths; the active `getAdminUser().brand_id` always wins cold-start paths; the active `getAdminUser().brand_id` always wins
on later requests. on later requests.
- No Supabase REST, no service-role keys — all access is direct - No Supabase REST, no service-role keys — all access is direct
through Drizzle over a single `pg` Pool. through Drizzle over a single shared `pg` Pool (see `src/lib/db.ts`).
--- ---
+1 -2
View File
@@ -14,7 +14,6 @@ const eslintConfig = defineConfig([
"next-env.d.ts", "next-env.d.ts",
// Ignore legacy .js scripts that use CommonJS // Ignore legacy .js scripts that use CommonJS
"scripts/**", "scripts/**",
"supabase/**",
"fix-agents.js", "fix-agents.js",
]), ]),
// Allow setState in useEffect for PWA prompts and client-side state initialization // Allow setState in useEffect for PWA prompts and client-side state initialization
@@ -26,7 +25,7 @@ const eslintConfig = defineConfig([
}, },
// Relax some rules for legacy code // Relax some rules for legacy code
{ {
files: ["db/**", "scripts/**", "supabase/**"], files: ["db/**", "scripts/**"],
rules: { rules: {
"@typescript-eslint/no-require-imports": "off", "@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
+26
View File
@@ -0,0 +1,26 @@
{
"ci": {
"collect": {
"url": [
"http://localhost:3000/admin/v2/orders",
"http://localhost:3000/admin/v2/stops",
"http://localhost:3000/admin/v2/products"
],
"numberOfRuns": 1,
"settings": {
"preset": "mobile",
"emulatedFormFactor": "mobile"
}
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.95 }],
"categories:pwa": "error",
"installable-manifest": "error",
"service-worker": "error",
"themed-omnibox": "warn"
}
}
}
}
+44 -10
View File
@@ -22,10 +22,6 @@ const nextConfig: NextConfig = {
// Optimize images // Optimize images
images: { images: {
remotePatterns: [ remotePatterns: [
{
protocol: "https",
hostname: "*.supabase.co",
},
{ {
protocol: "https", protocol: "https",
hostname: "images.unsplash.com", hostname: "images.unsplash.com",
@@ -86,18 +82,56 @@ const nextConfig: NextConfig = {
}, },
], ],
}, },
{
source: "/_next/static/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
{
source: "/icons/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
{
source: "/screenshots/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=604800" },
],
},
]; ];
}, },
// Redirects // Redirects
async redirects() { async redirects() {
return [ return [
// Redirect old paths if needed // Cutover (PR 6, Task 6.1): mobile-first admin lives at /admin/v2/*.
// { // Redirect the v1 list/detail paths to their v2 equivalents.
// source: '/old-path', // Use `permanent: false` (307) so we can revert easily if a
// destination: '/new-path', // regression is caught in monitoring before the v1 files are
// permanent: true, // removed in Task 6.2.
// }, // PR 8, Task 8.1: redirect the bare /admin path to the v2 dashboard.
// The dashboard itself still lives at /admin (as the v1 page)
// until Task 8.2 removes it after a 3-day monitoring window —
// this redirect just points the v1 entry point at v2.
{ source: "/admin", destination: "/admin/v2", permanent: false },
{ source: "/admin/orders", destination: "/admin/v2/orders", permanent: false },
{ source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false },
{ source: "/admin/stops", destination: "/admin/v2/stops", permanent: false },
{ source: "/admin/products", destination: "/admin/v2/products", permanent: false },
// v1 admin pages that were removed when the supabase shim was
// deleted. There are no v2 equivalents yet (reports / taxes /
// settings sub-pages are still TBD on the v2 surface), so they
// redirect to the v2 dashboard for now. Once those modules ship
// on v2, the redirects can be tightened to point at the new
// pages.
{ source: "/admin/products/:id", destination: "/admin/v2/products", permanent: false },
{ source: "/admin/reports", destination: "/admin/v2", permanent: false },
{ source: "/admin/taxes", destination: "/admin/v2", permanent: false },
{ source: "/admin/settings/shipping", destination: "/admin/settings", permanent: false },
{ source: "/admin/settings/integrations", destination: "/admin/settings", permanent: false },
{ source: "/admin/settings/billing", destination: "/admin/settings", permanent: false },
]; ];
}, },
+3 -2
View File
@@ -34,14 +34,13 @@
"@sentry/nextjs": "^10.55.0", "@sentry/nextjs": "^10.55.0",
"@stripe/react-stripe-js": "^6.6.0", "@stripe/react-stripe-js": "^6.6.0",
"@stripe/stripe-js": "^9.7.0", "@stripe/stripe-js": "^9.7.0",
"@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.105.3",
"@upstash/ratelimit": "^2.0.8", "@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0", "@upstash/redis": "^1.38.0",
"drizzle-orm": "^0.36.4", "drizzle-orm": "^0.36.4",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
"gsap": "^3.15.0", "gsap": "^3.15.0",
"idb": "^8.0.3",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "^16.2.6", "next": "^16.2.6",
"next-auth": "^5.0.0-beta.31", "next-auth": "^5.0.0-beta.31",
@@ -61,6 +60,7 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@lhci/cli": "^0.15.1",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
@@ -75,6 +75,7 @@
"drizzle-kit": "^0.30.6", "drizzle-kit": "^0.30.6",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.5", "eslint-config-next": "16.2.5",
"fake-indexeddb": "^6.2.5",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"pg": "^8.20.0", "pg": "^8.20.0",
"playwright": "^1.59.1", "playwright": "^1.59.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+17 -85
View File
@@ -2,100 +2,32 @@
"name": "Route Commerce", "name": "Route Commerce",
"short_name": "Route Commerce", "short_name": "Route Commerce",
"description": "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution", "description": "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution",
"start_url": "/", "start_url": "/admin",
"scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#faf8f5", "background_color": "#faf8f5",
"theme_color": "#1a4d2e", "theme_color": "#166534",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"icons": [ "icons": [
{ { "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "any" },
"src": "/icons/icon-72x72.png", { "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "any" },
"sizes": "72x72", { "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "any" },
"type": "image/png", { "src": "/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "any" },
"purpose": "any maskable" { "src": "/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "any" },
}, { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ { "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png", "purpose": "any" },
"src": "/icons/icon-96x96.png", { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
"sizes": "96x96", { "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
], ],
"categories": ["business", "productivity"], "categories": ["business", "productivity"],
"shortcuts": [ "shortcuts": [
{ { "name": "View Orders", "short_name": "Orders", "url": "/admin/v2/orders", "icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }] },
"name": "View Orders", { "name": "Add Product", "short_name": "Add Product", "url": "/admin/v2/products/new", "icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }] },
"short_name": "Orders", { "name": "Create Stop", "short_name": "Create Stop", "url": "/admin/v2/stops/new", "icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }] }
"description": "View and manage orders",
"url": "/admin/orders",
"icons": [{ "src": "/icons/orders-shortcut.png", "sizes": "96x96" }]
},
{
"name": "Add Product",
"short_name": "Add Product",
"description": "Add a new product",
"url": "/admin/products/new",
"icons": [{ "src": "/icons/products-shortcut.png", "sizes": "96x96" }]
},
{
"name": "Create Stop",
"short_name": "Create Stop",
"description": "Schedule a new pickup stop",
"url": "/admin/stops/new",
"icons": [{ "src": "/icons/stops-shortcut.png", "sizes": "96x96" }]
}
], ],
"screenshots": [ "screenshots": [
{ { "src": "/screenshots/dashboard.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide", "label": "Admin Dashboard" },
"src": "/screenshots/dashboard.png", { "src": "/screenshots/mobile-storefront.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow", "label": "Mobile Storefront" }
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Admin Dashboard"
},
{
"src": "/screenshots/mobile-storefront.png",
"sizes": "390x844",
"type": "image/png",
"form_factor": "narrow",
"label": "Mobile Storefront"
}
], ],
"related_applications": [], "related_applications": [],
"prefer_related_applications": false "prefer_related_applications": false
+87
View File
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#166534">
<title>Offline — Route Commerce</title>
<style>
:root {
--color-bg: #ffffff;
--color-surface: #faf8f5;
--color-text: #1d1d1f;
--color-text-muted: #424245;
--color-accent: #166534;
--color-warning: #854d0e;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Manrope, system-ui, sans-serif;
background: var(--color-surface);
color: var(--color-text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
-webkit-font-smoothing: antialiased;
}
.card {
max-width: 480px;
width: 100%;
background: var(--color-bg);
border-radius: 24px;
padding: 48px 32px;
text-align: center;
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
}
.icon {
width: 64px;
height: 64px;
margin: 0 auto 24px;
background: var(--color-warning);
color: white;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
}
h1 {
font-family: Fraunces, Georgia, serif;
font-size: 32px;
font-weight: 600;
margin: 0 0 12px;
letter-spacing: -0.01em;
}
p {
font-size: 18px;
line-height: 1.45;
color: var(--color-text-muted);
margin: 0 0 24px;
}
button {
background: var(--color-accent);
color: white;
border: 0;
border-radius: 12px;
padding: 16px 32px;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
min-height: 56px;
}
button:active { opacity: 0.85; }
</style>
</head>
<body>
<div class="card" role="status">
<div class="icon" aria-hidden="true">📡</div>
<h1>You're offline</h1>
<p>This page isn't available without a connection. Cached pages will still work. Your changes will sync when you're back online.</p>
<button type="button" onclick="location.reload()">Try again</button>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+75 -97
View File
@@ -1,130 +1,108 @@
// Service Worker for PWA - Caching and offline support // public/sw.js
// Route Commerce Service Worker
// Two-cache strategy: shell (cache-first) + data (stale-while-revalidate)
const CACHE_NAME = "route-commerce-v1"; const SHELL_CACHE = "rc-shell-v3";
const OFFLINE_URL = "/offline"; const DATA_CACHE = "rc-data-v3";
const OFFLINE_URL = "/offline.html";
const STATIC_ASSETS = [ const SHELL_ASSETS = [
"/", "/",
"/manifest.json", "/manifest.json",
"/favicon.svg", "/favicon.svg",
"/og-default.jpg", "/og-default.svg",
"/offline.html",
"/icons/icon-192x192.png",
"/icons/icon-512x512.png",
]; ];
// Install event - cache static assets
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then((cache) => { caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS))
return cache.addAll(STATIC_ASSETS);
})
); );
self.skipWaiting(); self.skipWaiting();
}); });
// Activate event - clean old caches
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((cacheNames) => { Promise.all([
return Promise.all( caches.keys().then((cacheNames) =>
Promise.all(
cacheNames cacheNames
.filter((name) => name !== CACHE_NAME) .filter((name) => name !== SHELL_CACHE && name !== DATA_CACHE)
.map((name) => caches.delete(name)) .map((name) => caches.delete(name))
)
),
self.clients.claim(),
])
); );
})
);
self.clients.claim();
}); });
// Fetch event - network first, fallback to cache
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
// Skip non-GET requests const req = event.request;
if (event.request.method !== "GET") return; if (req.method !== "GET") return;
const url = new URL(req.url);
// Skip API requests if (url.origin !== self.location.origin) return;
if (event.request.url.includes("/api/")) return;
// Skip cross-origin requests
if (!event.request.url.startsWith(self.location.origin)) return;
// Navigations → network-first, fall back to cache, fall back to offline page
if (req.mode === "navigate") {
event.respondWith( event.respondWith(
fetch(event.request) fetch(req)
.then((response) => { .then((res) => {
// Clone response for caching const clone = res.clone();
const responseClone = response.clone(); caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
return res;
// Cache successful responses
if (response.status === 200) {
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
}) })
.catch(() => { .catch(() =>
// Return cached response or offline page caches.match(req).then((cached) => cached || caches.match(OFFLINE_URL))
return caches.match(event.request).then((cachedResponse) => { )
if (cachedResponse) { );
return cachedResponse; return;
} }
// Return offline page for navigation requests
if (event.request.mode === "navigate") { // Static assets → cache-first
return caches.match(OFFLINE_URL); if (
url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/icons/") ||
url.pathname.startsWith("/screenshots/") ||
url.pathname.endsWith(".svg") ||
url.pathname.endsWith(".woff2")
) {
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;
return fetch(req).then((res) => {
const clone = res.clone();
if (res.status === 200) {
caches.open(SHELL_CACHE).then((cache) => cache.put(req, clone));
} }
return new Response("Network error", { status: 408 }); return res;
}); });
}) })
); );
}); return;
// Push notification handling
self.addEventListener("push", (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body,
icon: "/icons/icon-192x192.png",
badge: "/icons/badge-72x72.png",
vibrate: [100, 50, 100],
data: {
url: data.url || "/",
},
actions: data.actions || [],
};
event.waitUntil(self.registration.showNotification(data.title, options));
});
// Notification click handling
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/";
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
// Focus existing window or open new one
for (const client of clientList) {
if (client.url === url && "focus" in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(url);
} }
// API GETs (read-only data) → stale-while-revalidate
if (url.pathname.startsWith("/api/")) {
event.respondWith(
caches.open(DATA_CACHE).then((cache) =>
cache.match(req).then((cached) => {
const network = fetch(req)
.then((res) => {
if (res.status === 200) cache.put(req, res.clone());
return res;
}) })
.catch(() => cached);
return cached || network;
})
)
);
return;
}
// Default: try network, fall back to cache
event.respondWith(
fetch(req).catch(() => caches.match(req))
); );
}); });
// Background sync for offline actions
self.addEventListener("sync", (event) => {
if (event.tag === "sync-orders") {
event.waitUntil(syncOrders());
}
});
async function syncOrders() {
// Implement order sync logic
console.log("Syncing orders in background...");
}
-160
View File
@@ -1,160 +0,0 @@
#!/usr/bin/env node
/**
* apply-admin-create-stop.js
*
* Applies the admin_create_stop RPC using ONLY the keys in .env.local.
* You do NOT need Supabase dashboard / SQL editor access.
*
* This is the fix for the "could not find the function public.admin_create_stop(...)"
* error when you click "Add New Stop".
*
* Usage (run from the repo root on a machine that can reach the DB):
* node scripts/apply-admin-create-stop.js
*
* The script connects as the real postgres user (using your SUPABASE_SERVICE_ROLE_KEY
* as the password) and runs the CREATE OR REPLACE + GRANT + NOTIFY.
* This is the only reliable way to create the SECURITY DEFINER function because
* direct REST inserts are blocked by the `block_stops_mutations` policy.
*
* After it prints success:
* - Restart your `npm run dev`
* - "Add New Stop" (AddStopModal / NewStopForm) will now call the RPC successfully.
*/
const { Client } = require("pg");
const fs = require("fs");
const path = require("path");
// Load .env.local (same as the rest of the project)
try {
require("dotenv").config({ path: path.resolve(__dirname, "../.env.local") });
} catch (e) {
// dotenv might not be hoisted in some setups; the keys may already be in process.env
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) {
console.error("❌ Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local");
console.error(" Make sure you are running from the repo root and .env.local exists with the keys.");
process.exit(1);
}
// Derive the direct Postgres URL (same logic as supabase/push-migrations.js)
const projectRef = supabaseUrl.replace("https://", "").split(".")[0];
const pgHost = `db.${projectRef}.supabase.co`;
const dbUrl = `postgresql://postgres:${encodeURIComponent(serviceKey)}@${pgHost}:5432/postgres`;
console.log("🔧 apply-admin-create-stop RPC installer");
console.log(" Project:", projectRef);
console.log(" Using service role key from .env.local (full Postgres access)");
console.log("");
async function main() {
const migrationPath = path.resolve(__dirname, "../supabase/migrations/202_fix_admin_create_stop.sql");
if (!fs.existsSync(migrationPath)) {
console.error("❌ Cannot find migration file:", migrationPath);
console.error(" The file should have been created by the previous fix.");
process.exit(1);
}
const sql = fs.readFileSync(migrationPath, "utf8");
const client = new Client({
connectionString: dbUrl,
ssl: { rejectUnauthorized: false },
// Some environments need a longer timeout
connectionTimeoutMillis: 15000,
});
try {
console.log("⏳ Connecting to Postgres (this uses port 5432 outbound)...");
await client.connect();
console.log("✅ Connected.");
console.log("⏳ Executing 202_fix_admin_create_stop.sql (CREATE OR REPLACE + GRANT + NOTIFY)...");
const result = await client.query(sql);
console.log("✅ Function installed / updated successfully.");
console.log(" Rows affected in last statement:", result.rowCount ?? "(see output above if any)");
console.log("");
console.log("🎉 admin_create_stop is now available via the anon key (SECURITY DEFINER).");
// Quick verification using the anon key over HTTPS (this is what the app uses)
try {
const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const probeBody = {
p_active: false,
p_address: null,
p_brand_id: "00000000-0000-0000-0000-000000000000", // will FK fail, but proves the function exists
p_city: "verify",
p_cutoff_time: null,
p_date: "2099-12-31",
p_location: "verify",
p_state: "TS",
p_time: "10:00",
p_zip: null,
};
const probe = await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/admin_create_stop`, {
method: "POST",
headers: { apikey: anon, "Content-Type": "application/json" },
body: JSON.stringify(probeBody),
});
const txt = await probe.text();
if (probe.status === 404) {
console.log("⚠️ Post-apply probe still got 404 — you may need to wait a few seconds or the NOTIFY didn't take.");
} else if (/foreign key|violates|brand_id/i.test(txt)) {
console.log("✅ Post-apply probe: function exists (got expected FK error on the zero-UUID brand — this is success).");
} else {
console.log("✅ Post-apply probe returned:", txt.slice(0, 200));
}
} catch (e) {
console.log(" (verification fetch skipped:", e.message, ")");
}
console.log("");
console.log("Next:");
console.log(" • Restart your dev server (`npm run dev`) if it was already running.");
console.log(" • Go to the stops admin page and try \"Add New Stop\".");
console.log(" • (Optional but recommended) node scripts/verify-stop-fns.js");
} catch (err) {
const msg = (err && err.message) ? err.message : String(err);
console.error("❌ Failed to apply the function.");
console.error(" Error:", msg.slice(0, 500));
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|network is unreachable|ECONNREFUSED|timeout/i.test(msg)) {
console.log("");
console.log("💡 Your current environment cannot reach the database on port 5432.");
console.log(" This is common inside some containers/CI sandboxes.");
console.log("");
console.log(" Copy and run ONE of the following on a *normal* laptop/terminal that has");
console.log(" outbound access to Supabase Postgres (most developer machines can reach it):");
console.log("");
console.log(" Option A (easiest, uses exactly the same code as this script):");
console.log(" npm run migrate:one 202");
console.log("");
console.log(" Option B (psql):");
console.log(" # Build the URL using the key from your .env.local (same as this script does):");
console.log(" # psql \"postgresql://postgres:PUT_YOUR_SERVICE_ROLE_KEY_HERE@db." + projectRef + ".supabase.co:5432/postgres\" \\");
console.log(" # -f supabase/migrations/202_fix_admin_create_stop.sql");
console.log("");
console.log(" Option C (Supabase CLI):");
console.log(" supabase db push --db-url \"postgresql://postgres:YOUR_SERVICE_ROLE_KEY@db." + projectRef + ".supabase.co:5432/postgres\" \\");
console.log(" supabase/migrations/202_fix_admin_create_stop.sql");
console.log("");
console.log(" After the command succeeds you will see the NOTIFY and the function will exist.");
console.log(" Then restart `npm run dev` and \"Add New Stop\" will stop showing the function-not-found error.");
}
process.exit(1);
} finally {
try { await client.end(); } catch {}
}
}
main().catch((e) => {
console.error("Unexpected crash:", e);
process.exit(1);
});
-49
View File
@@ -1,49 +0,0 @@
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const headers = {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
"Content-Type": "application/json",
};
async function rpc(name, body = {}) {
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
method: "POST", headers, body: JSON.stringify(body),
});
return { status: r.status, text: (await r.text()).slice(0, 500) };
}
(async () => {
// 1. Try calling admin_create_stop with service role
console.log("=== 1. Call admin_create_stop (service role) ===");
console.log(await rpc("admin_create_stop", { p_brand_id: null }));
// 2. Try with body
console.log("\n=== 2. Call admin_create_stop with stub body ===");
console.log(await rpc("admin_create_stop", {
p_brand_id: "00000000-0000-0000-0000-000000000000",
p_city: "test", p_state: "CO", p_location: "x", p_date: "2025-01-01", p_time: "08:00"
}));
// 3. Try with a name that obviously doesn't exist to see the error format
console.log("\n=== 3. Call a non-existent function (control) ===");
console.log(await rpc("definitely_does_not_exist_xyz", {}));
// 4. Use the PostgREST introspection — query information_schema via rpc
// PostgREST exposes pg_proc through /rest/v1/ for system tables is not allowed
// But we can use the OpenAPI spec endpoint
console.log("\n=== 4. OpenAPI spec — looking for admin_create_stop ===");
const openApi = await fetch(`${url}/rest/v1/`, { headers: { apikey: anonKey, Authorization: `Bearer ${anonKey}` } });
const text = await openApi.text();
const matches = text.match(/admin_create_stop[a-z_]*/g) || [];
console.log("Matches in OpenAPI:", matches);
// 5. Get all function names from OpenAPI
const funcMatches = [...text.matchAll(/"([a-z][a-z0-9_]*)\s*\{/g)].map(m => m[1]);
const stopFns = funcMatches.filter(n => n.includes("stop"));
console.log("\nStop-related functions in OpenAPI:", stopFns);
})();
-52
View File
@@ -1,52 +0,0 @@
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const { Client } = require("pg");
const dns = require("dns");
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const projectRef = url.replace("https://", "").split(".")[0];
const candidates = [
`db.${projectRef}.supabase.co`,
`aws-0-us-east-1.pooler.supabase.com`,
`aws-0-us-west-1.pooler.supabase.com`,
];
(async () => {
for (const host of candidates) {
try {
const ip = await new Promise((resolve, reject) => {
dns.resolve4(host, (err, addrs) => err ? reject(err) : resolve(addrs));
});
console.log(`${host} -> ${ip.join(", ")}`);
} catch (e) {
console.log(`${host} -> DNS FAIL: ${e.code}`);
}
}
// Try direct DB with port 6543 (pooler) using supabase format
// Username pattern: postgres.<project-ref>
const client = new Client({
host: "aws-0-us-east-1.pooler.supabase.com",
port: 6543, database: "postgres",
user: `postgres.${projectRef}`,
password: process.env.SUPABASE_SERVICE_ROLE_KEY,
ssl: { rejectUnauthorized: false },
});
try {
await client.connect();
const r = await client.query(`
SELECT p.proname,
pg_get_function_identity_arguments(p.oid) AS id_args,
p.prosecdef AS secdef
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
WHERE n.nspname='public' AND p.proname IN
('admin_create_stop','admin_create_stops_batch','delete_stop')
ORDER BY p.proname;
`);
console.log("\nFUNCTIONS IN DB:");
console.log(JSON.stringify(r.rows, null, 2));
} catch (e) {
console.error("POOLER ERR:", e.message);
} finally {
try { await client.end(); } catch {}
}
})();
-44
View File
@@ -1,44 +0,0 @@
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const { Client } = require("pg");
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const projectRef = url.replace("https://", "").split(".")[0];
const pw = process.env.SUPABASE_SERVICE_ROLE_KEY;
const tries = [
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: `postgres.${projectRef}`, database: "postgres" },
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 5432, user: `postgres.${projectRef}`, database: "postgres" },
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: "postgres", database: "postgres" },
];
(async () => {
for (const cfg of tries) {
const client = new Client({ ...cfg, password: pw, ssl: { rejectUnauthorized: false } });
try {
await client.connect();
const r = await client.query(`
SELECT p.proname,
pg_get_function_identity_arguments(p.oid) AS id_args,
p.prosecdef AS secdef
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
WHERE n.nspname='public' AND p.proname IN
('admin_create_stop','admin_create_stops_batch','delete_stop')
ORDER BY p.proname;
`);
console.log(`\n${cfg.host}:${cfg.port} user=${cfg.user}`);
console.log(JSON.stringify(r.rows, null, 2));
// Also check if migration tracking table exists
const trk = await client.query(`
SELECT version, name FROM supabase_migrations.schema_migrations
WHERE version >= 145 ORDER BY version;
`).catch(() => ({ rows: [] }));
console.log("Recent migrations applied:", trk.rows);
await client.end();
return;
} catch (e) {
console.log(`${cfg.host}:${cfg.port} user=${cfg.user}${e.message}`);
try { await client.end(); } catch {}
}
}
})();
-78
View File
@@ -1,78 +0,0 @@
/**
* 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);
});
-61
View File
@@ -1,61 +0,0 @@
/**
* Create an admin user in Neon Auth via direct HTTP call.
* Run: npx tsx scripts/create-admin-user.ts [email] [password] [name]
*
* Makes a direct HTTP request to the Neon Auth API so we don't need
* a Next.js request context, then updates email_verified in the DB.
*/
import { config } from "dotenv";
config({ path: ".env.local" });
import pg from "pg";
const { Pool } = pg;
async function main() {
const email = process.argv[2] ?? "admin@example.com";
const password = process.argv[3] ?? "Admin1234!";
const name = process.argv[4] ?? "Admin";
const baseUrl = process.env.NEON_AUTH_BASE_URL!;
console.log(`Creating user: ${email}`);
const res = await fetch(`${baseUrl}/sign-up/email`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Origin": "http://localhost:4000",
"x-neon-auth-proxy": "node",
},
body: JSON.stringify({ email, password, name, callbackURL: "/admin" }),
});
const data = await res.json().catch(() => ({}));
console.log(`Sign-up status: ${res.status}`);
if (!res.ok) {
console.error("Failed to create user:", JSON.stringify(data));
process.exit(1);
}
const userId = data.user?.id;
console.log("User created:", data.user?.email, "| ID:", userId);
// Mark email verified in DB so they can log in immediately
if (userId) {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
await pool.query(
"UPDATE neon_auth.user SET email_verified = true WHERE id = $1",
[userId]
);
console.log("Email verified in DB.");
} finally {
await pool.end();
}
}
console.log(`\nLog in at http://localhost:4000/login with ${email} / ${password}`);
}
main();
-99
View File
@@ -1,99 +0,0 @@
#!/bin/bash
# End-to-end validation test for the local Postgres + PostgREST + MinIO + Next.js stack
# Exit 0 = all green, exit 1 = at least one failure.
set -e
API="http://localhost:3001"
WEB="http://localhost:4000"
pass=0
fail=0
declare -a FAILURES
check() {
local name="$1" url="$2" expected="${3:-200}" cookies="${4:-}"
local cmd="curl -s -o /dev/null -w '%{http_code}'"
if [ -n "$cookies" ]; then cmd="$cmd -b \"$cookies\""; fi
local code=$(eval "$cmd $url")
if [ "$code" = "$expected" ]; then
echo " PASS $name ($code)"
pass=$((pass+1))
else
echo " FAIL $name expected=$expected got=$code url=$url"
fail=$((fail+1))
FAILURES+=("$name")
fi
}
echo "=== Postgres connection ==="
PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT 'ok';" >/dev/null
echo " PASS postgres responds"
pass=$((pass+1))
echo "=== DB integrity ==="
TABLE_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';")
[ "$TABLE_COUNT" -ge 65 ] && echo " PASS $TABLE_COUNT public tables (>=65)" && pass=$((pass+1)) || { echo " FAIL $TABLE_COUNT tables"; fail=$((fail+1)); }
FN_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname='public';")
[ "$FN_COUNT" -ge 250 ] && echo " PASS $FN_COUNT functions (>=250)" && pass=$((pass+1)) || { echo " FAIL $FN_COUNT functions"; fail=$((fail+1)); }
BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands;")
[ "$BRANDS" -ge 2 ] && echo " PASS $BRANDS brands" && pass=$((pass+1)) || { echo " FAIL $BRANDS brands"; fail=$((fail+1)); }
STOPS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM stops WHERE active=true AND deleted_at IS NULL;")
[ "$STOPS" -ge 200 ] && echo " PASS $STOPS active stops" && pass=$((pass+1)) || { echo " FAIL $STOPS stops"; fail=$((fail+1)); }
# No test brands
TEST_BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands WHERE slug IN ('sunrise-farms','green-valley','orchard-fresh');")
[ "$TEST_BRANDS" = "0" ] && echo " PASS no test brands" && pass=$((pass+1)) || { echo " FAIL test brands found"; fail=$((fail+1)); }
echo "=== Tuxedo Corn data ==="
PHONE=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT phone FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de';")
[ "$PHONE" = "970-323-6874" ] && echo " PASS phone=$PHONE" && pass=$((pass+1)) || { echo " FAIL phone=$PHONE (expected 970-323-6874)"; fail=$((fail+1)); }
LOGOS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de' AND logo_url LIKE '/storage/%';")
[ "$LOGOS" = "1" ] && echo " PASS logo_url is /storage/ path" && pass=$((pass+1)) || { echo " FAIL logo_url not /storage/"; fail=$((fail+1)); }
echo "=== PostgREST ==="
check "GET /" "$API/"
check "GET /brands" "$API/brands?select=id,name,slug&order=name"
check "GET /brands?slug=eq.tuxedo" "$API/brands?slug=eq.tuxedo&select=*"
check "GET /stops" "$API/stops?select=id,city&limit=5"
check "GET /products" "$API/products?select=id,name&limit=5"
check "POST rpc get_brand_settings_by_slug" "$API/rpc/get_brand_settings_by_slug" 200 \
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
check "POST rpc get_brand_plan_info" "$API/rpc/get_brand_plan_info" 200 \
"-X POST -H Content-Type:application/json -d {\"p_brand_id\":\"64294306-5f42-463d-a5e8-2ad6c81a96de\"}"
check "POST rpc get_public_stops_for_brand" "$API/rpc/get_public_stops_for_brand" 200 \
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
check "GET /admin_users with brand join" "$API/admin_users?select=id,user_id,role,brand_id,brands(name)&limit=5"
check "GET /stops with brand join" "$API/stops?select=id,city,brand_id,brands(name)&limit=5"
echo "=== MinIO ==="
check "Storage logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
check "Storage olathe-sweet-logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
check "Storage olathe-sweet-logo-dark.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
echo "=== Public storefronts ==="
check "GET /" "$WEB/"
check "GET /tuxedo" "$WEB/tuxedo"
check "GET /tuxedo/about" "$WEB/tuxedo/about"
check "GET /tuxedo/stops" "$WEB/tuxedo/stops"
check "GET /indian-river-direct" "$WEB/indian-river-direct"
check "GET /login" "$WEB/login"
echo "=== Admin pages (dev_session=platform_admin) ==="
for p in /admin /admin/products /admin/stops /admin/orders /admin/users /admin/settings /admin/settings/billing /admin/settings/apps /admin/settings/payments /admin/communications /admin/communications/compose /admin/time-tracking /admin/wholesale /admin/water-log /admin/analytics /admin/reports; do
check "GET $p" "$WEB$p" 200 "dev_session=platform_admin"
done
echo ""
echo "=== Summary ==="
echo " PASS: $pass"
echo " FAIL: $fail"
if [ $fail -gt 0 ]; then
echo " Failures:"
for f in "${FAILURES[@]}"; do echo " - $f"; done
exit 1
fi
echo " ALL GREEN"
+44
View File
@@ -0,0 +1,44 @@
// scripts/generate-pwa-icons.js
// Generates the full PWA icon set from public/favicon.svg
// Run: node scripts/generate-pwa-icons.js
const fs = require("fs");
const path = require("path");
const sharp = require("sharp");
const SIZES = [72, 96, 128, 144, 152, 192, 384, 512];
const OUT_DIR = path.join(__dirname, "..", "public", "icons");
const SVG_PATH = path.join(__dirname, "..", "public", "favicon.svg");
const svg = fs.readFileSync(SVG_PATH);
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
for (const size of SIZES) {
await sharp(svg).resize(size, size).png().toFile(path.join(OUT_DIR, `icon-${size}x${size}.png`));
console.log(`✓ icon-${size}x${size}.png`);
}
// Maskable: same icon, with 20% safe-area padding baked in
await sharp(svg).resize(410, 410).extend({
top: 51, bottom: 51, left: 51, right: 51,
background: { r: 22, g: 77, b: 46, alpha: 1 },
}).png().toFile(path.join(OUT_DIR, "icon-maskable-512x512.png"));
console.log("✓ icon-maskable-512x512.png");
// Badge
await sharp(svg).resize(72, 72).png().toFile(path.join(OUT_DIR, "badge-72x72.png"));
console.log("✓ badge-72x72.png");
// Shortcuts (same icon, 96x96)
for (const name of ["orders-shortcut", "products-shortcut", "stops-shortcut"]) {
await sharp(svg).resize(96, 96).png().toFile(path.join(OUT_DIR, `${name}.png`));
console.log(`${name}.png`);
}
// Apple touch icons
for (const [name, size] of [["apple-touch-icon", 180], ["apple-touch-icon-167", 167], ["apple-touch-icon-152", 152]]) {
await sharp(svg).resize(size, size).png().toFile(path.join(__dirname, "..", "public", `${name}.png`));
console.log(`${name}.png`);
}
// favicon.ico (32x32)
await sharp(svg).resize(32, 32).png().toFile(path.join(__dirname, "..", "public", "favicon.ico"));
console.log("✓ favicon.ico");
}
main().catch((err) => { console.error(err); process.exit(1); });
+39
View File
@@ -0,0 +1,39 @@
// scripts/generate-pwa-screenshots.js
const fs = require("fs");
const path = require("path");
const sharp = require("sharp");
const OUT_DIR = path.join(__dirname, "..", "public", "screenshots");
fs.mkdirSync(OUT_DIR, { recursive: true });
async function main() {
// Desktop dashboard placeholder
await sharp({
create: { width: 1280, height: 720, channels: 3, background: { r: 250, g: 248, b: 245 } }
})
.composite([{
input: Buffer.from(`<svg width="1280" height="720" xmlns="http://www.w3.org/2000/svg">
<text x="640" y="360" font-family="serif" font-size="48" fill="#1a4d2e" text-anchor="middle">Route Commerce</text>
</svg>`),
top: 0, left: 0,
}])
.png()
.toFile(path.join(OUT_DIR, "dashboard.png"));
console.log("✓ dashboard.png");
// Mobile storefront placeholder
await sharp({
create: { width: 390, height: 844, channels: 3, background: { r: 250, g: 248, b: 245 } }
})
.composite([{
input: Buffer.from(`<svg width="390" height="844" xmlns="http://www.w3.org/2000/svg">
<text x="195" y="422" font-family="serif" font-size="32" fill="#1a4d2e" text-anchor="middle">Route Commerce</text>
</svg>`),
top: 0, left: 0,
}])
.png()
.toFile(path.join(OUT_DIR, "mobile-storefront.png"));
console.log("✓ mobile-storefront.png");
}
main().catch(console.error);
+1 -2
View File
@@ -305,8 +305,7 @@ async function main() {
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`; let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`; sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`;
sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\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 += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
sql += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
sql += `BEGIN;\n\n`; sql += `BEGIN;\n\n`;
// Locations first (master directory) // Locations first (master directory)
-262
View File
@@ -1,262 +0,0 @@
/**
* import-woo-to-route.ts - 2026 Season Import
*
* Reads Tuxedo_Corn_2026_Tour_Schedule-2.xlsx (Full Tour Schedule sheet) and produces:
* products.csv - 1 core pickup product
* stops.csv - 164 active stops
*
* Usage:
* npx tsx scripts/import-woo-to-route.ts \
* --xlsx "/path/to/Tuxedo_Corn_2026_Tour_Schedule-2.xlsx" \
* --brand 64294306-5f42-463d-a5e8-2ad6c81a96de \
* --output ./import-output
*/
import * as fs from "fs";
import * as path from "path";
import { parseArgs } from "util";
import ExcelJS from "exceljs";
// Types
interface ProductOutput {
name: string;
price: number;
type: "Pickup" | "Shipping" | "Pickup & Shipping";
description: string;
active: boolean;
image_url: string;
is_taxable: boolean;
}
interface StopOutput {
city: string;
state: string;
location: string;
date: string;
time: string;
address: string;
notes: string;
}
function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
async function main() {
const args = parseArgs({
options: {
xlsx: { type: "string" },
brand: { type: "string" },
output: { type: "string", default: "./import-output" },
},
});
const xlsxPath = args.values.xlsx as string;
const brandId = args.values.brand as string;
const outputDir = args.values.output as string;
if (!xlsxPath || !brandId) {
console.error(
"Usage: npx tsx scripts/import-woo-to-route.ts --xlsx <path> --brand <uuid> [--output <dir>]"
);
process.exit(1);
}
if (!fs.existsSync(xlsxPath)) {
console.error("File not found: " + xlsxPath);
process.exit(1);
}
// Read Excel
// Row 0=title, Row1=subtitle, Row2=color-key, Row3=column-headers, Row4+=data
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(xlsxPath);
const sheet = wb.getWorksheet(1);
if (!sheet) {
console.error("No worksheet found in file");
process.exit(1);
}
const allRows: (string | null | undefined)[][] = [];
sheet.eachRow((row) => {
allRows.push(row.values as (string | null | undefined)[]);
});
const colHeaders = (allRows[3] as string[]).map((h: unknown) => String(h ?? "").trim());
console.log("Column headers:", colHeaders);
const ci = (col: string) => colHeaders.indexOf(col);
const wkIdx = ci("Wk"), typeIdx = ci("Type"), datesIdx = ci("Dates"), dayIdx = ci("Day");
const cityIdx = ci("City / Location"), hostIdx = ci("Host / Venue");
const timeIdx = ci("Time"), truckIdx = ci("Truck"), statusIdx = ci("Status"), notesIdx = ci("Notes");
interface ScheduleRow {
wk: number; type: string; dates: string; day: string;
city: string; host: string; time: string; truck: string; status: string; notes: string;
}
const scheduleRows: ScheduleRow[] = [];
let currentWeek: { wk: number; type: string; dates: string; day: string } | null = null;
for (let i = 4; i < allRows.length; i++) {
const row = allRows[i] as (string | null | undefined)[];
if (!row) continue;
const wkVal = row[wkIdx];
const Day = String(row[dayIdx] ?? "").trim();
const City_Location = String(row[cityIdx] ?? "").trim();
const Status = String(row[statusIdx] ?? "").trim();
if (wkVal !== null && wkVal !== undefined && String(wkVal).trim() !== "") {
currentWeek = {
wk: Number(wkVal),
type: String(row[typeIdx] ?? "").trim(),
dates: String(row[datesIdx] ?? "").trim(),
day: Day,
};
}
if (!currentWeek) continue;
if (Status === "Off day") continue;
if (!City_Location) continue;
scheduleRows.push({
wk: currentWeek.wk, type: currentWeek.type, dates: currentWeek.dates,
day: Day || currentWeek.day,
city: City_Location,
host: String(row[hostIdx] ?? "").trim(),
time: String(row[timeIdx] ?? "").trim(),
truck: String(row[truckIdx] ?? "").trim(),
status: Status,
notes: String(row[notesIdx] ?? "").trim(),
});
}
const ACTIVE_STATUSES = ["✓ Confirmed", "🔴 Pre-sale live"];
const activeRows = scheduleRows.filter((r) => ACTIVE_STATUSES.includes(r.status));
console.log("\nSchedule rows: " + scheduleRows.length + " total | " + activeRows.length + " active\n");
// PRODUCTS -- one core product for all stops
const products: ProductOutput[] = [
{
name: "Olathe Sweet Corn -- 24 Ear Box",
price: 25,
type: "Pickup",
description:
"Fresh Olathe Sweet Corn pickup box. Add to cart, then choose your pickup stop at checkout. " +
"Available July-September 2026 at stops across Colorado, Wyoming, and New Mexico. " +
"See tuxedocorn.com for full schedule.",
active: true,
image_url:
"https://tuxedocorn.com/wp-content/uploads/2024/06/110260317_3494904527187819_6983832261179080791_n-1.jpg",
is_taxable: true,
},
];
console.log("[products] 1 core product: \"" + products[0].name + "\" at $" + products[0].price + "\n");
// STOPS -- one row per active schedule entry
const stops: StopOutput[] = [];
const seenStopKeys = new Set<string>();
for (const row of activeRows) {
if (!row.city) continue;
// Parse "City ST" or "City, ST" -> city + state
const cityRaw = row.city.replace(/--/g, "-").trim();
let city = cityRaw, state = "CO";
if (cityRaw.includes(",")) {
const parts = cityRaw.split(",").map((s: string) => s.trim());
const lastPart = parts[parts.length - 1] ?? "";
if (/^[A-Z]{2}$/.test(lastPart)) { city = parts.slice(0, -1).join(", ").trim(); state = lastPart; }
else city = parts.join(", ");
} else {
const tokens = cityRaw.split(/\s+/);
const lastToken = tokens[tokens.length - 1] ?? "";
if (/^[A-Z]{2}$/.test(lastToken)) { state = lastToken; city = tokens.slice(0, -1).join(" "); }
}
const stopKey = slugify(city + "-" + state + "-" + row.dates + "-" + row.time);
if (seenStopKeys.has(stopKey)) continue;
seenStopKeys.add(stopKey);
// Human-readable stop label = "Store (Time) | Truck T1"
const stopName = row.host + " (" + row.time + ")" + (row.truck ? " | Truck " + row.truck : "");
stops.push({
city,
state,
location: stopName,
date: row.dates, // week range -- set actual date in /admin/stops after import
time: row.time,
address: row.city + " | " + row.host,
notes: row.day + (row.notes ? " | " + row.notes : ""),
});
}
console.log("[stops] " + stops.length + " stops written\n");
// Show sample by week
const byWeek: Record<string, typeof stops> = {};
for (const s of stops) {
if (!byWeek[s.date]) byWeek[s.date] = [];
byWeek[s.date].push(s);
}
const weeks = Object.keys(byWeek).slice(0, 4);
for (const week of weeks) {
const ws = byWeek[week];
console.log(" " + week + " (" + ws.length + " stops):");
for (const s of ws.slice(0, 3)) {
console.log(" " + s.city + ", " + s.state + " | \"" + s.location + "\" | " + s.time);
}
}
// Write output CSVs
fs.mkdirSync(outputDir, { recursive: true });
function writeCSV(filename: string, header: string, rows: string[][]) {
const lines = rows.map((r) =>
r.map((c) => "\"" + String(c ?? "").replace(/\"/g, "\"\"") + "\"").join(",")
);
fs.writeFileSync(path.join(outputDir, filename), header + lines.join("\n"));
}
writeCSV(
"products.csv",
"name,price,type,description,active,image_url,is_taxable\n",
products.map((p) => [
p.name, String(p.price), p.type, p.description,
String(p.active), p.image_url, String(p.is_taxable),
])
);
writeCSV(
"stops.csv",
"city,state,location,date,time,address,notes\n",
stops.map((s) => [s.city, s.state, s.location, s.date, s.time, s.address, s.notes])
);
// Summary
console.log("\n============================================================");
console.log("IMPORT READY");
console.log("============================================================");
console.log("\nproducts.csv: 1 product (\"" + products[0].name + "\" @ $" + products[0].price + " Pickup)");
console.log("stops.csv: " + stops.length + " stops (confirmed + pre-sale only)");
console.log("\nOutput: " + outputDir + "/");
console.log("");
console.log(">>> NOTE: stops.csv 'date' field is a WEEK RANGE (e.g. Jul 22-28).");
console.log("After importing stops, visit /admin/stops to set the actual date for each stop.");
console.log("");
console.log("NEXT STEPS:");
console.log(" 1. Upload " + outputDir + "/products.csv via /admin/import -> type: products");
console.log(" 2. Upload " + outputDir + "/stops.csv via /admin/import -> type: stops");
console.log(" 3. Visit /admin/stops -- set each stop's actual date, verify address/time");
console.log(" 4. Visit /tuxedo -- verify product and stops appear\n");
console.log("Re-run: npx tsx scripts/import-woo-to-route.ts --xlsx \"" + xlsxPath + "\" --brand " + brandId + " --output " + outputDir + "\n");
}
main().catch(console.error);
-96
View File
@@ -1,96 +0,0 @@
/**
* Seed an admin user for development.
* Run: npx tsx scripts/seed-admin.ts [email] [password] [name]
*/
import { config } from "dotenv";
config({ path: ".env.local" });
import pg from "pg";
const { Pool } = pg;
async function main() {
const email = process.argv[2] ?? "admin@test.com";
const password = process.argv[3] ?? "Admin1234!";
const name = process.argv[4] ?? "Admin";
const baseUrl = process.env.NEON_AUTH_BASE_URL!;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
// 1. Create or get brand
const brandResult = await pool.query(`
INSERT INTO brands (name, slug, plan_tier)
VALUES ('Demo Brand', 'demo', 'enterprise')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id, name, slug
`);
const brand = brandResult.rows[0];
console.log("✓ Brand:", brand.name, `(${brand.id})`);
// 2. Create user in Neon Auth via direct API
const res = await fetch(`${baseUrl}/sign-up/email`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Origin": "http://localhost:4000",
"x-neon-auth-proxy": "node",
},
body: JSON.stringify({ email, password, name, callbackURL: "/admin" }),
});
const data = await res.json();
if (!res.ok && data.code !== "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
console.error("✗ Failed to create user:", data);
process.exit(1);
}
const userId = data.user?.id;
console.log("✓ Neon Auth user:", email, userId ? `(${userId})` : "(already existed)");
// 3. Verify email in Neon Auth DB
if (userId) {
await pool.query(
`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`,
[userId]
);
console.log("✓ Email verified in DB");
}
// 4. Get user ID from neon_auth.user by email
const neonUser = await pool.query(
`SELECT id FROM neon_auth.user WHERE email = $1`,
[email]
);
const neonUserId = neonUser.rows[0]?.id;
if (!neonUserId) {
console.error("✗ Could not find user in neon_auth.user");
process.exit(1);
}
// 5. Insert into admin_users (upsert)
const adminResult = await pool.query(`
INSERT INTO admin_users (user_id, email, name, role)
VALUES ($1, $2, $3, 'brand_admin')
ON CONFLICT (email) DO UPDATE SET user_id = EXCLUDED.user_id, name = EXCLUDED.name, role = EXCLUDED.role
RETURNING id
`, [neonUserId, email, name]);
const adminUser = adminResult.rows[0];
console.log("✓ Admin user:", email, `(${adminUser.id})`);
// 6. Link to brand
await pool.query(`
INSERT INTO admin_user_brands (admin_user_id, brand_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, [adminUser.id, brand.id]);
console.log("✓ Linked to brand:", brand.name);
console.log(`\n🎉 All set! Log in at http://localhost:4000/login`);
console.log(` Email: ${email}`);
console.log(` Password: ${password}`);
} finally {
await pool.end();
}
}
main();
-41
View File
@@ -1,41 +0,0 @@
import { config } from "dotenv";
config({ path: ".env.local" });
import pg from "pg";
const { Pool } = pg;
const TUXEDO = "64294306-5f42-463d-a5e8-2ad6c81a96de";
async function main() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
// brand_settings
const bs = await pool.query(
`INSERT INTO brand_settings (brand_id, legal_business_name, phone, email, logo_url, tagline, from_email)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (brand_id) DO UPDATE SET legal_business_name = EXCLUDED.legal_business_name
RETURNING brand_id, legal_business_name`,
[TUXEDO, "Tuxedo Corn LLC", "970-555-0100", "info@tuxedocorn.com",
"/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png",
"Farm-Fresh Sweet Corn, Delivered", "orders@tuxedocorn.com"]
);
console.log("brand_settings:", bs.rows[0]);
// wholesale_settings
const ws = await pool.query(
`INSERT INTO wholesale_settings (brand_id, require_approval, pickup_location, fob_location, from_email)
VALUES ($1, true, $2, $3, $4)
ON CONFLICT (brand_id) DO UPDATE SET pickup_location = EXCLUDED.pickup_location
RETURNING brand_id, pickup_location`,
[TUXEDO, "59751 David Road, Olathe, CO 81425", "FOB Olathe, CO", "orders@tuxedocorn.com"]
);
console.log("wholesale_settings:", ws.rows[0]);
console.log("\nDone!");
} finally {
await pool.end();
}
}
main().catch((e) => { console.error(e.message); process.exit(1); });
-343
View File
@@ -1,343 +0,0 @@
#!/bin/bash
# Seed script for Route Commerce Platform
# Run with: bash scripts/seed.sh
set -e
# Use service role key for write operations during seeding
SUPABASE_URL="${SUPABASE_URL:-https://your-project.supabase.co}"
SERVICE_KEY="${SUPABASE_SERVICE_ROLE_KEY}"
ANON_KEY="${ANON_KEY:-$NEXT_PUBLIC_SUPABASE_ANON_KEY}"
AUTH_HEADER="apikey: $SERVICE_KEY"
CONTENT_TYPE="Content-Type: application/json"
echo "Fetching brands..."
BRANDS=$(curl -s "$SUPABASE_URL/rest/v1/brands?select=id,name" \
-H "$AUTH_HEADER")
echo "Brands: $BRANDS"
TUXEDO_ID=$(echo "$BRANDS" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
IRD_ID=$(echo "$BRANDS" | grep -o '"id":"[^"]*"' | tail -1 | cut -d'"' -f4)
echo "Tuxedo: $TUXEDO_ID"
echo "IRD: $IRD_ID"
echo ""
echo "Creating stops..."
STOP1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$TUXEDO_ID"'",
"city": "Burlington",
"state": "NC",
"date": "2026-05-15",
"time": "8:00 AM 2:00 PM",
"location": "102 W Front St, Burlington, NC",
"slug": "burlington-nc-2026-05-15",
"active": true
}')
echo "Stop 1: $STOP1"
STOP2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$TUXEDO_ID"'",
"city": "Greensboro",
"state": "NC",
"date": "2026-05-16",
"time": "8:00 AM 2:00 PM",
"location": "200 S Elm St, Greensboro, NC",
"slug": "greensboro-nc-2026-05-16",
"active": true
}')
echo "Stop 2: $STOP2"
STOP3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$IRD_ID"'",
"city": "Fort Pierce",
"state": "FL",
"date": "2026-05-15",
"time": "7:00 AM 1:00 PM",
"location": "1224 N US-1, Fort Pierce, FL",
"slug": "fort-pierce-fl-2026-05-15",
"active": true
}')
echo "Stop 3: $STOP3"
echo ""
echo "Creating products..."
PROD1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$TUXEDO_ID"'",
"name": "Sweet Corn",
"description": "Fresh picked white sweet corn, 8 ears per bag",
"price": 12,
"type": "Sweet Corn",
"active": true
}')
echo "Prod 1: $PROD1"
PROD2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$TUXEDO_ID"'",
"name": "Peaches",
"description": "Just-peeled freestone peaches, pint jar",
"price": 8,
"type": "Fruit",
"active": true
}')
echo "Prod 2: $PROD2"
PROD3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$TUXEDO_ID"'",
"name": "Corn & Peach Bundle",
"description": "Sweet corn + peaches combo, save $2",
"price": 18,
"type": "Bundle",
"active": true
}')
echo "Prod 3: $PROD3"
PROD4=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$IRD_ID"'",
"name": "Navel Oranges",
"description": "Premium navel oranges, 4 lb bag",
"price": 15,
"type": "Citrus",
"active": true
}')
echo "Prod 4: $PROD4"
PROD5=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$IRD_ID"'",
"name": "Grapefruit",
"description": "Ruby red grapefruit, 3 lb bag",
"price": 12,
"type": "Citrus",
"active": true
}')
echo "Prod 5: $PROD5"
PROD6=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"brand_id": "'"$IRD_ID"'",
"name": "Citrus Sampler",
"description": "Oranges, grapefruit & tangelos, 6 lb mix",
"price": 22,
"type": "Bundle",
"active": true
}')
echo "Prod 6: $PROD6"
P1_ID=$(echo "$PROD1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
P2_ID=$(echo "$PROD2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
P3_ID=$(echo "$PROD3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
P4_ID=$(echo "$PROD4" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
P5_ID=$(echo "$PROD5" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
P6_ID=$(echo "$PROD6" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
S1_ID=$(echo "$STOP1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
S2_ID=$(echo "$STOP2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
S3_ID=$(echo "$STOP3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
echo ""
echo "Assigning products to stops..."
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P1_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P2_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P3_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S2_ID"'", "product_id": "'"$P1_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S2_ID"'", "product_id": "'"$P3_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P4_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P5_ID"'"}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P6_ID"'"}' > /dev/null
echo ""
echo "Creating orders..."
ORDER1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"customer_name": "Sarah Mitchell",
"customer_email": "sarah.m@email.com",
"customer_phone": "(336) 555-0142",
"stop_id": "'"$S1_ID"'",
"status": "pending",
"subtotal": 32,
"discount_amount": 0,
"pickup_complete": false,
"payment_processor": "manual",
"payment_status": "paid"
}')
echo "Order 1: $ORDER1"
ORDER2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"customer_name": "James Rivera",
"customer_email": "jrivera@email.com",
"customer_phone": "(336) 555-0298",
"stop_id": "'"$S1_ID"'",
"status": "confirmed",
"subtotal": 22,
"discount_amount": 2,
"discount_reason": "First-time customer",
"pickup_complete": true,
"payment_processor": "manual",
"payment_status": "paid"
}')
echo "Order 2: $ORDER2"
ORDER3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"customer_name": "Lisa Chen",
"customer_phone": "(919) 555-0763",
"stop_id": "'"$S2_ID"'",
"status": "pending",
"subtotal": 12,
"pickup_complete": false,
"payment_processor": "manual",
"payment_status": "pending"
}')
echo "Order 3: $ORDER3"
ORDER4=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"customer_name": "Marcus Thompson",
"customer_email": "mthompson@email.com",
"customer_phone": "(772) 555-0119",
"stop_id": "'"$S3_ID"'",
"status": "confirmed",
"subtotal": 37,
"pickup_complete": false,
"payment_processor": "venmo",
"payment_status": "paid",
"payment_transaction_id": "VEN-88420"
}')
echo "Order 4: $ORDER4"
ORDER5=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
-H "$AUTH_HEADER" \
-H "$CONTENT_TYPE" \
-H "Prefer: return=representation" \
-d '{
"customer_name": "Amanda Foster",
"customer_email": "afoster@email.com",
"stop_id": "'"$S3_ID"'",
"status": "cancelled",
"subtotal": 15,
"pickup_complete": false,
"internal_notes": "Customer cancelled after hours, no show next day",
"payment_processor": "manual",
"payment_status": "refunded",
"refunded_amount": 15,
"refund_reason": "Customer cancelled"
}')
echo "Order 5: $ORDER5"
O1_ID=$(echo "$ORDER1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
O2_ID=$(echo "$ORDER2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
O3_ID=$(echo "$ORDER3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
O4_ID=$(echo "$ORDER4" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
O5_ID=$(echo "$ORDER5" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
echo ""
echo "Creating order items..."
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O1_ID"'", "product_id": "'"$P1_ID"'", "quantity": 2, "price": 12}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O1_ID"'", "product_id": "'"$P2_ID"'", "quantity": 1, "price": 8}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O2_ID"'", "product_id": "'"$P3_ID"'", "quantity": 1, "price": 18}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O2_ID"'", "product_id": "'"$P1_ID"'", "quantity": 2, "price": 12}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O3_ID"'", "product_id": "'"$P1_ID"'", "quantity": 1, "price": 12}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O4_ID"'", "product_id": "'"$P4_ID"'", "quantity": 2, "price": 15}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O4_ID"'", "product_id": "'"$P5_ID"'", "quantity": 1, "price": 12}' > /dev/null
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d '{"order_id": "'"$O5_ID"'", "product_id": "'"$P4_ID"'", "quantity": 1, "price": 15}' > /dev/null
echo ""
echo "Done! Seed data created:"
echo " 3 stops (Burlington NC, Greensboro NC, Fort Pierce FL)"
echo " 6 products (3 Tuxedo Corn, 3 Indian River Direct)"
echo " 5 orders with items"
echo ""
echo "Quick test URLs:"
echo " /admin/orders"
echo " /admin/products"
echo " /admin/stops"
-343
View File
@@ -1,343 +0,0 @@
#!/usr/bin/env python3
"""
seed_tuxedo_tour.py
Parses Tuxedo_Corn_2026_Tour_Schedule-3.xlsx and seeds the `stops` table for
the Tuxedo brand via the admin_create_stops_batch RPC.
Skips:
- Title / subtitle / legend rows (rows 1-3)
- Week header rows (col A = "Wk N", col D-J empty)
- Cross-Dock / Monday OFF rows (col D contains "OFF" or "Cross-Dock")
Joins with the Stop Directory sheet to enrich each stop with:
- address, phone, contact
Usage:
python3 scripts/seed_tuxedo_tour.py --dry-run # show what would be inserted
python3 scripts/seed_tuxedo_tour.py # actually insert
Requires:
- supabase CLI linked to project wnzkhezyhnfzhkhiflrp
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from openpyxl import load_workbook
TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"
YEAR = 2026
MONTH_MAP = {
"Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04",
"May": "05", "Jun": "06", "Jul": "07", "Aug": "08",
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
}
DEFAULT_XLSX = "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" # run from repo root; override with --xlsx /path/to/file.xlsx
def parse_excel_date(s):
"""'Jul 22' -> '2026-07-22'"""
if not s:
return None
m = re.match(r"^([A-Za-z]{3})\s+(\d{1,2})$", str(s).strip())
if not m:
return None
mm = MONTH_MAP.get(m.group(1))
if not mm:
return None
return f"{YEAR}-{mm}-{int(m.group(2)):02d}"
def parse_time_range(s):
"""'10:00 AM - 1:00 PM' -> '10:00 AM' (start time)"""
if not s:
return ""
cleaned = re.sub(r"[–—]", "-", str(s)).strip()
cleaned = re.sub(r"\s+", " ", cleaned)
m = re.match(r"^(\d{1,2}:\d{2}\s*[AP]M)", cleaned, re.IGNORECASE)
return m.group(1).upper().replace(" ", " ") if m else cleaned
def split_city_state(s):
"""'Cheyenne, WY' -> ('Cheyenne', 'WY')"""
if not s:
return "", ""
parts = [p.strip() for p in str(s).split(",")]
if len(parts) == 1:
return parts[0], ""
return parts[0], parts[1]
def slugify(s):
s = (s or "").lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
def is_week_header(row):
a = str(row[0] or "").strip()
d = str(row[3] or "").strip()
return re.match(r"^Wk\s", a) and d == ""
def is_off_row(row):
d = str(row[3] or "").strip()
return "OFF" in d or "Cross-Dock" in d or "CrossDock" in d
def is_data_row(row):
d = str(row[3] or "").strip()
e = str(row[4] or "").strip()
if not d or not e:
return False
if "," not in e:
return False
return True
def load(xlsx_path):
wb = load_workbook(xlsx_path, data_only=True)
schedule = wb["Full Schedule"]
directory = wb["Stop Directory"]
# Build Stop Directory lookup: (truck, host_normalized) -> {address, phone, contact, ...}
dir_map = {}
for row in directory.iter_rows(min_row=2, values_only=True):
truck = str(row[0] or "").strip()
city = str(row[1] or "").strip()
state = str(row[2] or "").strip()
host = str(row[3] or "").strip()
address = str(row[4] or "").strip()
phone = str(row[5] or "").strip()
contact = str(row[6] or "").strip()
if not truck or not host:
continue
key = f"{truck}|{host.lower()}"
dir_map[key] = {
"city": city, "state": state, "host": host,
"address": address, "phone": phone, "contact": contact,
}
# Read Full Schedule (skip first 3 title/subtitle/legend rows)
stops = []
skipped = {"weekHeader": 0, "off": 0, "invalid": 0}
for row in schedule.iter_rows(min_row=4, values_only=True):
# Trim to 10 cols
cells = [("" if v is None else str(v).strip()) for v in row[:10]]
if is_week_header(cells):
skipped["weekHeader"] += 1
continue
if is_off_row(cells):
skipped["off"] += 1
continue
if not is_data_row(cells):
skipped["invalid"] += 1
continue
wk, region, date_text, day, city_state, host, time, truck, status, notes = cells
date_iso = parse_excel_date(date_text)
if not date_iso:
skipped["invalid"] += 1
continue
city, state = split_city_state(city_state)
if not city:
skipped["invalid"] += 1
continue
# Enrich from directory
dir_key = f"{truck}|{host.lower()}"
d = dir_map.get(dir_key)
stops.append({
"week": wk,
"region": region,
"date": date_iso,
"day": day,
"city": city,
"state": state or (d["state"] if d else ""),
"location": host,
"time": parse_time_range(time),
"time_range": time,
"truck": truck,
"status_text": status,
"notes": notes,
"address": d["address"] if d and d["address"] else None,
"phone": d["phone"] if d and d["phone"] else None,
"contact": d["contact"] if d and d["contact"] else None,
})
return stops, skipped, len(dir_map)
def assign_slugs(stops, dry_run):
used = set()
if not dry_run:
out = subprocess.run(
["supabase", "db", "query", "--linked",
f"SELECT slug FROM stops WHERE brand_id = '{TUXEDO_BRAND_ID}';"],
capture_output=True, text=True, timeout=120,
)
# Parse the table output - slugs are in second column between │
for m in re.finditer(r"\s*([a-z0-9][a-z0-9-]*)\s*│", out.stdout):
used.add(m.group(1))
for s in stops:
base = f"{slugify(s['city'])}-{s['date']}"
slug = base
n = 0
while slug in used:
n += 1
slug = f"{base}-{n}"
used.add(slug)
s["slug"] = slug
def to_rpc_row(s):
return {
"city": s["city"],
"state": s["state"],
"location": s["location"],
"date": f"{s['date']} 00:00:00+00",
"time": s["time"],
"address": s["address"],
"zip": None,
"cutoff_time": None,
# active=true so the stops appear on the public storefront immediately.
# Matches the behavior of publishStop in src/actions/stops.ts.
"active": True,
}
def build_payload_json(batch):
"""Build a clean JSON string for use in a SQL file."""
return json.dumps(batch, ensure_ascii=False)
def insert_batch(batch):
"""Write SQL to a temp file and execute via --file to avoid shell escaping."""
payload_json = build_payload_json(batch)
sql = (
f"SELECT admin_create_stops_batch("
f"'{TUXEDO_BRAND_ID}'::uuid, "
f"$${payload_json}$$::jsonb);\n"
)
# Write to temp file
tmp_path = Path("/tmp/seed_tuxedo_tour.sql")
tmp_path.write_text(sql, encoding="utf-8")
try:
proc = subprocess.run(
["supabase", "db", "query", "--linked", "--file", str(tmp_path)],
capture_output=True, text=True, timeout=300,
)
finally:
tmp_path.unlink(missing_ok=True)
if proc.returncode != 0:
raise RuntimeError(f"RPC failed: {proc.stderr[:800]}")
return proc.stdout
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--xlsx", default=DEFAULT_XLSX)
args = ap.parse_args()
if not Path(args.xlsx).exists():
sys.exit(f"XLSX not found: {args.xlsx}")
stops, skipped, dir_count = load(args.xlsx)
assign_slugs(stops, dry_run=args.dry_run)
print(f"\nParsed {len(stops)} stops "
f"(skipped: {skipped['weekHeader']} week-headers, "
f"{skipped['off']} OFF days, {skipped['invalid']} invalid)")
print(f"Stop Directory: {dir_count} entries loaded for enrichment\n")
if not stops:
sys.exit("No stops to insert.")
print("Sample (first 3):")
for s in stops[:3]:
print(f" {s['date']} {s['time']:10s} | {s['city']:18s}, {s['state']:2s} | "
f"{s['location'][:35]:35s} | {s['truck']} | {s['status_text']} | {s['slug']}")
if s["notes"]:
print(f" notes: {s['notes'][:120]}")
if s["address"]:
print(f" addr: {s['address']} ph: {s['phone']} ctc: {s['contact']}")
print()
# Show counts by week and region
by_week = {}
by_region = {}
by_truck = {}
for s in stops:
by_week[s["week"]] = by_week.get(s["week"], 0) + 1
by_region[s["region"]] = by_region.get(s["region"], 0) + 1
by_truck[s["truck"]] = by_truck.get(s["truck"], 0) + 1
print("By week:", dict(sorted(by_week.items())))
print("By region:", by_region)
print("By truck:", by_truck)
print()
# Date range
dates = sorted(s["date"] for s in stops)
print(f"Date range: {dates[0]} to {dates[-1]}\n")
if args.dry_run:
batches = (len(stops) + 49) // 50
print(f"[DRY RUN] Would insert {len(stops)} stops in {batches} batch(es) of 50.")
return
BATCH = 50
total = 0
batches = (len(stops) + BATCH - 1) // BATCH
for i in range(0, len(stops), BATCH):
batch = [to_rpc_row(s) for s in stops[i:i + BATCH]]
bnum = i // BATCH + 1
sys.stdout.write(f" Inserting batch {bnum}/{batches} ({len(batch)} stops)... ")
sys.stdout.flush()
try:
insert_batch(batch)
total += len(batch)
print("OK")
except Exception as e:
print("FAIL")
print(f" {e}")
# The batch RPC hardcodes status='draft' on insert. The Tuxedo storefront
# page only filters on active=true (not status), so active=true is enough
# to make stops visible. But for consistency with the publishStop server
# action — which sets both — flip status to 'active' for the rows we just
# inserted. Slug-based so we only touch stops from this run, not the
# pre-existing "Olathe" test stop.
if total > 0:
slugs = [s["slug"] for s in stops]
# Build a safe IN list (slug is a text column)
slug_list = ", ".join(f"'{slug.replace(chr(39), chr(39)+chr(39))}'" for slug in slugs)
publish_sql = (
f"UPDATE stops SET status = 'active' "
f"WHERE brand_id = '{TUXEDO_BRAND_ID}' "
f"AND slug IN ({slug_list});"
)
tmp = Path("/tmp/seed_tuxedo_publish.sql")
tmp.write_text(publish_sql, encoding="utf-8")
try:
subprocess.run(
["supabase", "db", "query", "--linked", "--file", str(tmp)],
capture_output=True, text=True, timeout=120,
)
print(f"\n Published {total} stops (status -> 'active').")
finally:
tmp.unlink(missing_ok=True)
print(f"\nDone. Inserted {total}/{len(stops)} stops for Tuxedo brand.")
if __name__ == "__main__":
main()
-74
View File
@@ -1,74 +0,0 @@
import { createClient } from "@supabase/supabase-js";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import path from "path";
// Load .env.local manually
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const envPath = path.join(__dirname, "..", ".env.local");
const envContent = readFileSync(envPath, "utf8");
envContent.split("\n").forEach((line) => {
const [key, ...rest] = line.split("=");
if (key && rest.length && key.trim() && !key.trim().startsWith("#")) {
process.env[key.trim()] = rest.join("=").trim().replace(/^"|"$/g, "");
}
});
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
console.error("Missing env vars");
process.exit(1);
}
const supabase = createClient(supabaseUrl, supabaseServiceKey);
async function uploadTuxedoHeroVideo() {
const filePath = "/Users/kylemartinez/Downloads/062624TuxedoCorn 001.mp4";
const fileBuffer = readFileSync(filePath);
const fileSizeMB = (fileBuffer.byteLength / 1024 / 1024).toFixed(2);
console.log(`File: ${filePath} (${fileSizeMB} MB)`);
// Ensure public videos bucket exists
const { data: buckets, error: listErr } = await supabase.storage.listBuckets();
if (listErr) { console.error("List buckets error:", listErr.message); process.exit(1); }
const existing = buckets?.find((b) => b.name === "videos");
if (!existing) {
console.log("Creating 'videos' bucket (public)...");
const { error: createErr } = await supabase.storage.createBucket("videos", { public: true });
if (createErr) { console.error("Create bucket error:", createErr.message); process.exit(1); }
console.log("Bucket 'videos' created.");
} else {
console.log(`'videos' bucket exists (public: ${existing.public}).`);
if (!existing.public) {
const { error: updErr } = await supabase.storage.updateBucket("videos", { public: true });
if (updErr) console.warn("Could not update bucket to public:", updErr.message);
else console.log("Bucket updated to public.");
}
}
// Upload
console.log("Uploading tuxedo-hero.mp4...");
const { data: uploadData, error: uploadErr } = await supabase.storage
.from("videos")
.upload("tuxedo-hero.mp4", fileBuffer, {
contentType: "video/mp4",
upsert: true,
});
if (uploadErr) {
console.error("Upload error:", uploadErr.message);
process.exit(1);
}
console.log("Upload OK:", JSON.stringify(uploadData));
// Public URL
const { data: urlData } = supabase.storage.from("videos").getPublicUrl("tuxedo-hero.mp4");
const url = urlData.publicUrl;
console.log(`\n✅ Public URL:\n${url}`);
}
uploadTuxedoHeroVideo().catch((e) => { console.error(e); process.exit(1); });
-11
View File
@@ -1,11 +0,0 @@
import { config } from "dotenv";
config({ path: ".env.local" });
import pg from "pg";
const { Pool } = pg;
async function main() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const r = await pool.query(`SELECT id, email, "emailVerified" FROM neon_auth.user WHERE email = 'admin@test.com'`);
console.log(JSON.stringify(r.rows, null, 2));
await pool.end();
}
main();
-132
View File
@@ -1,132 +0,0 @@
#!/usr/bin/env node
/**
* verify-stop-fns.js
*
* Confirms `admin_create_stop` and `admin_create_stops_batch` exist in the
* live database with the exact signatures the client uses, and that the
* PostgREST schema cache has them registered.
*
* Run: node scripts/verify-stop-fns.js
*
* Replaces the older `check-stop-fns*.js` scripts (left in the repo for
* history; this one is the canonical post-fix verification).
*
* Exits 0 on success, 1 if any check fails.
*/
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !serviceKey || !anonKey) {
console.error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / NEXT_PUBLIC_SUPABASE_ANON_KEY in .env.local");
process.exit(1);
}
const SERVICE_HEADERS = {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
"Content-Type": "application/json",
};
const ANON_HEADERS = {
apikey: anonKey,
Authorization: `Bearer ${anonKey}`,
"Content-Type": "application/json",
};
let failed = 0;
const fail = (msg) => { console.error("✗ " + msg); failed += 1; };
const pass = (msg) => console.log("✓ " + msg);
async function rpc(name, body, headers = SERVICE_HEADERS) {
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
const text = await r.text();
let parsed;
try { parsed = JSON.parse(text); } catch { parsed = text.slice(0, 200); }
return { status: r.status, body: parsed };
}
(async () => {
console.log(`\nVerifying stop RPCs against ${url}\n`);
// ── 1. admin_create_stop — empty body (expect 400/422, NOT PGRST202) ─────
// PGRST202 = function not in schema cache. We want a 400-style validation
// error here, which proves PostgREST knows the function exists.
console.log("1. admin_create_stop signature check (PostgREST cache)");
const probe1 = await rpc("admin_create_stop", {});
if (probe1.status === 404) {
fail("admin_create_stop returned 404 — function not found (PGRST202 cache miss). " +
"Did you apply migration 147 and run `NOTIFY pgrst, 'reload schema'`?");
} else if (probe1.status >= 500) {
fail(`admin_create_stop returned ${probe1.status} — server error. Body: ${JSON.stringify(probe1.body)}`);
} else {
pass(`admin_create_stop reachable in PostgREST (status ${probe1.status})`);
}
// ── 2. admin_create_stop — happy path with a real (but harmless) brand ──
console.log("\n2. admin_create_stop happy-path call (anon key)");
const probe2 = await rpc(
"admin_create_stop",
{
p_brand_id: "00000000-0000-0000-0000-000000000000",
p_city: "verify-script-city",
p_state: "CO",
p_location: "verification stop",
p_date: "2099-12-31",
p_time: "08:00 AM 02:00 PM",
p_address: "123 Verify St",
p_zip: "80000",
p_cutoff_time: "08:00", // time-only — should be combined with date
p_active: false,
},
ANON_HEADERS
);
if (probe2.status === 404) {
fail("admin_create_stop not callable via anon key. " +
"GRANT EXECUTE missing? Body: " + JSON.stringify(probe2.body));
} else if (probe2.status >= 400) {
// Expected: brand_id is the zero UUID (foreign key violation). That's fine —
// it proves the function executed past type validation.
const msg = JSON.stringify(probe2.body);
if (/foreign key|violates/i.test(msg)) {
pass(`admin_create_stop ran end-to-end (got expected FK violation: ${msg.slice(0, 120)}...)`);
} else {
fail(`admin_create_stop failed unexpectedly (${probe2.status}): ${msg}`);
}
} else {
pass(`admin_create_stop returned success: ${JSON.stringify(probe2.body)}`);
}
// ── 3. admin_create_stops_batch — same checks ───────────────────────────
console.log("\n3. admin_create_stops_batch signature check");
const probe3 = await rpc("admin_create_stops_batch", { p_brand_id: null, p_stops: [] });
if (probe3.status === 404) {
fail("admin_create_stops_batch returned 404 — function not in schema cache.");
} else {
pass(`admin_create_stops_batch reachable (status ${probe3.status})`);
}
// ── 4. OpenAPI introspection — confirm functions are exposed ────────────
console.log("\n4. OpenAPI introspection");
const openApi = await fetch(`${url}/rest/v1/`, { headers: ANON_HEADERS });
const text = await openApi.text();
const hasSingle = /admin_create_stop\b/.test(text);
const hasBatch = /admin_create_stops_batch\b/.test(text);
if (hasSingle) pass("admin_create_stop appears in OpenAPI spec");
else fail("admin_create_stop NOT in OpenAPI spec — PostgREST hasn't seen it yet");
if (hasBatch) pass("admin_create_stops_batch appears in OpenAPI spec");
else fail("admin_create_stops_batch NOT in OpenAPI spec");
console.log(`\n${failed === 0 ? "✓ All checks passed" : `${failed} check(s) failed`}\n`);
process.exit(failed === 0 ? 0 : 1);
})().catch((e) => {
console.error("verify-stop-fns.js crashed:", e);
process.exit(1);
});
+1 -1
View File
@@ -273,7 +273,7 @@ export async function createAdminUser(
// admin endpoint requires the caller to have role='admin' in // admin endpoint requires the caller to have role='admin' in
// Neon Auth, which is not always true (provision-admin.ts does // Neon Auth, which is not always true (provision-admin.ts does
// not set it). Falling back to sign-up is a safe, well-tested // not set it). Falling back to sign-up is a safe, well-tested
// path — see scripts/seed-admin.ts. // path.
const code = adminResult.error.code ?? ""; const code = adminResult.error.code ?? "";
const isAuthRequired = const isAuthRequired =
code === "FAILED_TO_CREATE_USER" || code === "FAILED_TO_CREATE_USER" ||
-77
View File
@@ -1,77 +0,0 @@
"use server";
import { supabase } from "@/lib/supabase";
export type AIAuthConfig = {
provider: string;
api_key: string;
organization_id: string;
base_url: string;
model: string;
max_tokens: number;
};
export async function getAIPreferences(brandId: string): Promise<{
api_key?: string;
organization_id?: string;
base_url?: string;
model?: string;
max_tokens?: number;
} | null> {
const { data, error } = await supabase
.from("brand_ai_settings")
.select("api_key, organization_id, base_url, model, max_tokens")
.eq("brand_id", brandId)
.single();
if (error || !data) return null;
return data;
}
export async function saveAIPreferences(
brandId: string,
config: AIAuthConfig
): Promise<{ success: boolean; error?: string }> {
const result = await supabase
.from("brand_ai_settings")
.upsert({
brand_id: brandId,
provider: config.provider,
api_key: config.api_key || null,
organization_id: config.organization_id || null,
base_url: config.base_url || null,
model: config.model || "gpt-4o-mini",
max_tokens: config.max_tokens || 4000,
updated_at: new Date().toISOString(),
}) as { data: unknown; error: { message: string } | null };
if (result.error) return { success: false, error: result.error.message };
return { success: true };
}
export async function testAIConnection(config: AIAuthConfig): Promise<{ ok: boolean; message: string }> {
if (!config.api_key?.trim()) {
return { ok: false, message: "API key is required" };
}
try {
const baseUrl = config.base_url?.trim() || "https://api.openai.com/v1";
const url = `${baseUrl.replace(/\/$/, "")}/models`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${config.api_key}`,
},
});
if (response.ok) {
return { ok: true, message: "Connection successful! Your API key is valid." };
} else if (response.status === 401) {
return { ok: false, message: "Invalid API key. Please check and try again." };
} else {
return { ok: false, message: `Error: ${response.status} ${response.statusText}` };
}
} catch {
return { ok: false, message: "Could not connect. Check your network and API key." };
}
}
+4 -3
View File
@@ -68,9 +68,10 @@ export type ConversionFunnel = {
/** /**
* Aggregate KPIs over a date window. Replaces the * Aggregate KPIs over a date window. Replaces the
* `get_reports_summary` SECURITY DEFINER RPC from * `get_reports_summary` SECURITY DEFINER RPC (originally from the now-
* `supabase/migrations/031_reports_v1_rpcs.sql`. Returns zeroed metrics * archived supabase migrations; consolidated into
* if the caller is unauthenticated or no orders exist in the window. * `db/migrations/0001_init.sql`). Returns zeroed metrics if the caller
* is unauthenticated or no orders exist in the window.
*/ */
async function getReportsSummary( async function getReportsSummary(
brandId: string | null, brandId: string | null,
+15 -5
View File
@@ -243,14 +243,24 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
} }
} }
// Public version for storefront pages — uses slug, no auth required // Public version for storefront pages — uses slug, no auth required.
//
// Inlined as a join against `brands` + `brand_settings` LEFT JOIN
// `wholesale_settings` instead of calling `get_brand_settings_by_slug`.
// That RPC is referenced from this code path but is not defined in any
// shipped migration, so the function-on-the-server call would throw
// `function get_brand_settings_by_slug(unknown) does not exist` and the
// storefront would silently fall back to defaults. The inlined query
// has the same shape and never fails because of a missing RPC.
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> { export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
// Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
// crash the prerender — the page just falls back to its default brand
// name and revalidates from a real request later.
try { try {
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>( const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
"SELECT * FROM get_brand_settings_by_slug($1)", `SELECT bs.*, b.name AS brand_name, ws.wholesale_enabled
FROM brands b
JOIN brand_settings bs ON bs.brand_id = b.id
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
WHERE b.slug = $1
LIMIT 1`,
[brandSlug], [brandSlug],
); );
const data = rows[0]; const data = rows[0];
+66
View File
@@ -28,6 +28,30 @@ export type DashboardSummary = {
active_products: number; active_products: number;
}; };
/**
* Mobile-dashboard summary shape. Returned by the `get_dashboard_summary`
* RPC (migration 0091) and consumed by `/admin/v2` to power the four
* stat cards + the 7-day order chart.
*
* - `revenue_today` is in CENTS (not dollars) — divide by 100 for display.
* - `orders_last_7_days` is oldest → today (7 elements, includes today).
*/
export type MobileDashboardSummary = {
orders_today: number;
revenue_today: number;
pending_fulfillment: number;
stops_today: number;
orders_last_7_days: Array<{ date: string; count: number }>;
};
const EMPTY_MOBILE_DASHBOARD: MobileDashboardSummary = {
orders_today: 0,
revenue_today: 0,
pending_fulfillment: 0,
stops_today: 0,
orders_last_7_days: [],
};
// ── Dashboard Stats Actions ───────────────────────────────────────────────── // ── Dashboard Stats Actions ─────────────────────────────────────────────────
export async function getDashboardStats(): Promise<DashboardStats> { export async function getDashboardStats(): Promise<DashboardStats> {
@@ -276,6 +300,48 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
} }
} }
/**
* Fetch the mobile dashboard summary for a single brand.
*
* Different name from the existing `getDashboardSummary()` (which is the
* legacy 4-field shape used by the v1 dashboard) so both can coexist.
* Returns safe fallbacks on any error — the v2 page renders a 0s
* dashboard rather than crashing the shell.
*/
export async function getMobileDashboardSummary(
brandId: string,
): Promise<MobileDashboardSummary> {
try {
const adminUser = await getAdminUser();
if (!adminUser) {
return EMPTY_MOBILE_DASHBOARD;
}
const { rows } = await pool.query<{ data: MobileDashboardSummary }>(
"SELECT get_dashboard_summary($1::uuid) AS data",
[brandId],
);
const data = rows[0]?.data;
if (!data) {
return EMPTY_MOBILE_DASHBOARD;
}
return {
orders_today: Number(data.orders_today ?? 0),
revenue_today: Number(data.revenue_today ?? 0),
pending_fulfillment: Number(data.pending_fulfillment ?? 0),
stops_today: Number(data.stops_today ?? 0),
orders_last_7_days: Array.isArray(data.orders_last_7_days)
? data.orders_last_7_days.map((d) => ({
date: String(d?.date ?? ""),
count: Number(d?.count ?? 0),
}))
: [],
};
} catch (error) {
console.error("Failed to fetch mobile dashboard summary:", error);
return EMPTY_MOBILE_DASHBOARD;
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────────
function formatTimeAgo(dateString: string): string { function formatTimeAgo(dateString: string): string {
+3 -2
View File
@@ -11,8 +11,9 @@ export type ImportOrdersResult =
/** /**
* Bulk-import orders. Replaces the legacy `create_order_with_items` SECURITY * Bulk-import orders. Replaces the legacy `create_order_with_items` SECURITY
* DEFINER RPC (`supabase/migrations/021_shipping_only_brand.sql`). The new * DEFINER RPC (originally from the now-archived supabase migrations;
* `orders` schema doesn't have `subtotal`, `customer_name`, `customer_email`, * consolidated into `db/migrations/0001_init.sql`). The new `orders`
* schema doesn't have `subtotal`, `customer_name`, `customer_email`,
* `customer_phone`, or `stop_id` columns — totals are stored in * `customer_phone`, or `stop_id` columns — totals are stored in
* `total_cents` and customers are referenced by `customer_id`. For each * `total_cents` and customers are referenced by `customer_id`. For each
* imported order we upsert a `customers` row keyed on email+tenant, then * imported order we upsert a `customers` row keyed on email+tenant, then
+88
View File
@@ -0,0 +1,88 @@
"use server";
import { revalidatePath } from "next/cache";
/**
* Offline mutation dispatcher.
*
* The v2 mobile admin (PR 3) introduces an IndexedDB-backed mutation
* queue (src/lib/offline/queue.ts) and a sync engine
* (src/lib/offline/sync.ts) that replays queued actions when the
* device comes back online. The client side enqueues an action with a
* name + JSON payload + idempotency key (clientActionId), then calls
* `syncPending` which invokes this dispatcher for each pending action.
*
* The dispatcher is the single server-side bridge between the offline
* queue and our SECURITY DEFINER server actions. To add a new offline-
* capable action, append an entry to `listClientActions()` with:
* - a stable name (kebab- or camelCase, the client enqueues by name)
* - a handler that accepts the JSON payload and returns
* { success: true } | { success: false; error: string }
*
* The action names referenced by the plan (markOrderReady,
* markOrderPickedUp, updateStopStatus, adjustProductStock) do not yet
* exist as server actions in the v1 codebase. They're added in
* follow-up PRs. For now the dispatcher returns `{ ok: false, error:
* "Unknown action" }` for them, which surfaces in the OfflineBanner
* conflict count. This keeps the structure in place without coupling
* PR 3 to a server-side action surface it doesn't own.
*
* Idempotency: each enqueue carries a clientActionId (uuid v4). Real
* idempotency requires a dedup table on the server — that's also a
* follow-up. Until then, replays of the same action will re-execute,
* but last-write-wins on the underlying tables is safe for the
* mutations we ship (status flips, stock adjust).
*/
interface ClientAction {
name: string;
// The handler signature is intentionally loose — payload shapes vary
// per action. The dispatcher in `sync.ts` invokes this with the raw
// payload from IndexedDB.
handler: (payload: unknown, clientActionId: string) => Promise<{
success: boolean;
error?: string;
}>;
}
export async function listClientActions(): Promise<ClientAction[]> {
// Wire real server actions here as they land:
// { name: "markOrderReady", handler: markOrderReady },
// { name: "markOrderPickedUp", handler: markOrderPickedUp },
// { name: "updateStopStatus", handler: updateStopStatus },
// { name: "adjustProductStock", handler: adjustProductStock },
return [];
}
export async function dispatchClientAction(
actionName: string,
payload: unknown,
clientActionId: string,
): Promise<
| { ok: true }
| { ok: false; conflict: string }
| { ok: false; error: string }
> {
const actions = await listClientActions();
const action = actions.find((a) => a.name === actionName);
if (!action) {
return { ok: false, error: "Unknown action" };
}
try {
const result = await action.handler(payload, clientActionId);
if (result.success === false) {
const message = result.error ?? "Action failed";
if (/conflict|stale/i.test(message)) {
return { ok: false, conflict: message };
}
return { ok: false, error: message };
}
revalidatePath("/admin/v2/orders");
revalidatePath("/admin/v2/orders/[id]", "page");
revalidatePath("/admin/v2/stops");
revalidatePath("/admin/v2/products");
return { ok: true };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
}
+2 -1
View File
@@ -73,7 +73,8 @@ export type CampaignActivityRow = {
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
// //
// The reports V1 RPCs (`get_reports_summary`, `get_orders_by_stop_report`, etc.) // The reports V1 RPCs (`get_reports_summary`, `get_orders_by_stop_report`, etc.)
// from `supabase/migrations/031_reports_v1_rpcs.sql` reference legacy columns // (originally from the now-archived supabase migrations; consolidated
// into `db/migrations/0001_init.sql`) reference legacy columns
// (`orders.subtotal`, `stops.city/state/date`, `communication_contacts`). // (`orders.subtotal`, `stops.city/state/date`, `communication_contacts`).
// The SaaS rebuild has a different `orders` schema (`total_cents`, no // The SaaS rebuild has a different `orders` schema (`total_cents`, no
// `stop_id`/`city`/`state` columns on `stops`) and a different contacts table // `stop_id`/`city`/`state` columns on `stops`) and a different contacts table
+7 -6
View File
@@ -4,12 +4,13 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope"; import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
// TODO(migration): the `harvest_lots` / `harvest_lot_events` / `lot_orders` // TODO(migration): the `harvest_lots` / `harvest_lot_events` / `lot_orders`
// tables from the route-trace feature (defined across // tables from the route-trace feature (originally defined in the now-
// supabase/migrations/143_enable_route_trace.sql and friends) were retired // archived supabase migrations; consolidated into
// from the SaaS rebuild — they don't exist in `db/schema/`. The functions // `db/migrations/0001_init.sql`) were retired from the SaaS rebuild —
// below all return empty results so the public trace page and FSMA reports // they don't exist in `db/schema/`. The functions below all return empty
// 404 gracefully. If route-trace comes back, re-introduce the tables in // results so the public trace page and FSMA reports 404 gracefully. If
// `db/schema/` and replace these stubs with real Drizzle queries. // route-trace comes back, re-introduce the tables in `db/schema/` and
// replace these stubs with real Drizzle queries.
export interface HarvestLot { export interface HarvestLot {
id: string; id: string;
+4 -4
View File
@@ -10,10 +10,10 @@ export type UpdateShippingStatusResult =
// TODO(migration): shipping is dormant in the SaaS rebuild. The legacy // TODO(migration): shipping is dormant in the SaaS rebuild. The legacy
// `shipments` table (with `tracking_number`, `fedex_shipment_id`, etc.), // `shipments` table (with `tracking_number`, `fedex_shipment_id`, etc.),
// the `shipping_status` column on `orders`, and the `update_shipping_order` // the `shipping_status` column on `orders`, and the `update_shipping_order`
// RPC from `supabase/migrations/040_shipping_fulfillment_rpcs.sql` are // RPC (originally from the now-archived supabase migrations) are gone.
// gone. The functions below stub to "not configured" so the admin // The functions below stub to "not configured" so the admin shipping
// shipping tab renders gracefully. Re-introduce shipping in // tab renders gracefully. Re-introduce shipping in `db/schema/` when
// `db/schema/` when the feature is reactivated. // the feature is reactivated.
export async function updateShippingStatus( export async function updateShippingStatus(
_orderId: string, _orderId: string,
+3 -2
View File
@@ -93,8 +93,9 @@ async function loadShippingSettingsForBrand(brandId: string): Promise<ShippingSe
/** /**
* Calls the legacy `get_order_items_perishable` SECURITY DEFINER RPC * Calls the legacy `get_order_items_perishable` SECURITY DEFINER RPC
* via `pool.query`. The function still lives in the database (see * via `pool.query`. The function still lives in the database
* supabase/migrations/083_shipping_settings.sql). * (originally from the now-archived supabase migrations; consolidated
* into `db/migrations/0001_init.sql`).
*/ */
async function isShipmentPerishable(orderId: string): Promise<boolean> { async function isShipmentPerishable(orderId: string): Promise<boolean> {
try { try {
+6 -6
View File
@@ -11,12 +11,12 @@
* *
* TODO(migration): shipping is dormant in the SaaS rebuild. The * TODO(migration): shipping is dormant in the SaaS rebuild. The
* legacy `shipping_settings` table (with the FedEx credential columns * legacy `shipping_settings` table (with the FedEx credential columns
* this file needs) is still present in the database see * this file needs) is still present in the database originally from
* supabase/migrations/083_shipping_settings.sql but the new * the now-archived supabase migrations, consolidated into
* `db/schema/` does not declare it. The data reads below hit the * `db/migrations/0001_init.sql` but the new `db/schema/` does not
* legacy table directly via `pool.query`. When shipping comes back, * declare it. The data reads below hit the legacy table directly via
* declare the table in `db/schema/shipping.ts` and switch the reads * `pool.query`. When shipping comes back, declare the table in
* to Drizzle. * `db/schema/shipping.ts` and switch the reads to Drizzle.
*/ */
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
+6 -6
View File
@@ -4,12 +4,12 @@
* Shipping settings CRUD + FedEx connection test. * Shipping settings CRUD + FedEx connection test.
* *
* TODO(migration): shipping is dormant in the SaaS rebuild. The * TODO(migration): shipping is dormant in the SaaS rebuild. The
* `shipping_settings` table is still part of the legacy schema (see * `shipping_settings` table is still part of the legacy schema
* supabase/migrations/083_shipping_settings.sql) the FedEx integration * (originally from the now-archived supabase migrations; consolidated
* continues to read/write it via raw `pool.query` SQL rather than the * into `db/migrations/0001_init.sql`) the FedEx integration continues
* Supabase REST gateway or Drizzle. When shipping is reactivated, move * to read/write it via raw `pool.query` SQL rather than Drizzle. When
* the table declaration into `db/schema/shipping.ts` and switch the * shipping is reactivated, move the table declaration into
* reads to typed Drizzle queries. * `db/schema/shipping.ts` and switch the reads to typed Drizzle queries.
*/ */
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
+27
View File
@@ -77,3 +77,30 @@ export async function getSyncLog(brandId: string): Promise<{
return { success: true, logs }; return { success: true, logs };
} }
/**
* Pending-item count for the dashboard widget badge. Used by
* `SquareSyncWidget` so it can refresh without re-running the full
* sync log query. Returns 0 when the caller isn't authorized never
* throws, so callers can render "0" while the auth check settles.
*/
export async function getSquareQueueCount(brandId: string): Promise<number> {
const adminUser = await getAdminUser();
if (!adminUser) return 0;
try {
assertBrandAccess(adminUser, brandId);
} catch {
return 0;
}
try {
const { rows } = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count
FROM square_sync_queue
WHERE brand_id = $1 AND status = 'pending'`,
[brandId],
);
return Number(rows[0]?.count ?? 0);
} catch {
return 0;
}
}
+7 -6
View File
@@ -4,12 +4,13 @@
* Assign / unassign a product to a stop. * Assign / unassign a product to a stop.
* *
* TODO(migration): the `assign_product_to_stop` and * TODO(migration): the `assign_product_to_stop` and
* `unassign_product_from_stop` SECURITY DEFINER RPCs live in * `unassign_product_from_stop` SECURITY DEFINER RPCs (originally from
* supabase/migrations/030. The RPCs still exist in the database and * the now-archived supabase migrations; consolidated into
* are called via `pool.query` rather than the Supabase REST gateway. * `db/migrations/0001_init.sql`). The RPCs still exist in the
* When stops/products are re-platformed on the SaaS rebuild, replace * database and are called via `pool.query`. When stops/products are
* these with direct inserts/deletes on the new `db/schema/stops.ts` * re-platformed on the SaaS rebuild, replace these with direct
* `stopProducts` table. * inserts/deletes on the new `db/schema/stops.ts` `stopProducts`
* table.
*/ */
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
+235
View File
@@ -0,0 +1,235 @@
"use server";
/**
* Public storefront data accessors.
*
* Each function hits Postgres directly via the shared `pg` pool from
* `@/lib/db` and is safe to call from public (unauthenticated) pages
* because the SQL filters by `is_public = true` / `active = true` and
* never returns anything the storefront shouldn't expose.
*/
import { pool } from "@/lib/db";
export type StorefrontBrand = {
id: string;
name: string;
slug: string;
};
export type StorefrontStop = {
id: string;
brand_id: string;
name: string | null;
city: string;
state: string;
zip: string | null;
address: string | null;
location: string | null;
date: string;
time: string | null;
cutoff_date: string | null;
status: string;
notes: string | null;
is_public: boolean;
};
export type StorefrontProduct = {
id: string;
brand_id: string;
name: string;
description: string | null;
price: number;
type: string | null;
image_url: string | null;
is_taxable: boolean | null;
pickup_type: string | null;
active: boolean;
};
export type StorefrontData = {
brand: StorefrontBrand | null;
stops: StorefrontStop[];
products: StorefrontProduct[];
};
/**
* Look up a brand by its public slug. Returns `null` if no brand matches
* (which means the storefront slug is wrong, or the brand was renamed).
*/
export async function getStorefrontBrandBySlug(
slug: string,
): Promise<StorefrontBrand | null> {
const { rows } = await pool.query<StorefrontBrand>(
`SELECT id, name, slug
FROM brands
WHERE slug = $1
LIMIT 1`,
[slug],
);
return rows[0] ?? null;
}
/**
* Public stops for a brand storefront. Filters out draft / private stops
* and any stop whose date has already passed.
*/
export async function getStorefrontStops(brandId: string): Promise<StorefrontStop[]> {
const { rows } = await pool.query<StorefrontStop>(
`SELECT id, brand_id, name, city, state, zip, address, location,
date::text AS date, time, cutoff_date::text AS cutoff_date,
status, notes, is_public
FROM stops
WHERE brand_id = $1
AND is_public = true
AND status = 'active'
AND date >= CURRENT_DATE
ORDER BY date ASC, time ASC NULLS LAST`,
[brandId],
);
return rows;
}
/**
* Active products visible on the storefront. Same shape as the admin
* product list but without the brand-internal columns (cost, supplier, ).
*
* Note: products.price is stored in cents (NUMERIC). We return it as a
* float in **dollars** so callers can format directly as `$${price}`
* without doing the cents-to-dollars math.
*/
export async function getStorefrontProducts(
brandId: string,
): Promise<StorefrontProduct[]> {
const { rows } = await pool.query<StorefrontProduct>(
`SELECT id, brand_id, name, description,
price::float8 / 100 AS price,
type, image_url, is_taxable, pickup_type, active
FROM products
WHERE brand_id = $1
AND active = true
AND deleted_at IS NULL
ORDER BY name ASC`,
[brandId],
);
return rows;
}
/**
* Single server action that the storefront Client Components call from
* their initial `useEffect`. Bundles brand lookup + public stops +
* active products into one round-trip-friendly call.
*
* Returns `{ brand: null, stops: [], products: [] }` if the slug doesn't
* match any brand pages fall back to default copy in that case.
*/
export async function getStorefrontData(slug: string): Promise<StorefrontData> {
const brand = await getStorefrontBrandBySlug(slug);
if (!brand) return { brand: null, stops: [], products: [] };
const [stops, products] = await Promise.all([
getStorefrontStops(brand.id),
getStorefrontProducts(brand.id),
]);
return { brand, stops, products };
}
export type StorefrontWholesaleSettings = {
invoice_business_name: string | null;
};
/**
* Public wholesale-settings record for a brand just the invoice
* business name used by the storefront contact page. Returns `null`
* if the brand or its wholesale settings row doesn't exist.
*
* NOTE: only `invoice_business_name` is exposed; other invoice-contact
* fields don't exist on `wholesale_settings` yet and shouldn't be
* surfaced here. If you need them, add a migration first.
*/
export async function getStorefrontWholesaleSettings(
slug: string,
): Promise<StorefrontWholesaleSettings | null> {
const brand = await getStorefrontBrandBySlug(slug);
if (!brand) return null;
const { rows } = await pool.query<StorefrontWholesaleSettings>(
`SELECT invoice_business_name
FROM wholesale_settings
WHERE brand_id = $1
LIMIT 1`,
[brand.id],
);
return rows[0] ?? null;
}
export type WholesalePortalConfig = {
wholesale_enabled: boolean | null;
online_payment_enabled: boolean | null;
};
/**
* Public wholesale portal flags for a brand. Used by the customer-facing
* `/wholesale/portal` page to decide whether to allow sign-in and whether
* to show the online-payment button. Doesn't require auth (the portal
* already validated the session before this is called).
*/
export async function getWholesalePortalConfig(
brandId: string,
): Promise<WholesalePortalConfig | null> {
const { rows } = await pool.query<WholesalePortalConfig>(
`SELECT wholesale_enabled, online_payment_enabled
FROM wholesale_settings
WHERE brand_id = $1
LIMIT 1`,
[brandId],
);
return rows[0] ?? null;
}
export type BrandName = { name: string };
/**
* Fetch a brand's display name by id. Public used by the wholesale
* portal header to render the brand name without leaking any other
* brand columns (logo, plan, stripe keys, etc.).
*/
export async function getStorefrontBrandName(
brandId: string,
): Promise<string | null> {
const { rows } = await pool.query<BrandName>(
`SELECT name FROM brands WHERE id = $1 LIMIT 1`,
[brandId],
);
return rows[0]?.name ?? null;
}
/**
* Look up a single public stop by its id, plus the brand's active
* products. Used by `/[brand]/stops/[id]` pages.
*
* NOTE: The URL parameter is named `[id]` (not `[slug]`) because the
* `stops` table has no slug column. URLs use the stop's UUID.
*
* Returns `null` if the stop is not public, not active, or its date has
* already passed. Returns the empty product array if the brand has no
* active products at the moment.
*/
export async function getStorefrontStopById(id: string): Promise<{
stop: StorefrontStop;
products: StorefrontProduct[];
} | null> {
const { rows: stopRows } = await pool.query<StorefrontStop>(
`SELECT id, brand_id, name, city, state, zip, address, location,
date::text AS date, time, cutoff_date::text AS cutoff_date,
status, notes, is_public
FROM stops
WHERE id = $1
AND is_public = true
AND status = 'active'
LIMIT 1`,
[id],
);
const stop = stopRows[0];
if (!stop) return null;
const products = await getStorefrontProducts(stop.brand_id);
return { stop, products };
}
+6 -6
View File
@@ -152,12 +152,12 @@ async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings |
// //
// TODO(migration): the tax dashboard reads from the legacy `orders` // TODO(migration): the tax dashboard reads from the legacy `orders`
// table via the `get_tax_summary` and `get_taxable_orders` SECURITY // table via the `get_tax_summary` and `get_taxable_orders` SECURITY
// DEFINER RPCs (supabase/migrations/095). Both the table and the RPCs // DEFINER RPCs (originally from the now-archived supabase migrations;
// still exist; we call the RPCs through `pool.query` rather than the // consolidated into `db/migrations/0001_init.sql`). Both the table and
// Supabase REST gateway. When the SaaS rebuild's orders table grows a // the RPCs still exist; we call the RPCs through `pool.query`. When the
// `tax_amount` column or the tax dashboard is rebuilt against the new // SaaS rebuild's orders table grows a `tax_amount` column or the tax
// schema, these helpers should be rewritten against the Drizzle // dashboard is rebuilt against the new schema, these helpers should be
// `orders` table directly. // rewritten against the Drizzle `orders` table directly.
export type TaxByStateRow = { export type TaxByStateRow = {
state: string; state: string;
-888
View File
@@ -1,888 +0,0 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
export type WholesaleOrder = {
id: string;
customer_id: string;
status: string;
fulfillment_status: string;
payment_status: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_required: number;
deposit_paid: number;
balance_due: number;
created_at: string;
updated_at: string;
invoice_number: string | null;
assigned_employee_id: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
items: Array<{
id: string;
product_name: string;
quantity: number;
unit_price: number;
line_total: number;
}>;
fulfilled_at: string | null;
};
export type WholesaleCustomer = {
id: string;
user_id: string | null;
company_name: string;
contact_name: string | null;
email: string;
phone: string | null;
account_status: string;
credit_limit: number;
deposits_enabled: boolean;
deposit_threshold: number | null;
deposit_percentage: number | null;
order_email: string | null;
invoice_email: string | null;
admin_notes: string | null;
role: string;
created_at: string;
deleted_at: string | null;
};
export type WholesaleProduct = {
id: string;
name: string;
description: string | null;
unit_type: string;
unit_type_custom: string | null;
availability: string;
qty_available: number;
season_start: string | null;
season_end: string | null;
price_tiers: Array<{ min_qty: number; max_qty: number; price: number }>;
hp_sku: string | null;
hp_item_id: string | null;
handling_instructions: string | null;
storage_warning: string | null;
loading_notes: string | null;
product_label: string | null;
pack_style: string | null;
container_type: string | null;
container_size_code: string | null;
units_per_container: number | null;
default_pickup_location: string | null;
created_at: string;
deleted_at: string | null;
};
export type NotificationRecipient = {
email: string;
name?: string;
active: boolean;
notification_types?: (
| "order_confirmation"
| "deposit_received"
| "order_fulfilled"
| "price_sheet"
| "unclaimed_pickup"
)[];
};
export type WholesaleSettings = {
id: string;
brand_id: string;
portal_page_id: string | null;
price_sheet_page_id: string | null;
require_approval: boolean;
min_order_amount: number | null;
online_payment_enabled: boolean;
wholesale_enabled: boolean;
square_sync_enabled: boolean;
pickup_location: string;
fob_location: string;
from_email: string;
invoice_business_name: string;
invoice_business_address: string | null;
invoice_business_phone: string | null;
invoice_business_email: string | null;
invoice_business_website: string | null;
notification_email: string | null;
notification_recipients: NotificationRecipient[];
last_invoice_number: number;
};
export type WholesaleDashboardStats = {
open_orders: number;
pickup_today: number;
past_due: number;
total_unpaid: number;
awaiting_deposit: number;
fulfilled_today: number;
};
/**
* Resolves the effective brand_id for an action, enforcing brand scoping.
*
* platform_admin null (means "all brands" passes to RPC unchanged)
* brand_admin their own brand_id only; rejects attempts to operate on other brands
* store_employee their own brand_id
* multi_brand_admin active brand from cookie/URL/legacy, must be in brand_ids
* unauthenticated null (actions should already bail out earlier)
*
* This prevents brand_admin from seeing or modifying another brand's data
* even if they manually pass a different brandId to the action.
*/
async function resolveBrandId(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): Promise<string | null> {
if (!adminUser) return null;
if (adminUser.role === "platform_admin") {
// platform_admin can operate on all brands — pass null (= all brands) to RPC
return null;
}
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && activeBrandId !== requestedBrandId) {
// Brand admin trying to operate on another brand's data — block it
return null; // caller should check and return unauthorized
}
return activeBrandId;
}
/**
* Like resolveBrandId but returns null for platform_admin AND throws an error
* if a brand_admin tries to operate outside their brand.
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
*/
async function enforceBrandScope(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): Promise<{ brandId: string | null; error?: string }> {
if (!adminUser) return { brandId: null, error: "Not authenticated" };
if (adminUser.role === "platform_admin") {
return { brandId: null }; // unrestricted
}
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && activeBrandId !== requestedBrandId) {
return { brandId: null, error: "Not authorized to operate on this brand" };
}
return { brandId: activeBrandId };
}
// ── Orders ──────────────────────────────────────────────────────────────────
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleOrder>(
"SELECT * FROM get_wholesale_orders($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleOrder>(
"SELECT * FROM get_wholesale_pickup_orders($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
const orders = await getWholesaleOrders(brandId);
const today = new Date().toISOString().split("T")[0];
const open = orders.filter(o => ["pending", "awaiting_deposit", "confirmed"].includes(o.status));
const pickupToday = orders.filter(o => o.anticipated_pickup_date === today && o.fulfillment_status !== "fulfilled");
const pastDue = orders.filter(o => o.anticipated_pickup_date && o.anticipated_pickup_date < today && o.fulfillment_status !== "fulfilled");
const totalUnpaid = open.reduce((sum, o) => sum + Number(o.balance_due), 0);
const awaitingDep = orders.filter(o => o.status === "awaiting_deposit");
const fulfilledToday = orders.filter(o => o.fulfillment_status === "fulfilled" && o.updated_at?.startsWith(today));
return {
open_orders: open.length,
pickup_today: pickupToday.length,
past_due: pastDue.length,
total_unpaid: totalUnpaid,
awaiting_deposit: awaitingDep.length,
fulfilled_today: fulfilledToday.length,
};
}
export async function markWholesaleOrderFulfilled(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query(
"SELECT * FROM mark_wholesale_order_fulfilled($1, $2)",
[orderId, adminUser.user_id]
);
if (!rows || rows.length === 0) {
return { success: false, error: "Failed to mark fulfilled" };
}
} catch {
return { success: false, error: "Failed to mark fulfilled" };
}
enqueueWholesaleWebhookForOrderFulfilled(orderId, resolved ?? undefined).catch(() => {});
return { success: true };
}
async function enqueueWholesaleWebhookForOrderFulfilled(orderId: string, brandId?: string) {
try {
await pool.query(
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
[
"order_fulfilled",
orderId,
brandId ?? null,
JSON.stringify({ order_id: orderId }),
]
);
} catch (_) {}
}
export async function updateWholesaleOrderStatus(
orderId: string,
status: "pending" | "confirmed" | "cancelled",
brandId?: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
await pool.query(
"SELECT * FROM update_wholesale_order_status($1, $2)",
[orderId, status]
);
return { success: true };
} catch {
return { success: false, error: "Failed to update order status" };
}
}
export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
await pool.query(
"SELECT * FROM delete_wholesale_order($1)",
[orderId]
);
return { success: true };
} catch {
return { success: false, error: "Failed to delete order" };
}
}
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM delete_wholesale_customer($1)",
[customerId]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" };
}
}
export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM delete_wholesale_product($1)",
[productId]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete product" };
}
}
// ── Customers ────────────────────────────────────────────────────────────────
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleCustomer>(
"SELECT * FROM get_wholesale_customers($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function saveWholesaleCustomer(params: {
brandId: string;
userId?: string;
companyName?: string;
contactName?: string;
email?: string;
phone?: string;
accountStatus?: string;
creditLimit?: number;
depositsEnabled?: boolean;
depositThreshold?: number;
depositPercentage?: number;
orderEmail?: string;
invoiceEmail?: string;
adminNotes?: string;
}): Promise<{ success: boolean; error?: string; id?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
[
params.brandId,
params.userId ?? null,
params.companyName ?? null,
params.contactName ?? null,
params.email ?? null,
params.phone ?? null,
params.accountStatus ?? "active",
params.creditLimit ?? 0,
params.depositsEnabled ?? false,
params.depositThreshold ?? null,
params.depositPercentage ?? null,
params.orderEmail ?? null,
params.invoiceEmail ?? null,
params.adminNotes ?? null,
]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, id: data?.id };
} catch {
return { success: false, error: "Failed to save customer" };
}
}
// ── Products ─────────────────────────────────────────────────────────────────
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleProduct>(
"SELECT * FROM get_wholesale_products($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function saveWholesaleProduct(params: {
brandId: string;
id?: string;
name: string;
description?: string;
unitType?: string;
availability?: string;
qtyAvailable?: number;
priceTiers?: Array<{ min_qty: number; max_qty: number; price: number }>;
hpSku?: string;
hpItemId?: string;
internalNotes?: string;
handlingInstructions?: string;
storageWarning?: string;
productLabel?: string;
packStyle?: string;
containerType?: string;
defaultPickupLocation?: string;
}): Promise<{ success: boolean; error?: string; id?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT * FROM upsert_wholesale_product($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
[
params.brandId,
params.id ?? null,
params.name,
params.description ?? null,
params.unitType ?? "each",
params.availability ?? "unavailable",
params.qtyAvailable ?? 0,
JSON.stringify(params.priceTiers ?? []),
params.hpSku ?? null,
params.hpItemId ?? null,
params.internalNotes ?? null,
params.handlingInstructions ?? null,
params.storageWarning ?? null,
params.productLabel ?? null,
params.packStyle ?? null,
params.containerType ?? null,
params.defaultPickupLocation ?? null,
]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, id: data?.id };
} catch {
return { success: false, error: "Failed to save product" };
}
}
// ── Settings ─────────────────────────────────────────────────────────────────
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
const bid = await getActiveBrandId(adminUser, brandId);
if (!bid && adminUser.role !== "platform_admin") {
return null;
}
try {
const { rows } = await pool.query<WholesaleSettings>(
"SELECT * FROM get_wholesale_settings($1)",
[bid]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function getWholesaleSettingsPublic(brandId: string): Promise<{ invoice_business_address: string | null } | null> {
try {
const { rows } = await pool.query<{ invoice_business_address: string | null }>(
"SELECT invoice_business_address FROM get_wholesale_settings($1)",
[brandId]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function saveWholesaleSettings(params: {
brandId: string;
requireApproval?: boolean;
minOrderAmount?: number;
onlinePaymentEnabled?: boolean;
wholesaleEnabled?: boolean;
squareSyncEnabled?: boolean;
pickupLocation?: string;
fobLocation?: string;
fromEmail?: string;
invoiceBusinessName?: string;
invoiceBusinessAddress?: string;
invoiceBusinessPhone?: string;
invoiceBusinessEmail?: string;
invoiceBusinessWebsite?: string;
notificationEmail?: string;
notificationRecipients?: NotificationRecipient[];
}): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
await pool.query(
"SELECT upsert_wholesale_settings($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)",
[
params.brandId,
params.requireApproval ?? null,
params.minOrderAmount ?? null,
params.onlinePaymentEnabled ?? null,
params.wholesaleEnabled ?? null,
params.pickupLocation ?? null,
params.fobLocation ?? null,
params.fromEmail ?? null,
params.invoiceBusinessName ?? null,
params.invoiceBusinessAddress ?? null,
params.invoiceBusinessPhone ?? null,
params.invoiceBusinessEmail ?? null,
params.invoiceBusinessWebsite ?? null,
params.notificationEmail ?? null,
params.notificationRecipients ? JSON.stringify(params.notificationRecipients) : null,
params.squareSyncEnabled ?? null,
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to save settings" };
}
}
// ── Deposits ─────────────────────────────────────────────────────────────────
export async function recordWholesaleDeposit(
orderId: string,
amount: number,
method: string = "cash",
reference?: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const activeBrandId = await getActiveBrandId(adminUser);
let data: { success?: boolean; error?: string } | null = null;
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
[orderId, amount, method, reference ?? null, adminUser.user_id, activeBrandId]
);
data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to record deposit" };
}
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to record deposit" };
}
// Fire webhook — fire-and-forget
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, activeBrandId ?? undefined).catch(() => {});
return { success: true };
}
async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId?: string) {
try {
await pool.query(
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
[
"deposit_recorded",
orderId,
brandId ?? null,
JSON.stringify({ order_id: orderId, amount }),
]
);
} catch (_) {}
}
// ── Bulk Actions ──────────────────────────────────────────────────────────────
export async function bulkFulfillWholesaleOrders(
orderIds: string[]
): Promise<{ success: boolean; count?: number; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
"SELECT * FROM bulk_fulfill_wholesale_orders($1, $2, $3)",
[orderIds, adminUser.user_id, await getActiveBrandId(adminUser)]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" };
}
return { success: true, count: data.count };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk fulfill orders" };
}
}
export async function bulkRecordWholesaleDeposit(
orderIds: string[],
amount: number,
method: string = "cash",
reference?: string
): Promise<{ success: boolean; count?: number; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
"SELECT * FROM bulk_record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
[orderIds, amount, method, reference ?? null, adminUser.user_id, await getActiveBrandId(adminUser)]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to bulk record deposits" };
}
return { success: true, count: data.count };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk record deposits" };
}
}
// ── Notifications ─────────────────────────────────────────────────────────────
export type WholesaleNotification = {
id: string;
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
email_to: string;
email_cc: string | null;
subject: string;
body_html: string | null;
body_text: string | null;
brand_id: string;
customer_id: string;
order_id: string | null;
status: string;
invoice_business_name: string | null;
invoice_business_email: string | null;
created_at: string;
};
export async function getWholesaleNotificationStats(
brandId: string
): Promise<{ pending: number; sent: number; failed: number; total: number }> {
try {
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
"SELECT * FROM get_wholesale_notification_stats($1)",
[brandId]
);
return rows[0] ?? { pending: 0, sent: 0, failed: 0, total: 0 };
} catch {
return { pending: 0, sent: 0, failed: 0, total: 0 };
}
}
export async function getWholesalePendingNotifications(
brandId: string,
limit = 50
): Promise<WholesaleNotification[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
if (!adminUser.can_manage_orders) return [];
try {
const { rows } = await pool.query<WholesaleNotification>(
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
[brandId, limit]
);
return rows;
} catch {
return [];
}
}
export async function markWholesaleNotificationSent(
notificationId: string,
error?: string
): Promise<{ success: boolean }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false };
if (!adminUser.can_manage_orders) return { success: false };
try {
await pool.query(
"SELECT mark_wholesale_notification_sent($1, $2)",
[notificationId, error ?? null]
);
return { success: true };
} catch {
return { success: false };
}
}
export async function enqueueWholesaleNotification(params: {
brandId: string;
customerId: string;
orderId: string;
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
emailTo: string;
emailCc?: string;
subject: string;
bodyHtml?: string;
bodyText?: string;
}): Promise<{ success: boolean; error?: string }> {
try {
await pool.query(
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
params.brandId,
params.customerId,
params.orderId,
params.type,
params.emailTo,
params.emailCc ?? null,
params.subject,
params.bodyHtml ?? null,
params.bodyText ?? null,
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to enqueue notification" };
}
}
// ── Webhooks ─────────────────────────────────────────────────────────────────
export type WebhookSettings = {
id: string;
brand_id: string;
url: string;
secret: string;
enabled: boolean;
created_at: string;
updated_at: string;
};
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
try {
const { rows } = await pool.query<WebhookSettings>(
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
[brandId]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function saveWebhookSettings(params: {
brandId: string;
url?: string;
secret?: string;
enabled?: boolean;
}): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
await pool.query(
"SELECT upsert_wholesale_webhook_settings($1::jsonb)",
[
JSON.stringify({
brand_id: params.brandId,
url: params.url ?? "",
secret: params.secret ?? "",
enabled: params.enabled ?? false,
}),
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to save webhook settings" };
}
}
export async function enqueueWholesaleWebhook(
eventType: "order_created" | "order_fulfilled" | "deposit_recorded" | "order_paid",
orderId: string | null = null,
payload: Record<string, unknown> | null = null,
brandId?: string
): Promise<{ success: boolean; logId?: string }> {
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)",
[eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, logId: data?.id };
} catch {
return { success: false };
}
}
export async function getRecentWebhookActivity(brandId: string, limit = 10): Promise<Array<{
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
}>> {
try {
const { rows } = await pool.query<{
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
}>(
"SELECT * FROM wholesale_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT $2",
[brandId, limit]
);
return rows;
} catch {
return [];
}
}
+95
View File
@@ -0,0 +1,95 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { enforceBrandScope, resolveBrandId } from "./scope";
import type { WholesaleCustomer } from "./types";
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleCustomer>(
"SELECT * FROM get_wholesale_customers($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function saveWholesaleCustomer(params: {
brandId: string;
userId?: string;
companyName?: string;
contactName?: string;
email?: string;
phone?: string;
accountStatus?: string;
creditLimit?: number;
depositsEnabled?: boolean;
depositThreshold?: number;
depositPercentage?: number;
orderEmail?: string;
invoiceEmail?: string;
adminNotes?: string;
}): Promise<{ success: boolean; error?: string; id?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
[
params.brandId,
params.userId ?? null,
params.companyName ?? null,
params.contactName ?? null,
params.email ?? null,
params.phone ?? null,
params.accountStatus ?? "active",
params.creditLimit ?? 0,
params.depositsEnabled ?? false,
params.depositThreshold ?? null,
params.depositPercentage ?? null,
params.orderEmail ?? null,
params.invoiceEmail ?? null,
params.adminNotes ?? null,
]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, id: data?.id };
} catch {
return { success: false, error: "Failed to save customer" };
}
}
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM delete_wholesale_customer($1)",
[customerId]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" };
}
}
+85
View File
@@ -0,0 +1,85 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { enqueueWholesaleWebhook } from "./webhooks";
export async function recordWholesaleDeposit(
orderId: string,
amount: number,
method: string = "cash",
reference?: string,
brandId?: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const activeBrandId = await getActiveBrandId(adminUser, brandId);
let data: { success?: boolean; error?: string } | null = null;
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
[orderId, amount, method, reference ?? null, adminUser.user_id, activeBrandId]
);
data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to record deposit" };
}
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to record deposit" };
}
// Fire webhook — fire-and-forget
enqueueWholesaleWebhook("deposit_recorded", orderId, { order_id: orderId, amount }, activeBrandId ?? undefined).catch(() => {});
return { success: true };
}
export async function bulkFulfillWholesaleOrders(
orderIds: string[]
): Promise<{ success: boolean; count?: number; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
"SELECT * FROM bulk_fulfill_wholesale_orders($1, $2, $3)",
[orderIds, adminUser.user_id, await getActiveBrandId(adminUser)]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" };
}
return { success: true, count: data.count };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk fulfill orders" };
}
}
export async function bulkRecordWholesaleDeposit(
orderIds: string[],
amount: number,
method: string = "cash",
reference?: string
): Promise<{ success: boolean; count?: number; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
"SELECT * FROM bulk_record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
[orderIds, amount, method, reference ?? null, adminUser.user_id, await getActiveBrandId(adminUser)]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to bulk record deposits" };
}
return { success: true, count: data.count };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk record deposits" };
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Barrel re-export for the wholesale action set.
*
* Source modules live next to this file:
* - orders.ts order CRUD + dashboard stats
* - customers.ts wholesale customer CRUD
* - products.ts wholesale product CRUD
* - settings.ts wholesale_settings + public read
* - deposits.ts deposit recording + bulk actions
* - notifications.ts email/SMS notification queue
* - webhooks.ts outbound webhook settings + dispatch
* - scope.ts internal brand-scoping helpers (NOT re-exported here)
* - types.ts shared type definitions
*
* Existing imports of `@/actions/wholesale` keep working. New code should
* import directly from the focused modules to keep the dependency graph
* tight.
*/
export type {
WholesaleOrder,
WholesaleCustomer,
WholesaleProduct,
NotificationRecipient,
WholesaleSettings,
WholesaleDashboardStats,
WholesaleNotification,
WebhookSettings,
} from "./types";
export {
getWholesaleOrders,
getWholesalePickupOrders,
getWholesaleDashboardStats,
markWholesaleOrderFulfilled,
updateWholesaleOrderStatus,
deleteWholesaleOrder,
} from "./orders";
export {
getWholesaleCustomers,
saveWholesaleCustomer,
deleteWholesaleCustomer,
} from "./customers";
export {
getWholesaleProducts,
saveWholesaleProduct,
deleteWholesaleProduct,
} from "./products";
export {
getWholesaleSettings,
getWholesaleSettingsPublic,
saveWholesaleSettings,
} from "./settings";
export {
recordWholesaleDeposit,
bulkFulfillWholesaleOrders,
bulkRecordWholesaleDeposit,
} from "./deposits";
export {
getWholesaleNotificationStats,
getWholesalePendingNotifications,
markWholesaleNotificationSent,
enqueueWholesaleNotification,
} from "./notifications";
export {
getWebhookSettings,
saveWebhookSettings,
enqueueWholesaleWebhook,
getRecentWebhookActivity,
} from "./webhooks";
+89
View File
@@ -0,0 +1,89 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import type { WholesaleNotification } from "./types";
export async function getWholesaleNotificationStats(
brandId: string
): Promise<{ pending: number; sent: number; failed: number; total: number }> {
try {
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
"SELECT * FROM get_wholesale_notification_stats($1)",
[brandId]
);
return rows[0] ?? { pending: 0, sent: 0, failed: 0, total: 0 };
} catch {
return { pending: 0, sent: 0, failed: 0, total: 0 };
}
}
export async function getWholesalePendingNotifications(
brandId: string,
limit = 50
): Promise<WholesaleNotification[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
if (!adminUser.can_manage_orders) return [];
try {
const { rows } = await pool.query<WholesaleNotification>(
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
[brandId, limit]
);
return rows;
} catch {
return [];
}
}
export async function markWholesaleNotificationSent(
notificationId: string,
error?: string
): Promise<{ success: boolean }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false };
if (!adminUser.can_manage_orders) return { success: false };
try {
await pool.query(
"SELECT mark_wholesale_notification_sent($1, $2)",
[notificationId, error ?? null]
);
return { success: true };
} catch {
return { success: false };
}
}
export async function enqueueWholesaleNotification(params: {
brandId: string;
customerId: string;
orderId: string;
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
emailTo: string;
emailCc?: string;
subject: string;
bodyHtml?: string;
bodyText?: string;
}): Promise<{ success: boolean; error?: string }> {
try {
await pool.query(
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
params.brandId,
params.customerId,
params.orderId,
params.type,
params.emailTo,
params.emailCc ?? null,
params.subject,
params.bodyHtml ?? null,
params.bodyText ?? null,
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to enqueue notification" };
}
}
+128
View File
@@ -0,0 +1,128 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { enforceBrandScope, resolveBrandId } from "./scope";
import { enqueueWholesaleWebhook } from "./webhooks";
import type { WholesaleDashboardStats, WholesaleOrder } from "./types";
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleOrder>(
"SELECT * FROM get_wholesale_orders($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleOrder>(
"SELECT * FROM get_wholesale_pickup_orders($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
const orders = await getWholesaleOrders(brandId);
const today = new Date().toISOString().split("T")[0];
const open = orders.filter(o => ["pending", "awaiting_deposit", "confirmed"].includes(o.status));
const pickupToday = orders.filter(o => o.anticipated_pickup_date === today && o.fulfillment_status !== "fulfilled");
const pastDue = orders.filter(o => o.anticipated_pickup_date && o.anticipated_pickup_date < today && o.fulfillment_status !== "fulfilled");
const totalUnpaid = open.reduce((sum, o) => sum + Number(o.balance_due), 0);
const awaitingDep = orders.filter(o => o.status === "awaiting_deposit");
const fulfilledToday = orders.filter(o => o.fulfillment_status === "fulfilled" && o.updated_at?.startsWith(today));
return {
open_orders: open.length,
pickup_today: pickupToday.length,
past_due: pastDue.length,
total_unpaid: totalUnpaid,
awaiting_deposit: awaitingDep.length,
fulfilled_today: fulfilledToday.length,
};
}
export async function markWholesaleOrderFulfilled(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query(
"SELECT * FROM mark_wholesale_order_fulfilled($1, $2)",
[orderId, adminUser.user_id]
);
if (!rows || rows.length === 0) {
return { success: false, error: "Failed to mark fulfilled" };
}
} catch {
return { success: false, error: "Failed to mark fulfilled" };
}
enqueueWholesaleWebhook("order_fulfilled", orderId, { order_id: orderId }, resolved ?? undefined).catch(() => {});
return { success: true };
}
export async function updateWholesaleOrderStatus(
orderId: string,
status: "pending" | "confirmed" | "cancelled",
brandId?: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
await pool.query(
"SELECT * FROM update_wholesale_order_status($1, $2)",
[orderId, status]
);
return { success: true };
} catch {
return { success: false, error: "Failed to update order status" };
}
}
export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
await pool.query(
"SELECT * FROM delete_wholesale_order($1)",
[orderId]
);
return { success: true };
} catch {
return { success: false, error: "Failed to delete order" };
}
}
+101
View File
@@ -0,0 +1,101 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { enforceBrandScope, resolveBrandId } from "./scope";
import type { WholesaleProduct } from "./types";
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleProduct>(
"SELECT * FROM get_wholesale_products($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function saveWholesaleProduct(params: {
brandId: string;
id?: string;
name: string;
description?: string;
unitType?: string;
availability?: string;
qtyAvailable?: number;
priceTiers?: Array<{ min_qty: number; max_qty: number; price: number }>;
hpSku?: string;
hpItemId?: string;
internalNotes?: string;
handlingInstructions?: string;
storageWarning?: string;
productLabel?: string;
packStyle?: string;
containerType?: string;
defaultPickupLocation?: string;
}): Promise<{ success: boolean; error?: string; id?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT * FROM upsert_wholesale_product($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
[
params.brandId,
params.id ?? null,
params.name,
params.description ?? null,
params.unitType ?? "each",
params.availability ?? "unavailable",
params.qtyAvailable ?? 0,
JSON.stringify(params.priceTiers ?? []),
params.hpSku ?? null,
params.hpItemId ?? null,
params.internalNotes ?? null,
params.handlingInstructions ?? null,
params.storageWarning ?? null,
params.productLabel ?? null,
params.packStyle ?? null,
params.containerType ?? null,
params.defaultPickupLocation ?? null,
]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, id: data?.id };
} catch {
return { success: false, error: "Failed to save product" };
}
}
export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM delete_wholesale_product($1)",
[productId]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete product" };
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Brand scoping helpers shared across the wholesale actions.
*
* No "use server" so this can also be imported from non-action contexts
* (e.g. middleware, tests).
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
/**
* Resolves the effective brand_id for an action, enforcing brand scoping.
*
* platform_admin null (means "all brands" passes to RPC unchanged)
* brand_admin their own brand_id only; rejects attempts to operate on other brands
* store_employee their own brand_id
* multi_brand_admin active brand from cookie/URL/legacy, must be in brand_ids
* unauthenticated null (actions should already bail out earlier)
*
* This prevents brand_admin from seeing or modifying another brand's data
* even if they manually pass a different brandId to the action.
*/
export async function resolveBrandId(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): Promise<string | null> {
if (!adminUser) return null;
if (adminUser.role === "platform_admin") {
// platform_admin can operate on all brands — pass null (= all brands) to RPC
return null;
}
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && activeBrandId !== requestedBrandId) {
// Brand admin trying to operate on another brand's data — block it
return null; // caller should check and return unauthorized
}
return activeBrandId;
}
/**
* Like resolveBrandId but returns null for platform_admin AND throws an error
* if a brand_admin tries to operate outside their brand.
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
*/
export async function enforceBrandScope(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): Promise<{ brandId: string | null; error?: string }> {
if (!adminUser) return { brandId: null, error: "Not authenticated" };
if (adminUser.role === "platform_admin") {
return { brandId: null }; // unrestricted
}
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && activeBrandId !== requestedBrandId) {
return { brandId: null, error: "Not authorized to operate on this brand" };
}
return { brandId: activeBrandId };
}
+87
View File
@@ -0,0 +1,87 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import type { NotificationRecipient, WholesaleSettings } from "./types";
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
const bid = await getActiveBrandId(adminUser, brandId);
if (!bid && adminUser.role !== "platform_admin") {
return null;
}
try {
const { rows } = await pool.query<WholesaleSettings>(
"SELECT * FROM get_wholesale_settings($1)",
[bid]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function getWholesaleSettingsPublic(brandId: string): Promise<{ invoice_business_address: string | null } | null> {
try {
const { rows } = await pool.query<{ invoice_business_address: string | null }>(
"SELECT invoice_business_address FROM get_wholesale_settings($1)",
[brandId]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function saveWholesaleSettings(params: {
brandId: string;
requireApproval?: boolean;
minOrderAmount?: number;
onlinePaymentEnabled?: boolean;
wholesaleEnabled?: boolean;
squareSyncEnabled?: boolean;
pickupLocation?: string;
fobLocation?: string;
fromEmail?: string;
invoiceBusinessName?: string;
invoiceBusinessAddress?: string;
invoiceBusinessPhone?: string;
invoiceBusinessEmail?: string;
invoiceBusinessWebsite?: string;
notificationEmail?: string;
notificationRecipients?: NotificationRecipient[];
}): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
await pool.query(
"SELECT upsert_wholesale_settings($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)",
[
params.brandId,
params.requireApproval ?? null,
params.minOrderAmount ?? null,
params.onlinePaymentEnabled ?? null,
params.wholesaleEnabled ?? null,
params.pickupLocation ?? null,
params.fobLocation ?? null,
params.fromEmail ?? null,
params.invoiceBusinessName ?? null,
params.invoiceBusinessAddress ?? null,
params.invoiceBusinessPhone ?? null,
params.invoiceBusinessEmail ?? null,
params.invoiceBusinessWebsite ?? null,
params.notificationEmail ?? null,
params.notificationRecipients ? JSON.stringify(params.notificationRecipients) : null,
params.squareSyncEnabled ?? null,
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to save settings" };
}
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Shared types for wholesale actions.
*
* Pure type definitions no runtime imports, no "use server" so it can
* be imported into both server and client code.
*/
export type WholesaleOrder = {
id: string;
customer_id: string;
status: string;
fulfillment_status: string;
payment_status: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_required: number;
deposit_paid: number;
balance_due: number;
created_at: string;
updated_at: string;
invoice_number: string | null;
assigned_employee_id: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
items: Array<{
id: string;
product_name: string;
quantity: number;
unit_price: number;
line_total: number;
}>;
fulfilled_at: string | null;
};
export type WholesaleCustomer = {
id: string;
user_id: string | null;
company_name: string;
contact_name: string | null;
email: string;
phone: string | null;
account_status: string;
credit_limit: number;
deposits_enabled: boolean;
deposit_threshold: number | null;
deposit_percentage: number | null;
order_email: string | null;
invoice_email: string | null;
admin_notes: string | null;
role: string;
created_at: string;
deleted_at: string | null;
};
export type WholesaleProduct = {
id: string;
name: string;
description: string | null;
unit_type: string;
unit_type_custom: string | null;
availability: string;
qty_available: number;
season_start: string | null;
season_end: string | null;
price_tiers: Array<{ min_qty: number; max_qty: number; price: number }>;
hp_sku: string | null;
hp_item_id: string | null;
handling_instructions: string | null;
storage_warning: string | null;
loading_notes: string | null;
product_label: string | null;
pack_style: string | null;
container_type: string | null;
container_size_code: string | null;
units_per_container: number | null;
default_pickup_location: string | null;
created_at: string;
deleted_at: string | null;
};
export type NotificationRecipient = {
email: string;
name?: string;
active: boolean;
notification_types?: (
| "order_confirmation"
| "deposit_received"
| "order_fulfilled"
| "price_sheet"
| "unclaimed_pickup"
)[];
};
export type WholesaleSettings = {
id: string;
brand_id: string;
portal_page_id: string | null;
price_sheet_page_id: string | null;
require_approval: boolean;
min_order_amount: number | null;
online_payment_enabled: boolean;
wholesale_enabled: boolean;
square_sync_enabled: boolean;
pickup_location: string;
fob_location: string;
from_email: string;
invoice_business_name: string;
invoice_business_address: string | null;
invoice_business_phone: string | null;
invoice_business_email: string | null;
invoice_business_website: string | null;
notification_email: string | null;
notification_recipients: NotificationRecipient[];
last_invoice_number: number;
};
export type WholesaleDashboardStats = {
open_orders: number;
pickup_today: number;
past_due: number;
total_unpaid: number;
awaiting_deposit: number;
fulfilled_today: number;
};
export type WholesaleNotification = {
id: string;
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
email_to: string;
email_cc: string | null;
subject: string;
body_html: string | null;
body_text: string | null;
brand_id: string;
customer_id: string;
order_id: string | null;
status: string;
invoice_business_name: string | null;
invoice_business_email: string | null;
created_at: string;
};
export type WebhookSettings = {
id: string;
brand_id: string;
url: string;
secret: string;
enabled: boolean;
created_at: string;
updated_at: string;
};
+91
View File
@@ -0,0 +1,91 @@
"use server";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import type { WebhookSettings } from "./types";
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
try {
const { rows } = await pool.query<WebhookSettings>(
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
[brandId]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function saveWebhookSettings(params: {
brandId: string;
url?: string;
secret?: string;
enabled?: boolean;
}): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
await pool.query(
"SELECT upsert_wholesale_webhook_settings($1::jsonb)",
[
JSON.stringify({
brand_id: params.brandId,
url: params.url ?? "",
secret: params.secret ?? "",
enabled: params.enabled ?? false,
}),
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to save webhook settings" };
}
}
export async function enqueueWholesaleWebhook(
eventType: "order_created" | "order_fulfilled" | "deposit_recorded" | "order_paid",
orderId: string | null = null,
payload: Record<string, unknown> | null = null,
brandId?: string
): Promise<{ success: boolean; logId?: string }> {
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)",
[eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, logId: data?.id };
} catch {
return { success: false };
}
}
export async function getRecentWebhookActivity(brandId: string, limit = 10): Promise<Array<{
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
}>> {
try {
const { rows } = await pool.query<{
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
}>(
"SELECT * FROM wholesale_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT $2",
[brandId, limit]
);
return rows;
} catch {
return [];
}
}
+2
View File
@@ -11,6 +11,7 @@ import { ToastContainer } from "@/components/admin/ToastContainer";
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition"; import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer"; import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer";
import CommandPalette from "@/components/admin/CommandPalette"; import CommandPalette from "@/components/admin/CommandPalette";
import PWAInstallPrompt from "@/components/pwa/InstallPrompt";
import { getEnabledAddons } from "@/actions/billing/stripe-portal"; import { getEnabledAddons } from "@/actions/billing/stripe-portal";
// Admin layout calls getAdminUser() which reads cookies(). Without this, // Admin layout calls getAdminUser() which reads cookies(). Without this,
@@ -135,6 +136,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<SmoothViewTransition>{children}</SmoothViewTransition> <SmoothViewTransition>{children}</SmoothViewTransition>
<RouteAnnouncer /> <RouteAnnouncer />
</div> </div>
<PWAInstallPrompt />
</ToastProviderWrapper> </ToastProviderWrapper>
); );
} }
-103
View File
@@ -1,103 +0,0 @@
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { getAdminOrders } from "@/actions/orders";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase";
export const dynamic = "force-dynamic";
export default async function AdminOrdersPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_orders) {
redirect("/admin/pickup");
}
const activeBrandId = await getActiveBrandId(adminUser);
// Platform admin can browse all brands' orders; everyone else must have a brand
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied message="You don't have access to any brand." />;
}
const { orders, stops } = await getAdminOrders();
const brandStops = activeBrandId
? stops.filter((s) => s.brand_id === activeBrandId)
: stops;
const brandOrders = activeBrandId
? orders.filter(
(o) =>
o.stops && brandStops.some((s) => s.id === o.stop_id)
)
: orders;
// Fetch active products for this brand (for admin "New Order" item picker)
let brandProducts: Array<{ id: string; name: string; price: number; type?: string | null; active?: boolean }> = [];
try {
let prodQuery = supabase
.from("products")
.select("id, name, price, type, active")
.eq("active", true)
.is("deleted_at", null)
.order("name")
.limit(200);
if (activeBrandId) {
prodQuery = prodQuery.eq("brand_id", activeBrandId);
}
const { data: prods } = await prodQuery;
brandProducts = (prods ?? []).map((p) => ({
id: String(p.id),
name: String(p.name ?? ""),
price: Number(p.price),
type: p.type as string | null ?? null,
active: Boolean(p.active ?? true),
}));
} catch {
// non-fatal for the orders list
}
return (
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader
title="Orders"
subtitle="View, filter, and manage customer orders."
icon={
<svg className="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
<path d="M3 6h18"/>
<path d="M16 10a4 4 0 0 1-8 0"/>
</svg>
}
/>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<div
className="rounded-2xl border overflow-hidden"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<AdminOrdersPanel
initialOrders={brandOrders}
initialStops={brandStops}
initialProducts={brandProducts}
brandId={activeBrandId}
/>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default async function AdminPage() {
// so a transient DB/network failure can't crash the whole admin page. // so a transient DB/network failure can't crash the whole admin page.
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null; let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") { if (!dashboardBrandId && adminUser?.role === "platform_admin") {
// Direct pg query (the supabase shim returns empty results). // Direct pg query (the legacy query-builder shim returned empty results).
// This ensures a platform_admin sees real dashboard stats even on first login // This ensures a platform_admin sees real dashboard stats even on first login
// before they have chosen an active brand. // before they have chosen an active brand.
try { try {
-158
View File
@@ -1,158 +0,0 @@
import { supabase } from "@/lib/supabase";
import ProductEditForm from "@/components/admin/ProductEditForm";
import { getAdminUser } from "@/lib/admin-permissions";
import { PageHeader, AdminBadge } from "@/components/admin/design-system";
import { Package as PackageIcon } from "lucide-react";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { redirect } from "next/navigation";
import Link from "next/link";
type ProductDetailPageProps = {
params: Promise<{
id: string;
}>;
};
interface Product {
id: string;
brand_id: string;
name: string;
description: string | null;
price: number;
active: boolean;
type: string | null;
image_url: string | null;
is_taxable: boolean;
pickup_type: string | null;
brands?: { name: string; slug: string };
}
interface Brand {
id: string;
name: string;
slug: string;
}
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
const { id } = await params;
const [{ data: product, error }, { data: brands }] = await Promise.all([
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single() as unknown as { data: Product | null; error: { message: string } | null },
supabase.from("brands").select("id, name, slug") as unknown as { data: Brand[] | null },
]);
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_products) redirect("/admin/pickup");
if (adminUser?.brand_id && product?.brand_id !== adminUser.brand_id) {
return <AdminAccessDenied />;
}
if (error || !product) {
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-[var(--admin-danger)]">Product not found</h1>
<pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
{error?.message ?? "Product not found"}
</pre>
<Link
href="/admin/products"
className="mt-4 inline-block text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]"
>
Back to Products
</Link>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl">
<div className="ha-eyebrow mb-2">Operations</div>
<PageHeader
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
title="Edit product"
subtitle="Update product details, pricing, and availability."
actions={
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"}
</AdminBadge>
}
/>
<div className="mb-6">
<Link
href="/admin/products"
className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
Back to Products
</Link>
</div>
<div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
{product.brands?.name}
</p>
<h1 className="mt-2 text-3xl font-bold text-[var(--admin-text-primary)]">
{product.name}
</h1>
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">
{product.description}
</p>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-6">
<div>
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Price</p>
<p
className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)}
</p>
</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Type</p>
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
{product.type}
</p>
</div>
</div>
</div>
<div className="mt-6 rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Product</h2>
<p className="mt-1 text-[var(--admin-text-muted)]">
Update product details, pricing, and availability.
</p>
<div className="mt-6">
<ProductEditForm
product={{
id: product.id,
name: product.name,
description: product.description ?? "",
price: Number(product.price),
type: product.type ?? "pickup",
active: product.active,
brand_id: product.brand_id,
image_url: product.image_url ?? "",
is_taxable: product.is_taxable,
pickup_type: product.pickup_type ?? "pickup",
}}
brands={brands ?? []}
/>
</div>
</div>
</div>
</main>
);
}
-42
View File
@@ -1,42 +0,0 @@
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import ReportsDashboard from "@/components/admin/ReportsDashboard";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
export default async function ReportsPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_reports) redirect("/admin/pickup");
const isPlatformAdmin = adminUser.role === "platform_admin";
const { data: brands } = await supabase
.from("brands")
.select("id, name")
.order("name") as unknown as { data: { id: string; name: string }[] | null };
const initialBrandId = isPlatformAdmin
? null
: await getActiveBrandId(adminUser);
return (
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-7xl">
<PageHeader
title="Reports"
subtitle="Operational visibility across orders, fulfillment, contacts, and campaigns."
/>
<ReportsDashboard
brands={brands ?? []}
initialBrandId={initialBrandId}
isPlatformAdmin={isPlatformAdmin}
brandId={adminUser.brand_id}
/>
</div>
</main>
);
}
-111
View File
@@ -1,111 +0,0 @@
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { getBillingOverview } from "@/actions/billing/billing-overview";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import BillingClientPage from "./BillingClientPage";
type Props = {
params: Promise<{ brandId?: string }>;
};
export default async function BillingPage({ params }: Props) {
const { brandId: brandIdParam } = await params;
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied message="You don't have access to that brand." />;
}
const effectiveBrandId = activeBrandId ?? "";
const isPlatformAdmin = adminUser.role === "platform_admin";
let resolvedBrandId = effectiveBrandId;
if (isPlatformAdmin && !resolvedBrandId) {
const { data: firstBrand } = await supabase
.from("brands")
.select("id")
.limit(1)
.single();
if (firstBrand?.id) {
resolvedBrandId = String(firstBrand.id);
} else {
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
<div className="mx-auto max-w-6xl">
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
<span>/</span>
<span className="text-[var(--admin-text-primary)]">Billing</span>
</nav>
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-8 text-center">
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">No Brands Found</h1>
<p className="mt-2 text-[var(--admin-text-muted)]">Create a brand in the database before accessing billing settings.</p>
<a href="/admin" className="mt-4 inline-block rounded-xl bg-[var(--admin-accent)] hover:bg-[var(--admin-accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors">
Back to Admin
</a>
</div>
</div>
</main>
);
}
}
if (!resolvedBrandId) return <AdminAccessDenied />;
// Single source of truth for everything the billing client needs.
const overviewRes = await getBillingOverview(resolvedBrandId);
if (!overviewRes.success || !overviewRes.data) {
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
<div className="mx-auto max-w-6xl">
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-8 text-center">
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">Billing Unavailable</h1>
<p className="mt-2 text-[var(--admin-text-muted)]">{overviewRes.error ?? "Could not load billing information."}</p>
<a href="/admin" className="mt-4 inline-block rounded-xl bg-[var(--admin-accent)] hover:bg-[var(--admin-accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors">
Back to Admin
</a>
</div>
</div>
</main>
);
}
const overview = overviewRes.data;
return (
<main className="min-h-screen bg-[var(--admin-bg)]">
{/* Platform billing header */}
<div className="bg-[var(--admin-bg-subtle)] border-b border-[var(--admin-border)] px-6 py-3">
<div className="mx-auto max-w-6xl">
<p className="text-xs text-[var(--admin-text-muted)]">
<span className="font-medium text-[var(--admin-text-primary)]">Route Commerce Platform Billing</span>
{" — "}Invoiced by Cielo Hermosa, LLC · Manage your platform subscription and add-ons.
{" "}Questions? <a href="mailto:billing@cielohermosa.com" className="text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] underline transition-colors">billing@cielohermosa.com</a>
</p>
</div>
</div>
<div className="mx-auto max-w-6xl px-6 py-10">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
<span>/</span>
<a href="/admin/settings" className="hover:text-[var(--admin-text-primary)] transition-colors">Settings</a>
<span>/</span>
<span className="text-[var(--admin-text-primary)]">Billing</span>
</nav>
<div className="mb-8">
<h1 className="text-3xl font-bold text-[var(--admin-text-primary)]">Billing &amp; Subscription</h1>
<p className="mt-1 text-[var(--admin-text-muted)]">
Manage your Route Commerce subscription for {overview.brandName ?? "your brand"}.
</p>
</div>
<BillingClientPage overview={overview} />
</div>
</main>
);
}
@@ -1,61 +0,0 @@
import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import IntegrationsClientPage from "./IntegrationsClientPage";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
export const metadata = {
title: "Integrations - Route Commerce Admin",
description: "Configure integrations for AI, email, SMS, and payments",
};
export default async function IntegrationsPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
const isPlatformAdmin = adminUser.role === "platform_admin";
const brandId = adminUser.brand_id ?? "";
// Platform admins: fetch all brands for the picker
const brands = isPlatformAdmin
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
: [];
return (
<main className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6 pb-10">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
<span>/</span>
<a href="/admin/settings" className="hover:text-[var(--admin-text-primary)] transition-colors">Settings</a>
<span>/</span>
<span className="text-[var(--admin-text-primary)]">Integrations</span>
</nav>
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)] text-white">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-text-primary)]">Integrations</h1>
<p className="text-sm text-[var(--admin-text-muted)]">
Connect AI, email, SMS, and payment providers to power your operations.
</p>
</div>
</div>
</div>
<IntegrationsClientPage
brandId={brandId}
brands={brands}
isPlatformAdmin={isPlatformAdmin}
/>
</div>
</main>
);
}
-63
View File
@@ -1,63 +0,0 @@
import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getShippingSettings } from "@/actions/shipping/settings";
import ShippingSettingsForm from "@/components/admin/ShippingSettingsForm";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
export default async function ShippingSettingsPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_orders || adminUser.role === "store_employee") {
redirect("/admin/pickup");
}
const isPlatformAdmin = adminUser.role === "platform_admin";
const brandId = adminUser.brand_id ?? "";
// Platform admins: fetch all brands for the picker
const brands = isPlatformAdmin
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
: [];
const effectiveBrandId = brandId || (brands[0]?.id ?? "");
const result = await getShippingSettings(effectiveBrandId);
const settings = result.success ? result.settings : null;
return (
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-2xl">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs mb-6" style={{ color: "var(--admin-text-muted)" }}>
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
<span>/</span>
<span style={{ color: "var(--admin-text-primary)" }}>Shipping</span>
</nav>
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl" style={{ backgroundColor: "var(--admin-accent)", color: "white" }}>
<svg className="h-5 w-5" 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.25m-2.25 0h2.25m0 0v-.375c0-.621-.504-1.125-1.125-1.125H15m-1.5-3l1.5 0l.75 0v-.375c0-.621-.504-1.125-1.125-1.125H15m0 0v-.375c0-.621-.504-1.125-1.125-1.125H12" />
</svg>
</div>
<h1 className="text-3xl font-bold" style={{ color: "var(--admin-text-primary)" }}>Shipping Settings</h1>
</div>
<p className="mt-2 text-sm" style={{ color: "var(--admin-text-muted)" }}>
Configure FedEx integration for shipping fresh produce sweet corn, onions, and more.
</p>
</div>
<div className="rounded-2xl border p-6 shadow-lg" style={{ backgroundColor: "white", borderColor: "var(--admin-border)" }}>
<ShippingSettingsForm
settings={settings}
brandId={effectiveBrandId}
brands={brands}
isPlatformAdmin={isPlatformAdmin}
/>
</div>
</div>
</main>
);
}
-85
View File
@@ -1,85 +0,0 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { supabase } from "@/lib/supabase";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import TaxDashboard from "@/components/admin/TaxDashboard";
import { PageHeader } from "@/components/admin/design-system";
type Props = {
params: Promise<{ brandId?: string }>;
};
export default async function TaxesPage({ params }: Props) {
const { brandId: brandIdParam } = await params;
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied message="You don't have access to that brand." />;
}
const effectiveBrandId = activeBrandId ?? "";
const isPlatformAdmin = adminUser.role === "platform_admin";
let resolvedBrandId = effectiveBrandId;
if (isPlatformAdmin && !resolvedBrandId) {
const { data: firstBrand } = await supabase
.from("brands")
.select("id")
.limit(1)
.single() as unknown as { data: { id: string } | null };
if (firstBrand?.id) {
resolvedBrandId = String(firstBrand.id);
} else {
return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-6xl">
<div className="rounded-2xl p-8 text-center border" style={{
backgroundColor: "white",
borderColor: "var(--admin-border)"
}}>
<h1 className="text-2xl font-bold" style={{ color: "var(--admin-text-primary)" }}>No Brands Found</h1>
<p className="mt-2" style={{ color: "var(--admin-text-muted)" }}>Create a brand in the database first.</p>
<a href="/admin" className="mt-4 inline-block rounded-xl px-6 py-3 text-sm font-medium border transition-colors"
style={{
backgroundColor: "var(--admin-bg-subtle)",
borderColor: "var(--admin-border)",
color: "var(--admin-text-primary)"
}}>
Back to Admin
</a>
</div>
</div>
</main>
);
}
}
if (!resolvedBrandId) return <AdminAccessDenied />;
const { data: brands } = await supabase
.from("brands")
.select("id, name")
.order("name");
const allBrands = (brands ?? []) as Array<{ id: string; name: string }>;
return (
<main className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-6xl px-6 py-10">
<PageHeader
title="Tax Dashboard"
subtitle="Sales tax collected on orders shipped to nexus states."
/>
<TaxDashboard
brands={allBrands}
initialBrandId={resolvedBrandId}
isPlatformAdmin={isPlatformAdmin}
/>
</div>
</main>
);
}
+88
View File
@@ -0,0 +1,88 @@
import AdminShell from "@/components/admin/AdminShell";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { listBrandsForAdmin } from "@/actions/brands";
import { getEnabledAddons } from "@/actions/billing/stripe-portal";
import { ToastProvider } from "@/components/admin/Toast";
import { ToastContainer } from "@/components/admin/ToastContainer";
import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition";
import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
return (
<ToastProvider>
{children}
<ToastContainer />
</ToastProvider>
);
}
export default async function AdminV2Layout({ children }: { children: React.ReactNode }) {
let adminUser = null;
try {
adminUser = await getAdminUser();
} catch {
return (
<ToastProviderWrapper>
<AdminAccessDenied message="Failed to verify authentication." />
</ToastProviderWrapper>
);
}
if (!adminUser) {
return (
<ToastProviderWrapper>
<AdminAccessDenied message="You don't have admin access." />
</ToastProviderWrapper>
);
}
if (adminUser.must_change_password) {
redirect("/change-password");
}
// Resolve brand + brands + addons with graceful fallbacks. A failure in
// any of these must not crash the whole shell — the AdminShell renders
// empty state (no brands) rather than a 500.
let activeBrandId: string | null = null;
try {
activeBrandId = await getActiveBrandId(adminUser);
} catch (err) {
console.error("[admin/v2/layout] getActiveBrandId failed:", err);
}
let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
try {
brands = await listBrandsForAdmin();
} catch (err) {
console.error("[admin/v2/layout] listBrandsForAdmin failed:", err);
}
let enabledAddons: Record<string, boolean> = {};
if (activeBrandId) {
try {
enabledAddons = await getEnabledAddons(activeBrandId);
} catch (err) {
console.error("[admin/v2/layout] getEnabledAddons failed:", err);
}
}
return (
<ToastProviderWrapper>
<AdminShell
userRole={adminUser.role}
brandIds={adminUser.brand_ids}
activeBrandId={activeBrandId}
brands={brands}
enabledAddons={enabledAddons}
>
<SmoothViewTransition>{children}</SmoothViewTransition>
<RouteAnnouncer />
</AdminShell>
</ToastProviderWrapper>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { notFound } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAdminOrderDetail, type AdminOrder } from "@/actions/orders";
import { formatDate } from "@/lib/format-date";
import PageHeader from "@/components/admin/PageHeader";
import StatusPill, { type StatusKind } from "@/components/admin/StatusPill";
import StickyActionBar from "@/components/admin/StickyActionBar";
import OrderActionButtons from "@/components/admin/orders/OrderActionButtons";
import FulfillmentTimeline from "@/components/admin/orders/FulfillmentTimeline";
export const dynamic = "force-dynamic";
/**
* Map the legacy `orders.status` + `pickup_complete` pair onto the
* StatusPill's `StatusKind` union. Must match the helper in
* `orders/page.tsx` so the list and detail pages agree on the
* pill vocabulary. Kept inlined here to avoid a shared-types file
* until we know the mapping is stable.
*/
type OrderStatusKind = "placed" | "ready" | "picked-up" | "cancelled";
function statusKind(o: { status: string; pickup_complete: boolean }): OrderStatusKind {
if (o.status === "canceled") return "cancelled";
if (o.pickup_complete || o.status === "fulfilled") return "picked-up";
if (o.status === "confirmed") return "ready";
return "placed";
}
export default async function OrderDetailV2Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const adminUser = await getAdminUser();
if (!adminUser) return null;
const order = await getAdminOrderDetail(id);
if (!order) notFound();
const kind = statusKind(order);
const subtotal = Number(order.subtotal ?? 0);
const taxAmount = Number(order.tax_amount ?? 0);
const discountAmount = Number(order.discount_amount ?? 0);
const total = subtotal + taxAmount - discountAmount;
const itemCount = order.order_items?.length ?? 0;
return (
<main className="pb-40">
<PageHeader
title={`Order #${order.id.slice(0, 8).toUpperCase()}`}
subtitle={formatDate(order.created_at)}
actions={<StatusPill status={kind as StatusKind} />}
/>
<div className="px-4 space-y-4">
<section
className="rounded-2xl p-4"
style={{ backgroundColor: "var(--color-surface-2)" }}
>
<h2
className="text-h1 font-display"
style={{ fontWeight: 600 }}
>
{order.customer_name}
</h2>
<div className="mt-2 space-y-1">
{order.customer_phone && (
<a
href={`tel:${order.customer_phone}`}
className="block text-body"
style={{ color: "var(--color-accent)" }}
>
{order.customer_phone}
</a>
)}
{order.customer_email && (
<a
href={`mailto:${order.customer_email}`}
className="block text-body"
style={{ color: "var(--color-accent)" }}
>
{order.customer_email}
</a>
)}
</div>
</section>
{order.stops && (
<section
className="rounded-2xl p-4"
style={{ backgroundColor: "var(--color-surface-2)" }}
>
<h3
className="text-label font-semibold mb-1"
style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}
>
PICKUP
</h3>
<div className="text-h2" style={{ fontWeight: 700, lineHeight: 1.3 }}>
{order.stops.city}, {order.stops.state}
</div>
{order.stops.date && (
<div
className="text-meta mt-1"
style={{ color: "var(--color-text-muted)" }}
>
{formatDate(order.stops.date)}
</div>
)}
</section>
)}
<FulfillmentTimeline
status={kind}
placedAt={order.created_at}
pickedUpAt={order.pickup_completed_at}
/>
<section
className="rounded-2xl p-4"
style={{ backgroundColor: "var(--color-surface-2)" }}
>
<h3
className="text-label font-semibold mb-3"
style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}
>
ITEMS ({itemCount})
</h3>
{itemCount > 0 ? (
<ul className="space-y-3">
{order.order_items?.map((item: NonNullable<AdminOrder["order_items"]>[number]) => {
const unitPrice = Number(item.price ?? 0);
const lineTotal = unitPrice * item.quantity;
return (
<li key={item.id} className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-h2" style={{ fontWeight: 700 }}>
{item.products?.name ?? "Unknown product"}
</div>
<div
className="text-meta"
style={{ color: "var(--color-text-muted)" }}
>
{item.quantity} × ${unitPrice.toFixed(2)}
</div>
</div>
<div className="text-body font-semibold">
${lineTotal.toFixed(2)}
</div>
</li>
);
})}
</ul>
) : (
<div
className="text-body"
style={{ color: "var(--color-text-muted)" }}
>
No items
</div>
)}
<div
className="mt-4 pt-3 border-t flex items-center justify-between"
style={{ borderColor: "var(--color-surface-3)" }}
>
<span
className="text-label font-semibold"
style={{ color: "var(--color-text-muted)", letterSpacing: "0.02em" }}
>
TOTAL
</span>
<span
className="text-h1 font-display"
style={{ fontWeight: 600 }}
>
${total.toFixed(2)}
</span>
</div>
</section>
{order.internal_notes && (
<section
className="rounded-2xl p-4 italic"
style={{ backgroundColor: "var(--color-surface-2)" }}
>
<p className="text-body">&ldquo;{order.internal_notes}&rdquo;</p>
</section>
)}
</div>
<StickyActionBar>
<OrderActionButtons orderId={order.id} status={kind} />
</StickyActionBar>
</main>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getAdminOrders } from "@/actions/orders";
import PageHeader from "@/components/admin/PageHeader";
import { CardList, CardListItem } from "@/components/admin/CardList";
import StatusPill, { type StatusKind } from "@/components/admin/StatusPill";
import EmptyState from "@/components/admin/EmptyState";
import OrdersFilterButton from "@/components/admin/orders/OrdersFilterButton";
import PullToRefresh from "@/components/admin/PullToRefresh";
import { formatRelativeTime } from "@/lib/format-date";
export const dynamic = "force-dynamic";
/**
* Map the legacy `orders.status` + `pickup_complete` pair onto the
* StatusPill's `StatusKind` union. The legacy schema uses
* ('pending' | 'confirmed' | 'fulfilled' | 'canceled') and a boolean
* `pickup_complete`; the v2 design uses the simpler
* (placed | ready | picked-up | cancelled) vocabulary.
*/
function statusKind(o: { status: string; pickup_complete: boolean }): StatusKind {
if (o.status === "canceled") return "cancelled";
if (o.pickup_complete || o.status === "fulfilled") return "picked-up";
if (o.status === "confirmed") return "ready";
return "placed";
}
export default async function OrdersV2Page({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const adminUser = await getAdminUser();
if (!adminUser) return null;
const result = await getAdminOrders();
const allOrders = result.success ? result.orders : [];
const params = await searchParams;
// The legacy RPC doesn't accept a status filter — apply it client-side.
// The shape we filter on matches the StatusPill vocabulary (placed/ready/
// picked-up/cancelled), not the legacy DB status string, so the user
// intent is the same.
const filtered = params.status
? allOrders.filter((o) => statusKind(o) === params.status)
: allOrders;
// Show at most 50 — the plan's example limit. Anything more is paginated
// (PR follow-up).
const orders = filtered.slice(0, 50);
if (orders.length === 0) {
return (
<main>
<PageHeader title="Orders" actions={<OrdersFilterButton />} />
<EmptyState
title="No orders yet"
description="When customers place orders, they'll show up here."
icon={<span aria-hidden="true">📦</span>}
/>
</main>
);
}
return (
<main>
<PageHeader
title="Orders"
subtitle={`${orders.length} order${orders.length === 1 ? "" : "s"}`}
actions={<OrdersFilterButton />}
/>
<PullToRefresh>
<CardList ariaLabel="Orders">
{orders.map((order) => {
const total = Number(order.subtotal ?? 0);
return (
<CardListItem key={order.id} href={`/admin/v2/orders/${order.id}`}>
<div className="flex items-start justify-between gap-3 mb-2">
<StatusPill status={statusKind(order)} />
<span
className="text-mono"
style={{ color: "var(--color-text-faint)" }}
>
#{order.id.slice(0, 8)}
</span>
<span
className="text-meta ml-auto"
style={{ color: "var(--color-text-muted)" }}
>
{formatRelativeTime(order.created_at)}
</span>
</div>
<div
className="text-h2"
style={{ fontWeight: 700, lineHeight: 1.3 }}
>
{order.customer_name ?? "Customer"}
</div>
<div className="flex items-center justify-between mt-2">
<span
className="text-h1 font-display"
style={{ fontWeight: 600 }}
>
${total.toFixed(2)}
</span>
<span
aria-hidden="true"
style={{ color: "var(--color-text-faint)" }}
>
</span>
</div>
</CardListItem>
);
})}
</CardList>
</PullToRefresh>
</main>
);
}

Some files were not shown because too many files have changed in this diff Show More