Merchants Navigation Refactor β Design β
Date: 2026-04-25 Scope: apps/admin/ β Lantern admin portal Context: Builds on 2026-04-24-merchants-page-patterns-design.md. Reference data model: services/api/auth/src/routes/adminUsers.js (the merchantId invariant on merchant-role users) and services/api/auth/src/routes/adminMerchants.js (the ownerUserIds[] reverse link).
Goal β
Replace the cross-merchant top-level tab strip (Overview Β· Offers Β· All Merchants) with a chrome that lifts "the merchant currently being worked on" to a header-level concept. After this change:
- Top-level routes (
/merchants/all,/merchants/create) wear their own header chrome β no shared tab strip. - Per-merchant routes (
/merchants/:merchantId/*) gain an admin-only switcher dropdown and breadcrumb in the existingMerchantDetailPageHeader. - Admin's "currently working on" merchant persists in localStorage; sidebar clicks smart-redirect there.
- Merchant-role users redirect from
/merchantsto/merchants/:theirMerchantId/overviewusingusers/{uid}.merchantId. - The two placeholder pages β
MerchantsOverview.jsxandMerchantsOffers.jsxβ and the now-passthroughMerchantsListLayout.jsxare deleted.
The eventual Offers and Analytics per-merchant tabs (and the port of OfferForm / MerchantDashboard from apps/web/) are out of scope here. They are project C, sequenced after this refactor lands.
Why now β
The page-patterns refactor (2026-04-24) introduced MerchantsListLayout as a 3-tab shell with role-based filtering. After scoping the upcoming offers work, two of those three top-level tabs (Overview, Offers) turn out not to model anything real β they're cross-merchant placeholders that will never have meaningful content because per-merchant overview and offers belong inside MerchantDetail. Deleting them now, before the offers work begins, prevents propagating the awkward shape into project C.
Lifting "current merchant" to header chrome also matches the multi-tenant SaaS pattern (Stripe, Linear, Shopify, GitHub) β admins think in terms of a single working context with a switcher, not "drill in / drill back out" of a list every time.
Architecture β
1. Two distinct chromes β
Cross-merchant chrome (admin-only routes) β /merchants/all, /merchants/create:
PageHeader
title: "Merchants" / "Create Merchant"
subtitle: "Select a merchant to view details, or create a new one." (on /all only)
actions: [+ Create Merchant] (on /all, admin) + <MerchantSwitcher /> (admin, when current set)
Body
MerchantsAll table / MerchantsCreate formPer-merchant chrome β /merchants/:merchantId/{overview,venues,notes,photos,address}, both roles:
PageHeader
breadcrumb: "β Merchants" linking to /merchants/all (admin only)
title: <merchant business name>
subtitle: "Merchant account details" (existing)
actions: [Edit] / [Cancel] [Save] (existing) + <MerchantSwitcher /> (admin)
PageTabs
Overview Β· Venues (n) Β· Notes Β· Photos Β· Address (existing β no new tabs)
Body
Outlet (existing)Two chromes is deliberate over a unified conditional layout β they have different shapes (tabs vs. no tabs, list-page title vs. entity-name title), so a unified version drifts toward conditionals at every render.
2. URL / routing β
<Route path="/merchants">
<Route index element={<MerchantsIndexRedirect user={user} isAdmin={isAdmin} />} />
<Route path="all" element={<MerchantsAll />} />
<Route path="create" element={<MerchantsCreate />} />
<Route path=":merchantId" element={<MerchantDetail />}>
{/* existing nested overview/venues/notes/photos/address */}
</Route>
</Route>Routes removed: /merchants/overview, /merchants/offers. Their components (MerchantsOverview.jsx, MerchantsOffers.jsx) and the wrapping MerchantsListLayout.jsx are deleted.
The sidebar merchants nav item routes to /merchants (unchanged from the page-patterns work). The smart redirect at the index does the role/state-aware destination logic.
3. Current-merchant persistence (localStorage) β
Key: lantern.admin.currentMerchant. Shape:
{
v: 1,
current: 'merch_abc123' | null,
recent: ['merch_abc123', 'merch_xyz789', ...] // most recent first, max 8
}Admin-only. Merchant-role users never read or write this β their canonical merchant is users/{uid}.merchantId.
Helper module β new file, apps/admin/src/lib/currentMerchant.js. Pure JS, no React, no Firebase. Exports:
| Function | Purpose | Called from |
|---|---|---|
getCurrentMerchantId() | Returns string or null. Defensive against parse errors. | MerchantsIndexRedirect |
getRecentMerchantIds() | Returns array, most recent first | MerchantSwitcher |
rememberMerchant(id) | Sets current = id, unshifts onto recent, dedupes, caps at 8 | MerchantDetail mount/param-change |
forgetMerchant(id) | Removes id from current (if matches) and recent | MerchantDetail 404 branch |
clearCurrentMerchant() | Wipes the whole object | Admin sign-out flow |
Storage cap is 8; dropdown displays 5 plus a "View all merchants β" link. Storing more than displayed means a merchant from 7 visits ago can re-surface in the dropdown after newer entries cycle out, instead of being permanently evicted.
The URL is authoritative for "what merchant is on screen right now." localStorage is consulted only at redirect time and to populate the recent list in the switcher. Components that render merchant data read useParams().merchantId, never getCurrentMerchantId(). This avoids the two-sources-of-truth trap when admin opens multiple tabs.
4. Smart redirect (MerchantsIndexRedirect) β
New component at apps/admin/src/components/merchants/MerchantsIndexRedirect.jsx:
import { Navigate } from 'react-router-dom'
import { getCurrentMerchantId } from '../../lib/currentMerchant'
export default function MerchantsIndexRedirect({ user, isAdmin }) {
if (!isAdmin) {
if (user?.merchantId) {
return <Navigate to={`/merchants/${user.merchantId}/overview`} replace />
}
return <MerchantAccountMisconfigured />
}
const currentId = getCurrentMerchantId()
return currentId
? <Navigate to={`/merchants/${currentId}/overview`} replace />
: <Navigate to="/merchants/all" replace />
}<Navigate replace /> is required (not optional) β without replace, the redirect adds /merchants to browser history, and the back button creates an infinite redirect loop.
Optimistic redirect, no pre-flight fetch. When admin's currentId points to a deleted merchant, the redirect lands on /merchants/:current/overview, MerchantDetail fetches and 404s, the 404 branch calls forgetMerchant(currentId) and <Navigate to="/merchants/all" replace />. Self-healing β no extra request on the happy path.
Auth-state prerequisite. The admin app's user prop must carry merchantId for the merchant-role redirect to work. If the existing auth hydration in apps/admin/src/firebase.js doesn't already load this field from users/{uid}, add it (~3 lines). Verify before implementation.
5. Switcher dropdown (MerchantSwitcher) β
New component at apps/admin/src/components/merchants/MerchantSwitcher.jsx. Admin-only. Renders inside the PageHeader actions slot of both chromes.
Structure (when on a per-merchant page with current set):
ββββββββββββββββββββββββββββββββββββββββ
β CURRENTLY VIEWING β
β β <merchant name from useParams> β
ββββββββββββββββββββββββββββββββββββββββ€
β RECENT β
β <up to 5 names from getRecentMerchantIds, excluding current> β
ββββββββββββββββββββββββββββββββββββββββ€
β View all merchants β β
ββββββββββββββββββββββββββββββββββββββββOn /merchants/all or /merchants/create (no :merchantId in URL), the "Currently viewing" section is hidden β just RECENT + View all.
When recent is empty (fresh admin):
ββββββββββββββββββββββββββββββββββββββββ
β No recent merchants β
ββββββββββββββββββββββββββββββββββββββββ€
β View all merchants β β
β + Create merchant β
ββββββββββββββββββββββββββββββββββββββββName resolution. recent[] stores IDs only. On dropdown open, fetch all IDs in parallel via Promise.all([...].map(id => getMerchantData(id))). Cache results in component state for the session. β€8 fetches, fine without further optimization.
"Currently viewing" sourced from useParams().merchantId, not from localStorage current. This keeps the switcher in sync with what the page is actually rendering, including across tabs.
6. Breadcrumb β
Per-merchant chrome only, admin only. Single chevron + link: β Merchants routing to /merchants/all. Hidden for merchant-role users (they have no /merchants/all to navigate back to β it would be a 404 trap).
Implemented as a new breadcrumb prop on the existing PageHeader component (rendered above the title). The prop is optional; existing PageHeader consumers are unaffected.
7. Per-route header wiring (after MerchantsListLayout deletion) β
MerchantsAll.jsx and MerchantsCreate.jsx each render their own PageHeader inline (since the shared layout no longer exists). The header content is small and per-route specific; no shared abstraction warranted.
MerchantDetail.jsx already renders its own PageHeader (added in the page-patterns work). It gains: the breadcrumb, the switcher in the actions slot, the useEffect calling rememberMerchant, and a self-healing 404 branch.
Data flow β
- URL is the source of truth for "merchant currently being viewed."
useParams().merchantIddrivesMerchantDetail, the breadcrumb, and the switcher's "Currently viewing" section. - localStorage is a write-on-route-mount, read-at-redirect-and-switcher-render layer. Updated by
rememberMerchantinsideMerchantDetail's effect; read byMerchantsIndexRedirectandMerchantSwitcher. Never observed via subscription β components don't react to localStorage changes; they react to URL changes (which trigger localStorage updates as a side effect). users/{uid}.merchantIdis the canonical link for merchant-role users. Loaded into theuserprop during admin auth hydration; consumed only byMerchantsIndexRedirect.- Multi-tab behavior is intentionally not synchronized. Tabs maintain independent rendered state because URL is authoritative; localStorage updates from one tab don't force re-renders in others. This matches Stripe / Linear behavior and avoids subtle bugs from cross-tab state coupling.
Error handling β
| Case | Trigger | Behavior | Visible copy |
|---|---|---|---|
| Zero merchants exist | Admin lands on /merchants/all, query returns 0 | Hide table; show empty-state card | "No merchants yet" / "Create your first merchant to start managing offers, venues, and more." / [+ Create Merchant] |
| Stored merchant deleted (admin) | Admin β /merchants β redirect to /merchants/X/overview β 404 | forgetMerchant(X) + <Navigate to="/merchants/all" replace /> | (silent) |
| Direct URL with bad ID | Admin pastes /merchants/typo123/overview | Same self-heal | (silent) |
| Network error fetching merchant | Fetch throws (offline, server 5xx) | Error state; do not evict (merchant may still exist) | "Couldn't load merchant" / [Try again] / [Back to all merchants] |
| Merchant-role, no merchantId | MerchantsIndexRedirect sees !user?.merchantId | <MerchantAccountMisconfigured /> | "Your merchant account is misconfigured." / "Please contact support to resolve this." / [Sign out] |
| Merchant-role, merchantId points to deleted merchant | Redirect succeeds; MerchantDetail 404s | <MerchantAccountMisconfigured /> (no admin fallback URL exists) | "Your merchant account references a business that no longer exists." / "Please contact support." / [Sign out] |
| Switcher dropdown, no recent | getRecentMerchantIds() returns [] | Empty-state dropdown | "No recent merchants" / [View all merchants β] / [+ Create merchant] |
| localStorage corrupted | JSON.parse throws inside helper module | getCurrentMerchantId() returns null; treat as fresh state | (silent) |
Loading state on MerchantDetail | Fetch in progress | Existing loading UI β no change | (existing) |
The "silent self-heal vs. visible error" distinction is deliberate: silent for stale localStorage and URL typos (the user didn't intentionally trigger them); visible for network errors (user can retry) and invariant violations (user needs human help).
Testing plan β
Automated tests (added in this PR):
| Surface | Test file | Coverage |
|---|---|---|
currentMerchant.js | apps/admin/src/lib/__tests__/currentMerchant.test.js | LRU push order, dedupe, cap at 8, forgetMerchant removes from both fields, clearCurrentMerchant wipes everything, JSON.parse errors return null |
MerchantsIndexRedirect.jsx | apps/admin/src/components/merchants/__tests__/MerchantsIndexRedirect.test.jsx | The 4 redirect cases (merchantβownId, merchantβmisconfigured, adminβstored, adminβ/all). Same MemoryRouter pattern as the existing MerchantsListLayout.test.jsx. |
MerchantSwitcher.jsx | apps/admin/src/components/merchants/__tests__/MerchantSwitcher.test.jsx | Empty state when recent is empty; "Currently viewing" only renders when useParams().merchantId is set; click on a recent item navigates; "View all" routes to /merchants/all. Mock getMerchantData() for name resolution. |
Tests target the public API of the helper module (5 functions), not the internal storage shape.
Manual verification:
- Admin first login (no localStorage) β
/merchants/all. Empty state if no merchants exist. - Admin returning login (
current = X, exists) β/merchants/X/overview. Switcher shows "Currently viewing X" + recent list. - Admin returning login, X was deleted β flash β
/merchants/all. localStorage no longer contains X. - Admin pastes typo URL β silent redirect to
/merchants/all. - Admin opens two tabs, switches in tab 2 β tab 1's switcher continues to show tab 1's URL merchant as "Currently viewing." localStorage in both tabs reflects tab 2 on next read.
- Admin clicks switcher β recent merchant Y β navigates to
/merchants/Y/overview. Y becomescurrent. Y moves to top ofrecent(deduped). - Admin clicks "View all merchants" in switcher β
/merchants/all. Switcher continues to render in cross-merchant chrome. - Admin signs out β
lantern.admin.currentMerchantkey is wiped (verify in devtools). - Merchant-role login β
/merchants/:theirId/overview. No breadcrumb. No switcher. No CTA buttons. Direct nav to/merchants/allor another merchant ID is blocked by existingmerchantOnlyredirect. - Merchant-role login, merchantId points to deleted merchant β
MerchantAccountMisconfiguredpage with sign-out. (Reproduce by manually nuking the merchant doc in dev Firestore.) npm run validate -- --workspace apps/adminpasses.
Files touched β
New β
| Path | Responsibility |
|---|---|
apps/admin/src/lib/currentMerchant.js | localStorage helper module |
apps/admin/src/lib/__tests__/currentMerchant.test.js | Unit tests for the helper |
apps/admin/src/components/merchants/MerchantsIndexRedirect.jsx | Smart redirect at /merchants index |
apps/admin/src/components/merchants/__tests__/MerchantsIndexRedirect.test.jsx | Redirect case coverage |
apps/admin/src/components/merchants/MerchantSwitcher.jsx | Admin switcher dropdown |
apps/admin/src/components/merchants/__tests__/MerchantSwitcher.test.jsx | Switcher rendering states |
Modified β
| Path | Change |
|---|---|
apps/admin/src/components/AdminDashboard.jsx | Replace <Route index> with <MerchantsIndexRedirect />. Remove /merchants/overview and /merchants/offers routes. Pass user (with merchantId) and isAdmin props. Update the existing merchantOnly redirect effect (lines ~163β169) to point at /merchants instead of the now-deleted /merchants/overview. |
apps/admin/src/components/merchants/MerchantDetail.jsx | Add useEffect calling rememberMerchant(merchantId) (admin-only). Augment 404 branch to forgetMerchant + redirect to /merchants/all. Add admin-only breadcrumb above title. Render <MerchantSwitcher /> in PageHeader actions slot. |
apps/admin/src/components/merchants/MerchantsAll.jsx | Wear own PageHeader: title "Merchants", subtitle "Select a merchant to view details, or create a new one.", actions [+ Create Merchant] (admin) + <MerchantSwitcher /> (admin, when current set). Add empty-state card for zero-merchants. |
apps/admin/src/components/merchants/MerchantsCreate.jsx | Wear own PageHeader: title "Create Merchant", actions [Cancel] (back to /merchants/all). No switcher. |
apps/admin/src/components/PageHeader.jsx | Add optional breadcrumb prop, rendered above title. Existing usages unaffected. |
apps/admin/src/firebase.js | Extend admin auth-state hydration to load users/{uid}.merchantId into the user object. Verify whether this is already happening before adding. Also add clearCurrentMerchant() to the sign-out flow. |
Deleted β
| Path | Reason |
|---|---|
apps/admin/src/components/merchants/MerchantsListLayout.jsx | Replaced by per-route headers; was a passthrough without the tab strip. |
apps/admin/src/components/merchants/__tests__/MerchantsListLayout.test.jsx | Tests the deleted component. |
apps/admin/src/components/merchants/MerchantsOverview.jsx | Top-level overview placeholder. Per-merchant overview already lives in MerchantDetail. |
apps/admin/src/components/merchants/MerchantsOffers.jsx | Top-level offers placeholder. Per-merchant offers is project C, not this PR. |
Out of scope β
- Per-merchant
OffersandAnalyticstabs inMerchantDetail. Project C β sequenced after this lands. - Port of
OfferFormandMerchantDashboardfromapps/web/. Project C. - Shared extraction of
AdSlot/OfferCards/offerNormalizerto@lantern/shared. Project A β deferred until project C needs it. - Consolidation of
Notes/Photos/Addresstabs into a unifiedProfiletab. Project D β separate cleanup. - Auth API merchant-deletion cleanup of referencing user records (so
users/{uid}.merchantIddoesn't outlive the merchant). Separate ticket; flagged as upstream work that would eliminate the merchant-role 404 edge case. - Cross-tab localStorage synchronization. Intentionally not implemented. Matches Stripe / Linear; can be added later if a real workflow requires it.
- Pin merchant in switcher β pure LRU is good enough until admins manage 30+ merchants and complain.
Risks β
- Auth hydration may not currently load
merchantId. Ifapps/admin/src/firebase.js's onAuthChange handler doesn't readusers/{uid}.merchantId, the merchant-role redirect will route to<MerchantAccountMisconfigured />for everybody. Verify in implementation; the fix is small (~3 lines) but skipping it breaks the entire merchant-role flow. PageHeaderprop addition. Addingbreadcrumbis backward-compatible, but ifPageHeaderis consumed by docs/storybook stories, those may need a story update. Check during implementation.- The third life-stage of
MerchantsListLayout. This component was added 2026-04-22, narrowed and role-filtered in the page-patterns work (2026-04-24), and is now being deleted (2026-04-25). That's three changes in four days. The deletion is correct, but the changelog tells a slightly embarrassing story β worth leading the PR description with the architectural rationale rather than the diff stats. - Merchant-role users' direct URL access. If a merchant-role user pastes
/merchants/some-other-merchant-id/overview, the existingmerchantOnlyeffect inAdminDashboard.jsx:163-169redirects them to/merchants/overviewβ a route that no longer exists after this refactor. Update that effect to redirect to/merchants(which then re-redirects via the smart index to their own merchant). One-line fix; flagged as a coordination point.