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.
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.
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).
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).
The 'Send Reset Email' button in /admin/users previously just
cleared the error banner on success, with no actual 'yes it
worked' feedback. Added a green success banner that mirrors the
error banner's style and auto-dismisses after 6 seconds.
The 'Reset Password' button already shows confirmation in the
modal (temp password to copy, or 'reset email sent' message), so
it doesn't need the banner.
Also tightened the type narrowing in the resetAdminPassword unit
tests — the discriminated union needed a two-step
narrow (`r.success` then `r.method`) before TypeScript would
allow access to variant-specific fields like `tempPassword`.
Both buttons in /admin/users were broken:
- "Send Reset Email" called a no-op stub in
src/actions/admin/users.ts that always returned an error.
- "Reset Password" called resetAdminPassword with a hard-coded
'Tuxedo2026!' password, and the function itself queried a
non-existent `users` table and called a non-existent
`update_user_password` RPC (leftover Supabase-era code).
Rewritten against Neon Auth:
- sendPasswordResetEmail(email) — platform_admin-only action that
calls auth.requestPasswordReset with the configured
NEXT_PUBLIC_SITE_URL + '/reset-password' redirect. Always returns
a clear success/error.
- resetAdminPassword(email) — platform_admin-only action that:
1. Looks up neon_auth.user by email
2. Generates a strong server-side random temp password
3. Tries auth.admin.setUserPassword first (instant credential,
returned to the UI for the platform admin to share)
4. On FORBIDDEN / UNAUTHORIZED / INTERNAL_SERVER_ERROR (the common
case — provision-admin.ts does not promote callers to
role='admin' in Neon Auth), falls back to
auth.requestPasswordReset, which sends a reset link the user
can click to set their own password.
5. On the privileged path, flips admin_users.must_change_password
so the user is forced to pick a real password on next sign-in.
UI updated to handle the new response shape (success-with-temp-
password vs success-with-reset-email-sent) with a fourth modal
state.
Tests: 19 new unit tests across both paths cover authz, input
handling, the privileged happy path, the FORBIDDEN/UNAUTHORIZED/
network-throw fallback, USER_NOT_FOUND surfacing, and the
'both paths fail' case. Full suite: 110/113 (3 pre-existing
getAdminUser failures unchanged).
The sendEmail / sendCampaignEmail / sendWelcomeEmail /
sendOrderReceiptEmail / sendPasswordResetEmail / sendOperationalAlert
helpers all returned Promise<boolean> and silently returned false on
any failure (missing API key, 401 invalid key, 422 unverified sender
domain, network error, etc.). The caller had no way to know which
problem it was.
Switch the public return type to a structured result:
export type EmailSendResult = { ok: true } | { ok: false; error: string };
- Missing RESEND_API_KEY: error reads 'RESEND_API_KEY is not set.
Add it to .env.local (or your hosting dashboard) and restart the
server.'
- 4xx/5xx: error reads 'Resend 422 — validation_error: The gmail.com
domain is not verified' (extracts Resend's own name + message).
- Network failure: error reads the thrown message verbatim.
- Non-JSON error body: falls back to 'Resend <status>'.
Callers updated:
- src/actions/admin/users.ts (createAdminUser): now propagates the
real emailError into the modal UI — no more generic 'sendWelcome
Email returned false'.
- src/actions/checkout.ts: logs the real error.
- src/app/api/cron/send-scheduled/route.ts: per-recipient error
logged for the scheduled-campaigns cron.
Tests:
- tests/unit/email-service.test.ts (new, 5 tests): covers happy
path, missing key, 4xx/5xx with JSON body, 4xx/5xx with non-JSON
body, and network failure.
- tests/unit/create-admin-user.test.ts: updated to use the new
{ ok, error? } return shape; new assertion that the action
surfaces the real emailError string into the result.
Wires up Better Auth's signIn.social({ provider: 'google' }) so users
can authenticate via Google OAuth. The flow is:
1. User clicks the Google button.
2. Client calls the signInWithGoogleAction server action.
3. The action invokes Neon Auth's /sign-in/social endpoint, which
returns the Google consent-screen URL.
4. Client navigates the browser to that URL.
5. Google redirects back through Neon Auth to the callbackURL.
Files:
- src/actions/auth-actions.ts: new signInWithGoogleAction server
action that wraps signIn.social and returns the redirect URL
to the client. Structured { url, error } return — never throws.
- src/app/login/LoginClient.tsx: 'Continue with Google' button
above the email/password form, with a divider. Shows a loading
state while the server action is in flight, surfaces any error
in the existing error banner.
- src/app/wholesale/login/page.tsx: same button for wholesale
buyers. Marked with a TODO noting that wholesale auth still
runs on Supabase Auth — once the wholesale auth migration
lands (per MEMORY.md 'What's left'), the button will start
working for them. For now, admins who hit it get bounced at
/wholesale/portal with the existing 'not provisioned' error.
- tests/unit/sign-in-with-google.test.ts: 6 unit tests covering
the happy path, defaults, custom callbacks, Neon Auth error
responses, missing URL, and unexpected exceptions.
Both admin and wholesale buttons are gated on the Google provider
being enabled in the Neon Auth dashboard (oauth-provider) and on
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET being set in the runtime
env — both are already wired through .gitea/workflows/deploy.yml.
Previously createAdminUser only inserted a local admin_users row and
emailed the password as plaintext — the user could not sign in because
the password was never set on a Neon Auth account.
This change makes the create flow:
1. Authorize: only platform_admin can mint new admin users.
2. Create the Neon Auth user via auth.admin.createUser, falling back
to the public /sign-up/email + emailVerified=true pattern when
the caller's Neon Auth session isn't an admin (the common case
in dev — provision-admin.ts does not set neon_auth.user.role).
3. Wrap the admin_users INSERT + admin_user_brands link in a
transaction, returning an error if the local insert fails (the
Neon Auth user is left orphaned and surfaced in the message).
4. Send the welcome email best-effort, returning success/failure
info to the UI.
The CreateUserModal now shows a success state with the temp password
(copy-to-clipboard), the welcome email status, and the auth path
used. The slide-in edit panel surfaces the password via window.alert
as a defense against the dead-code new-user path.
10 new unit tests cover authorization, the admin + signup paths, the
DB-failure orphan case, and the email best-effort behavior.
- page.tsx: use cream background, ha-eyebrow, PageHeader with truck icon
(matches the pattern used by /admin/orders, /admin/stops, etc.)
- ShippingFulfillmentPanel.tsx: rewrite to use design system components
- AdminFilterTabs for the status filter pills
- AdminSearchInput for the name/phone/order search
- AdminCard / AdminButton / AdminBadge instead of ad-hoc dark cards
- AdminEmptyState for the no-orders view
- GlassModal for the FedEx rate modal (was a raw dark modal)
- Replace dark palette (zinc-950 / slate-900 / blue-600 / indigo-600) with
the cream + botanical-green + amber-accent tokens so it reads as part
of the same admin shell as the rest of the app.
The /admin/shipping page was 500-ing with 'column c.name does not
exist' because the getShippingOrders query in src/actions/shipping.ts
was still using legacy column names on the new-schema customers
table (name, email, phone) — the actual columns are first_name,
last_name, primary_email, primary_phone.
The dashboard and analytics pages were logging the same kind of
errors but catching them, so the UI just showed zeroed stats instead
of crashing. Same fix:
- customers: name → first_name || ' ' || last_name
- customers: email → primary_email
- customers: phone → primary_phone
- orders: subtotal → total_cents / 100
- orders: created_at → placed_at
- orders: customer_name → join customers and concat first/last
These are the same kind of fixes that landed in migration 0041 for
the command-center RPCs. The application-layer queries just hadn't
been updated.
The <ViewTransition name="page-content"> wrapper around the admin
main content was triggering a 'shared element' morph on every page
load and navigation: the browser tries to animate the bounding box
of the named element across the old and new pages, and since each
admin page has a different height, the browser was scaling the
snapshot from the top-left corner. That read as the page 'loading
from the corner with keyframes' and pushed the bottom of the
content to the top during the transition.
Setting update="none" opts out of the named transition entirely.
The children just swap on navigation — no fade, no scale, no morph.
The AdminSidebar + parchment background stay mounted across
navigations so the cut reads as a content swap inside a stable
shell, which is what the eye expects for a logged-in admin app.
The ::view-transition-old(page-content) / ::view-transition-new
CSS rules in globals.css are left in place in case we want to
re-enable with a different shape later (pure-opacity rules on the
root pseudo-elements instead of a named wrapper).
The /admin/command-center route was platform_admin-only, used a heavy
custom theme (Major Mono / JetBrains / Inter Tight + ~700 lines of
schematic CSS), called three RPCs, and overlapped with the per-brand
dashboards reachable at /admin, /admin/orders, /admin/stops, and
/admin/reports. The page is being removed; it didn't justify the
build complexity, font payload, or schema surface area.
Changes:
- Drop page, dashboard component, CSS, and server actions
- Remove the platform_admin nav entry from header + sidebar
- Remove the cmd+K palette entry
- Add migration 0042 to drop the 3 RPCs, founder_pain_log table,
and founder_pain_log_platform view it owned
- Decrement route count in docs/pricing-assessment.md (88 -> 87)
Verification: tsc clean, eslint clean, npm run build clean, command-
center absent from the route list.
Migration 0040 was written against an assumed schema and referenced
columns that don't exist in the prod orders table:
- orders.created_at → use orders.placed_at
- orders.subtotal → use orders.total_cents / 100
- stops.date regex → column is already DATE, not TEXT, drop the
~ '^\d{4}-...$' check
Migration 0041 fixes both broken functions in place via
CREATE OR REPLACE FUNCTION.
The action layer at src/actions/platform/command-center.ts used
SELECT fn() AS "fn" for all platform RPCs, but two of the three
return TABLE(...) (setof) — the correct syntax is SELECT * FROM fn().
The platformRPC<T> helper now takes a kind: 'scalar' | 'setof' flag
and emits the right SQL.
Verified on prod:
METRICS: { orders_today:0, active_brands:1, active_routes:538, ... }
BRAND_HEALTH: 1 row (Tuxedo Corn), 538 active_stops, 0 failed, healthy schema
ACTIVITY: 0 rows (operational_events has no brand_id in payload, no error)
Aesthetic shift from generic dark glassmorphism to an industrial
mission-control look: phosphor amber on near-black, Major Mono Display
for the wordmark, JetBrains Mono for all labels/numbers/codes, Inter
Tight for body copy.
- Top status bar with live clock, pulsing LIVE LED, and scrolling brand
ticker (plan tiers + slugs)
- Serial-numbered sections (// 01-07) with CAD-style corner crosshairs
and scale-rule dividers
- 6 KPIs in a flush 1px-separated grid with tabular numerals, sparklines,
and per-cell M.0N serials
- AI briefing as a numbered readout with model metadata sidebar
- Brand health panels with severity LEDs, vertical dividers between
metrics, and open-pain indicators
- Activity feed as terminal-style rows with color-coded event codes
- Pain log items with severity LEDs and P.NNN serials
- Quick Access links with L.NN prefixes and slide-on-hover
- Loading state replaced with terminal-style blinking bar + 'BOOT
SEQUENCE' eyebrow
- 'Connection Lost' replaced with 'SIGNAL LOST · Platform Disconnected'
panel that surfaces the actual error, reconnect action, and a
diagnostic hint footer
- prefers-reduced-motion respected across ticker, LED pulse, and
boot-reveal animations
- TV Mode scales up the title, KPIs, and briefing for wall display
The /admin/command-center page calls 3 RPCs and a table that only existed in
the archived Supabase migrations (126/127). The active db/migrations/ directory
had no replacement, so prod was missing these objects and the page threw
'Connection Lost' on every load (fetchAll() Promise.all rejects on missing RPCs).
Un-archives the original SQL into a single new migration (0040_command_center.sql)
with idempotent CREATE OR NOT EXISTS / CREATE OR REPLACE so it's safe to apply
to DBs that may already have some of the objects.
Follow-up to 9fcc514. The global motion pass hit the public site;
this targets the admin design system, which had its own animation
budget that was still producing positional movement on every
interaction.
Killed:
- TabSwitcher y:4 slide (every tab change in /admin)
- CommandPalette scale(0.98) panel pop
- ha-drawer-in: 100% translateX (full-screen sweep)
- ha-popover-in: translateY(-4px) + scale(0.98)
- ha-check-pop: cubic-bezier(0.34, 1.56, 0.64, 1) 1.15x overshoot
- slide-in-from-right: 100% translateX (toasts)
- dashboard-fade-up: 10px Y slide on every card
- tailwindcss-animate keyframes for slide-in-from-{left,right,top,bottom} and zoom-in-{90,95} (used by ToastContainer, LotDetailPanel)
Calmed:
- All durations cut to 120-200ms range with ease-out
- All keyframes that survived are opacity-only
Added a @media (prefers-reduced-motion: reduce) block scoped to
the admin selectors so users with that OS setting see instant
transitions across toasts/drawers/popovers/dashboard cards.
The site had a lot of aggressive motion that compounded into a
vertigo-inducing experience. Visual design (colors, type, layout) is
unchanged — only the movement has been calmed.
Single-commit overview:
- Route transitions: 90/140ms pure-opacity crossfade (was 220ms with
4-6px Y-shift on enter/exit).
- atelier-* modal animations: 180ms opacity-only, 4px max (was 420ms
with 20px slide + scale(0.96→1) and 80-440ms cumulative stagger).
- Hover transforms: -1px lift or no lift (was -2px + scale(0.98)).
- CTA shimmer: 400ms (was 700ms).
- Toggle thumb: ease-out (was cubic-bezier(0.34, 1.56, 0.64, 1) bouncy
overshoot).
- GSAP ScrollAnimations: capped at 12-16px translation, 320ms duration,
power1.out, reduced-motion guards at the top of every effect.
ParallaxLayer no longer scrolls content; only the data-parallax
attribute can opt in, and only to 24px.
- TuxedoVideoHero: killed 80px scroll-driven Y-shift on hero, killed
video 1.15 scale-on-scroll, killed parallax-float scroll effect, cut
hero-reveal to 8px/320ms (was 40px/1s/power3.out), removed the
motion.scale on the logo and CTA buttons, slowed the bouncing
scroll indicator from 1.5s to 2.4s.
- CinematicShowcase: killed morphing product cards (rotateY ±5°,
scale 0.95→1.02), killed parallax background HARVEST text (-100px),
killed translateX carousel, killed scale(0.9→1) back.out(1.4) reveal
in favor of opacity-only 8px/320ms entrance, removed progress-dot
scale, removed progress-bar transition lag.
- OnboardingFlow: removed scale(0.9→1) and y:20→0 entrance animations.
- Global MotionConfig: caps every framer-motion animation in the tree
to 0.2s easeOut, and sets reducedMotion='user' so framer-motion
automatically strips x/y/scale/rotate from all 71 motion.div reveals
across the public site when the OS prefers-reduced-motion is set.
- globals.css prefers-reduced-motion block: comprehensive kill switch
that disables animation/transition duration app-wide, wipes the
route view-transition, and clears the .parallax-float / .hero-reveal
transforms.
How to test:
- Default: motion is calmer, ~10x faster, with no parallax
- OS-level 'reduce motion' on: zero positional movement, opacity fades
only.
Files changed: 7 (no new files)
- List page: new PageHeader with 'OPERATIONS' eyebrow + 'Add product' CTA
- New/Edit pages: PageHeader with eyebrow, icon, status badge (edit)
- ProductsClient: EmptyState for zero products, AdminBadge for status,
Fragment Mono on stats, token-based colors throughout
- ProductTableBody: AdminBadge tones for status/taxable, token-based
hover/selected states, Fragment Mono on price column
- NewProductForm / ProductEditForm / ProductFormModal: surrounding
chrome on tokens; save bars use .ha-btn-primary / .ha-btn-ghost
- .atelier-* body preserved as editorial treatment
- Add PageHeader + Operations eyebrow on list page
- Detail page: PageHeader with order-id/date eyebrow, customer title,
total/status subtitle, AdminBadge tone for pickup + payment processor
- AdminOrdersPanel: KPIStat for stat cards, EmptyState with Create your
first order CTA, AdminBadge tone for status pills, lucide-react icons,
all hardcoded Tailwind colors replaced with var(--admin-*) tokens
- OrderTableBody: AdminBadge tone for status + pickup pills, all hardcoded
colors replaced with tokens
- OrderEditForm: ha-field-label + ha-field-input / ha-field-textarea classes
for form fields, ha-segment control for status buttons, semantic pickup
toggle with primary/warning tokens, all hardcoded colors replaced
Behavior preserved end-to-end. Type-check clean.