The Wholesale Portal admin client was the largest single file in the app
(2391 lines) and mixed 11 distinct UI concerns in one module.
Split into focused files under src/components/wholesale/admin/:
types.ts shared types (MsgFn, PendingRegistration, ...)
WholesaleIcon.tsx header SVG
WholesaleLoadingSkeleton.tsx loading state
StatusBadge.tsx order status pill
AddRecipientForm.tsx notification recipient input
PriceSheetModal.tsx bulk price-sheet send confirmation
CustomerPricingPanel.tsx per-customer pricing overrides
WebhookSettingsSection.tsx outbound webhook config + test dispatch
DashboardTab.tsx stat cards + recent orders + webhook feed
ProductsTab.tsx wholesale product CRUD
CustomersTab.tsx customers + registrations + pricing
OrdersTab.tsx orders + bulk ops + deposit modals
SettingsTab.tsx portal settings + webhook + recipients
WholesaleClient.tsx is now a 189-line shell that owns data loading and
tab routing. No behavior changes; the original logic was preserved
verbatim. Mechanical extraction only.
Also fixed an existing import bug where getPendingWholesaleRegistrations
was being imported from @/actions/wholesale instead of
@/actions/wholesale-register (typecheck caught it).
Verified: typecheck clean for new code (pre-existing dahlia/fetch mock
errors unrelated). Build compiles through to the same pre-existing
billing type error. Zero new lint warnings introduced.
Refactor-progress tracked at /tmp/refactor-routecomm.md.
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.
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).
- 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.
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.
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.
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).
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.
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.
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'.
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).
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.
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.
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.
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.
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.
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).