Skip to content

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 existing MerchantDetail PageHeader.
  • Admin's "currently working on" merchant persists in localStorage; sidebar clicks smart-redirect there.
  • Merchant-role users redirect from /merchants to /merchants/:theirMerchantId/overview using users/{uid}.merchantId.
  • The two placeholder pages β€” MerchantsOverview.jsx and MerchantsOffers.jsx β€” and the now-passthrough MerchantsListLayout.jsx are 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 form

Per-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 ​

jsx
<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:

js
{
  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:

FunctionPurposeCalled from
getCurrentMerchantId()Returns string or null. Defensive against parse errors.MerchantsIndexRedirect
getRecentMerchantIds()Returns array, most recent firstMerchantSwitcher
rememberMerchant(id)Sets current = id, unshifts onto recent, dedupes, caps at 8MerchantDetail mount/param-change
forgetMerchant(id)Removes id from current (if matches) and recentMerchantDetail 404 branch
clearCurrentMerchant()Wipes the whole objectAdmin 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:

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().merchantId drives MerchantDetail, 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 rememberMerchant inside MerchantDetail's effect; read by MerchantsIndexRedirect and MerchantSwitcher. 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}.merchantId is the canonical link for merchant-role users. Loaded into the user prop during admin auth hydration; consumed only by MerchantsIndexRedirect.
  • 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 ​

CaseTriggerBehaviorVisible copy
Zero merchants existAdmin lands on /merchants/all, query returns 0Hide 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 β†’ 404forgetMerchant(X) + <Navigate to="/merchants/all" replace />(silent)
Direct URL with bad IDAdmin pastes /merchants/typo123/overviewSame self-heal(silent)
Network error fetching merchantFetch 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 merchantIdMerchantsIndexRedirect sees !user?.merchantId<MerchantAccountMisconfigured />"Your merchant account is misconfigured." / "Please contact support to resolve this." / [Sign out]
Merchant-role, merchantId points to deleted merchantRedirect 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 recentgetRecentMerchantIds() returns []Empty-state dropdown"No recent merchants" / [View all merchants β†’] / [+ Create merchant]
localStorage corruptedJSON.parse throws inside helper modulegetCurrentMerchantId() returns null; treat as fresh state(silent)
Loading state on MerchantDetailFetch in progressExisting 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):

SurfaceTest fileCoverage
currentMerchant.jsapps/admin/src/lib/__tests__/currentMerchant.test.jsLRU push order, dedupe, cap at 8, forgetMerchant removes from both fields, clearCurrentMerchant wipes everything, JSON.parse errors return null
MerchantsIndexRedirect.jsxapps/admin/src/components/merchants/__tests__/MerchantsIndexRedirect.test.jsxThe 4 redirect cases (merchant→ownId, merchant→misconfigured, admin→stored, admin→/all). Same MemoryRouter pattern as the existing MerchantsListLayout.test.jsx.
MerchantSwitcher.jsxapps/admin/src/components/merchants/__tests__/MerchantSwitcher.test.jsxEmpty 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:

  1. Admin first login (no localStorage) β†’ /merchants/all. Empty state if no merchants exist.
  2. Admin returning login (current = X, exists) β†’ /merchants/X/overview. Switcher shows "Currently viewing X" + recent list.
  3. Admin returning login, X was deleted β†’ flash β†’ /merchants/all. localStorage no longer contains X.
  4. Admin pastes typo URL β†’ silent redirect to /merchants/all.
  5. 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.
  6. Admin clicks switcher β†’ recent merchant Y β†’ navigates to /merchants/Y/overview. Y becomes current. Y moves to top of recent (deduped).
  7. Admin clicks "View all merchants" in switcher β†’ /merchants/all. Switcher continues to render in cross-merchant chrome.
  8. Admin signs out β†’ lantern.admin.currentMerchant key is wiped (verify in devtools).
  9. Merchant-role login β†’ /merchants/:theirId/overview. No breadcrumb. No switcher. No CTA buttons. Direct nav to /merchants/all or another merchant ID is blocked by existing merchantOnly redirect.
  10. Merchant-role login, merchantId points to deleted merchant β†’ MerchantAccountMisconfigured page with sign-out. (Reproduce by manually nuking the merchant doc in dev Firestore.)
  11. npm run validate -- --workspace apps/admin passes.

Files touched ​

New ​

PathResponsibility
apps/admin/src/lib/currentMerchant.jslocalStorage helper module
apps/admin/src/lib/__tests__/currentMerchant.test.jsUnit tests for the helper
apps/admin/src/components/merchants/MerchantsIndexRedirect.jsxSmart redirect at /merchants index
apps/admin/src/components/merchants/__tests__/MerchantsIndexRedirect.test.jsxRedirect case coverage
apps/admin/src/components/merchants/MerchantSwitcher.jsxAdmin switcher dropdown
apps/admin/src/components/merchants/__tests__/MerchantSwitcher.test.jsxSwitcher rendering states

Modified ​

PathChange
apps/admin/src/components/AdminDashboard.jsxReplace <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.jsxAdd 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.jsxWear 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.jsxWear own PageHeader: title "Create Merchant", actions [Cancel] (back to /merchants/all). No switcher.
apps/admin/src/components/PageHeader.jsxAdd optional breadcrumb prop, rendered above title. Existing usages unaffected.
apps/admin/src/firebase.jsExtend 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 ​

PathReason
apps/admin/src/components/merchants/MerchantsListLayout.jsxReplaced by per-route headers; was a passthrough without the tab strip.
apps/admin/src/components/merchants/__tests__/MerchantsListLayout.test.jsxTests the deleted component.
apps/admin/src/components/merchants/MerchantsOverview.jsxTop-level overview placeholder. Per-merchant overview already lives in MerchantDetail.
apps/admin/src/components/merchants/MerchantsOffers.jsxTop-level offers placeholder. Per-merchant offers is project C, not this PR.

Out of scope ​

  • Per-merchant Offers and Analytics tabs in MerchantDetail. Project C β€” sequenced after this lands.
  • Port of OfferForm and MerchantDashboard from apps/web/. Project C.
  • Shared extraction of AdSlot / OfferCards / offerNormalizer to @lantern/shared. Project A β€” deferred until project C needs it.
  • Consolidation of Notes / Photos / Address tabs into a unified Profile tab. Project D β€” separate cleanup.
  • Auth API merchant-deletion cleanup of referencing user records (so users/{uid}.merchantId doesn'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. If apps/admin/src/firebase.js's onAuthChange handler doesn't read users/{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.
  • PageHeader prop addition. Adding breadcrumb is backward-compatible, but if PageHeader is 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 existing merchantOnly effect in AdminDashboard.jsx:163-169 redirects 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.

Built with VitePress