Skip to content

Merchants Navigation Refactor Implementation Plan โ€‹

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the cross-merchant tab strip with per-route headers, a merchant switcher dropdown, localStorage-backed "current merchant" persistence, and smart redirects โ€” so admins think in terms of a single working context, not drill-in/drill-out from a list.

Architecture: Two distinct chromes: (1) cross-merchant chrome for /merchants/all and /merchants/create with their own PageHeader; (2) per-merchant chrome for /merchants/:merchantId/* with breadcrumb, switcher, and the existing PageTabs. MerchantsListLayout (the shared tab strip shell) is deleted. A pure-JS helper module (currentMerchant.js) manages localStorage persistence. A MerchantsIndexRedirect component at the /merchants index handles role-aware + state-aware routing. PageHeader gains an optional breadcrumb prop.

Tech Stack: React 19, React Router v6 (layout routes, useParams, useNavigate, useLocation), lucide-react, Vitest + Testing Library React, localStorage.

Spec: docs/superpowers/specs/2026-04-25-merchants-nav-refactor-design.md


Before you start โ€‹

  • Verify auth hydration. The user prop passed to AdminDashboard is a raw Firebase Auth user object (apps/admin/src/App.jsx:49โ€“88). It does not carry merchantId. Task 5 adds this field. Until Task 5 is wired, MerchantsIndexRedirect will misroute merchant-role users. Tasks 1โ€“4 can be built and tested in isolation; Tasks 5โ€“8 must be wired together.
  • Commit policy. Confirm commit permission at the first commit step; if denied, pause after each task and let the user commit manually.

File structure โ€‹

New files โ€‹

PathResponsibility
apps/admin/src/lib/currentMerchant.jslocalStorage helper: getCurrentMerchantId, getRecentMerchantIds, rememberMerchant, forgetMerchant, clearCurrentMerchant. Pure JS, no React, no Firebase.
apps/admin/src/lib/__tests__/currentMerchant.test.jsUnit tests for the 5 exported functions.
apps/admin/src/components/merchants/MerchantsIndexRedirect.jsxSmart redirect at /merchants index: merchantโ†’own detail, adminโ†’stored or /all.
apps/admin/src/components/merchants/__tests__/MerchantsIndexRedirect.test.jsx4 redirect cases + misconfigured state.
apps/admin/src/components/merchants/MerchantSwitcher.jsxAdmin-only dropdown: "Currently viewing" + recent list + "View all" link.
apps/admin/src/components/merchants/__tests__/MerchantSwitcher.test.jsxRendering states, navigation, empty state.

Modified files โ€‹

PathChange
apps/admin/src/components/PageHeader.jsxAdd optional breadcrumb prop, rendered above the title.
apps/admin/src/components/merchants/MerchantDetail.jsxAdd rememberMerchant effect. Augment 404 branch with forgetMerchant + redirect. Add breadcrumb (admin-only). Add <MerchantSwitcher /> in header actions. Accept isAdmin prop.
apps/admin/src/components/merchants/MerchantsAll.jsxRender own <PageHeader /> (title "Merchants", subtitle, [+ Create Merchant] button + <MerchantSwitcher />). Replace emoji in empty state.
apps/admin/src/components/merchants/MerchantsCreate.jsxRender own <PageHeader /> (title "Create Merchant", [Cancel] action).
apps/admin/src/components/AdminDashboard.jsxReplace <Route index> with <MerchantsIndexRedirect />. Remove /merchants/overview and /merchants/offers routes. Remove MerchantsListLayout wrapper. Pass isAdmin to <MerchantDetail />. Update merchantOnly redirect to point to /merchants instead of /merchants/overview. Remove MerchantsListLayout, MerchantsOverview, MerchantsOffers imports.
apps/admin/src/App.jsxExtend auth hydration to load users/{uid}.merchantId into user object for merchant-role users. Add clearCurrentMerchant() to sign-out flow.

Deleted files โ€‹

PathReason
apps/admin/src/components/merchants/MerchantsListLayout.jsxReplaced by per-route headers.
apps/admin/src/components/merchants/__tests__/MerchantsListLayout.test.jsxTests the deleted component.
apps/admin/src/components/merchants/MerchantsOverview.jsxPlaceholder โ€” per-merchant overview is in MerchantDetail.
apps/admin/src/components/merchants/MerchantsOffers.jsxPlaceholder โ€” per-merchant offers is Project C.

Task 1: currentMerchant.js helper module (TDD) โ€‹

Files:

  • Create: apps/admin/src/lib/__tests__/currentMerchant.test.js

  • Create: apps/admin/src/lib/currentMerchant.js

  • [ ] Step 1: Write the failing tests

Create apps/admin/src/lib/__tests__/currentMerchant.test.js:

js
import { describe, it, expect, beforeEach } from 'vitest'
import {
  getCurrentMerchantId,
  getRecentMerchantIds,
  rememberMerchant,
  forgetMerchant,
  clearCurrentMerchant,
} from '../currentMerchant'

const KEY = 'lantern.admin.currentMerchant'

beforeEach(() => {
  localStorage.clear()
})

describe('getCurrentMerchantId', () => {
  it('returns null when nothing stored', () => {
    expect(getCurrentMerchantId()).toBeNull()
  })

  it('returns the stored current merchant ID', () => {
    localStorage.setItem(KEY, JSON.stringify({ v: 1, current: 'merch_1', recent: ['merch_1'] }))
    expect(getCurrentMerchantId()).toBe('merch_1')
  })

  it('returns null on corrupted JSON', () => {
    localStorage.setItem(KEY, '{not valid json')
    expect(getCurrentMerchantId()).toBeNull()
  })

  it('returns null when current is missing from the object', () => {
    localStorage.setItem(KEY, JSON.stringify({ v: 1, recent: [] }))
    expect(getCurrentMerchantId()).toBeNull()
  })
})

describe('getRecentMerchantIds', () => {
  it('returns empty array when nothing stored', () => {
    expect(getRecentMerchantIds()).toEqual([])
  })

  it('returns the stored recent array', () => {
    localStorage.setItem(KEY, JSON.stringify({ v: 1, current: 'merch_1', recent: ['merch_1', 'merch_2'] }))
    expect(getRecentMerchantIds()).toEqual(['merch_1', 'merch_2'])
  })

  it('returns empty array on corrupted JSON', () => {
    localStorage.setItem(KEY, '{broken')
    expect(getRecentMerchantIds()).toEqual([])
  })
})

describe('rememberMerchant', () => {
  it('sets current and adds to recent', () => {
    rememberMerchant('merch_1')
    expect(getCurrentMerchantId()).toBe('merch_1')
    expect(getRecentMerchantIds()).toEqual(['merch_1'])
  })

  it('moves a repeated ID to the front of recent (dedupes)', () => {
    rememberMerchant('merch_1')
    rememberMerchant('merch_2')
    rememberMerchant('merch_1')
    expect(getRecentMerchantIds()).toEqual(['merch_1', 'merch_2'])
    expect(getCurrentMerchantId()).toBe('merch_1')
  })

  it('caps recent at 8 entries', () => {
    for (let i = 1; i <= 10; i++) {
      rememberMerchant(`merch_${i}`)
    }
    const recent = getRecentMerchantIds()
    expect(recent).toHaveLength(8)
    expect(recent[0]).toBe('merch_10')
    expect(recent[7]).toBe('merch_3')
  })

  it('does not crash on corrupted existing state', () => {
    localStorage.setItem(KEY, '{broken')
    rememberMerchant('merch_1')
    expect(getCurrentMerchantId()).toBe('merch_1')
    expect(getRecentMerchantIds()).toEqual(['merch_1'])
  })
})

describe('forgetMerchant', () => {
  it('removes from current and recent', () => {
    rememberMerchant('merch_1')
    rememberMerchant('merch_2')
    forgetMerchant('merch_2')
    expect(getCurrentMerchantId()).toBeNull()
    expect(getRecentMerchantIds()).toEqual(['merch_1'])
  })

  it('clears current only if it matches', () => {
    rememberMerchant('merch_1')
    rememberMerchant('merch_2')
    forgetMerchant('merch_1')
    expect(getCurrentMerchantId()).toBe('merch_2')
    expect(getRecentMerchantIds()).toEqual(['merch_2'])
  })

  it('is a no-op if ID is not present', () => {
    rememberMerchant('merch_1')
    forgetMerchant('merch_999')
    expect(getCurrentMerchantId()).toBe('merch_1')
    expect(getRecentMerchantIds()).toEqual(['merch_1'])
  })
})

describe('clearCurrentMerchant', () => {
  it('wipes the entire localStorage entry', () => {
    rememberMerchant('merch_1')
    rememberMerchant('merch_2')
    clearCurrentMerchant()
    expect(getCurrentMerchantId()).toBeNull()
    expect(getRecentMerchantIds()).toEqual([])
    expect(localStorage.getItem(KEY)).toBeNull()
  })
})
  • [ ] Step 2: Run tests to verify they fail

Run: npx vitest run apps/admin/src/lib/__tests__/currentMerchant.test.js

Expected: FAIL โ€” module not found.

  • [ ] Step 3: Implement currentMerchant.js

Create apps/admin/src/lib/currentMerchant.js:

js
const KEY = 'lantern.admin.currentMerchant'
const MAX_RECENT = 8

function read() {
  try {
    const raw = localStorage.getItem(KEY)
    if (!raw) return null
    return JSON.parse(raw)
  } catch {
    return null
  }
}

function write(data) {
  localStorage.setItem(KEY, JSON.stringify(data))
}

export function getCurrentMerchantId() {
  const data = read()
  return data?.current ?? null
}

export function getRecentMerchantIds() {
  const data = read()
  return Array.isArray(data?.recent) ? data.recent : []
}

export function rememberMerchant(id) {
  const data = read() || { v: 1, current: null, recent: [] }
  data.current = id
  data.recent = [id, ...data.recent.filter((r) => r !== id)].slice(0, MAX_RECENT)
  write(data)
}

export function forgetMerchant(id) {
  const data = read()
  if (!data) return
  if (data.current === id) data.current = null
  data.recent = data.recent.filter((r) => r !== id)
  write(data)
}

export function clearCurrentMerchant() {
  localStorage.removeItem(KEY)
}
  • [ ] Step 4: Run tests to verify they pass

Run: npx vitest run apps/admin/src/lib/__tests__/currentMerchant.test.js

Expected: all 13 tests pass.

  • [ ] Step 5: Commit
bash
git add apps/admin/src/lib/currentMerchant.js apps/admin/src/lib/__tests__/currentMerchant.test.js
git commit -m "feat(admin): add currentMerchant localStorage helper

Pure-JS module for persisting admin's 'currently working on' merchant.
LRU recent list capped at 8, defensive against corrupted JSON, no React
or Firebase dependencies."

Task 2: Add breadcrumb prop to PageHeader โ€‹

Files:

  • Modify: apps/admin/src/components/PageHeader.jsx

  • [ ] Step 1: Add the breadcrumb prop

Open apps/admin/src/components/PageHeader.jsx. The current file (14 lines):

jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'

export default function PageHeader({ title, subtitle, actions }) {
  return (
    <div className="page-header-bar">
      <div className="page-header-bar__titleblock">
        <h1 className="page-header-bar__title">{title}</h1>
        {subtitle && <p className="page-header-bar__subtitle">{subtitle}</p>}
      </div>
      {actions && <div className="page-header-bar__actions">{actions}</div>}
    </div>
  )
}

Change to:

jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'

export default function PageHeader({ breadcrumb, title, subtitle, actions }) {
  return (
    <div className="page-header-bar">
      <div className="page-header-bar__titleblock">
        {breadcrumb && <div className="page-header-bar__breadcrumb">{breadcrumb}</div>}
        <h1 className="page-header-bar__title">{title}</h1>
        {subtitle && <p className="page-header-bar__subtitle">{subtitle}</p>}
      </div>
      {actions && <div className="page-header-bar__actions">{actions}</div>}
    </div>
  )
}
  • [ ] Step 2: Add the breadcrumb CSS

Open apps/admin/src/styles.css. Find the .page-header-bar__subtitle rule. Add the breadcrumb rule immediately before it:

css
.page-header-bar__breadcrumb {
  font-size: 0.75rem;
  font-weight: 500;
  margin-bottom: 2px;
}

.page-header-bar__breadcrumb a {
  color: var(--muted);
  text-decoration: none;
}

.page-header-bar__breadcrumb a:hover {
  color: var(--text);
}
  • [ ] Step 3: Commit
bash
git add apps/admin/src/components/PageHeader.jsx apps/admin/src/styles.css
git commit -m "feat(admin): add optional breadcrumb prop to PageHeader

Renders a small breadcrumb line above the title when provided. Backward-
compatible โ€” existing PageHeader consumers are unaffected."

Task 3: MerchantsIndexRedirect (TDD) โ€‹

Files:

  • Create: apps/admin/src/components/merchants/__tests__/MerchantsIndexRedirect.test.jsx

  • Create: apps/admin/src/components/merchants/MerchantsIndexRedirect.jsx

  • [ ] Step 1: Write the failing tests

Create apps/admin/src/components/merchants/__tests__/MerchantsIndexRedirect.test.jsx:

jsx
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import MerchantsIndexRedirect from '../MerchantsIndexRedirect'

// Mock the localStorage helper
vi.mock('../../../lib/currentMerchant', () => ({
  getCurrentMerchantId: vi.fn(() => null),
}))

import { getCurrentMerchantId } from '../../../lib/currentMerchant'

function renderAt(path, { user = {}, isAdmin = true } = {}) {
  return render(
    <MemoryRouter initialEntries={[path]}>
      <Routes>
        <Route path="/merchants" element={<MerchantsIndexRedirect user={user} isAdmin={isAdmin} />} />
        <Route path="/merchants/all" element={<div data-testid="landed">all</div>} />
        <Route path="/merchants/:id/overview" element={<div data-testid="landed">detail</div>} />
      </Routes>
    </MemoryRouter>
  )
}

beforeEach(() => {
  vi.clearAllMocks()
  localStorage.clear()
})

describe('MerchantsIndexRedirect', () => {
  it('admin with no stored merchant โ†’ /merchants/all', () => {
    getCurrentMerchantId.mockReturnValue(null)
    renderAt('/merchants', { isAdmin: true })
    expect(screen.getByTestId('landed')).toHaveTextContent('all')
  })

  it('admin with stored merchant โ†’ /merchants/:id/overview', () => {
    getCurrentMerchantId.mockReturnValue('merch_abc')
    renderAt('/merchants', { isAdmin: true })
    expect(screen.getByTestId('landed')).toHaveTextContent('detail')
  })

  it('merchant-role with merchantId โ†’ /merchants/:id/overview', () => {
    renderAt('/merchants', { user: { merchantId: 'merch_xyz' }, isAdmin: false })
    expect(screen.getByTestId('landed')).toHaveTextContent('detail')
  })

  it('merchant-role without merchantId โ†’ shows misconfigured message', () => {
    renderAt('/merchants', { user: {}, isAdmin: false })
    expect(screen.getByText(/merchant account is misconfigured/i)).toBeInTheDocument()
  })
})
  • [ ] Step 2: Run tests to verify they fail

Run: npx vitest run apps/admin/src/components/merchants/__tests__/MerchantsIndexRedirect.test.jsx

Expected: FAIL โ€” module not found.

  • [ ] Step 3: Implement MerchantsIndexRedirect

Create apps/admin/src/components/merchants/MerchantsIndexRedirect.jsx:

jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { Navigate } from 'react-router-dom'
import { LogOut } from 'lucide-react'
import { getCurrentMerchantId } from '../../lib/currentMerchant'
import { signOut } from '../../firebase'

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 />
}

function MerchantAccountMisconfigured() {
  return (
    <div className="user-management-container">
      <div className="user-management-main" style={{ paddingTop: 'var(--space-8)' }}>
        <div className="feature-card" style={{ maxWidth: 480 }}>
          <h3>Merchant account is misconfigured</h3>
          <p className="text-muted" style={{ marginTop: 'var(--space-2)' }}>
            Your account doesn&apos;t have a linked merchant profile. Please contact support to
            resolve this.
          </p>
          <button
            className="btn btn-secondary btn-sm"
            style={{ marginTop: 'var(--space-4)' }}
            onClick={() => signOut()}
          >
            <LogOut size={14} />
            Sign out
          </button>
        </div>
      </div>
    </div>
  )
}
  • [ ] Step 4: Run tests to verify they pass

Run: npx vitest run apps/admin/src/components/merchants/__tests__/MerchantsIndexRedirect.test.jsx

Expected: all 4 tests pass.

  • [ ] Step 5: Commit
bash
git add apps/admin/src/components/merchants/MerchantsIndexRedirect.jsx apps/admin/src/components/merchants/__tests__/MerchantsIndexRedirect.test.jsx
git commit -m "feat(admin): add MerchantsIndexRedirect for smart merchant routing

Role-aware redirect at /merchants index: merchant-role users go to their
own detail page, admins go to their last-viewed merchant or /merchants/all.
Includes MerchantAccountMisconfigured fallback for merchant-role users
without a linked merchantId."

Task 4: MerchantSwitcher dropdown (TDD) โ€‹

Files:

  • Create: apps/admin/src/components/merchants/__tests__/MerchantSwitcher.test.jsx

  • Create: apps/admin/src/components/merchants/MerchantSwitcher.jsx

  • [ ] Step 1: Write the failing tests

Create apps/admin/src/components/merchants/__tests__/MerchantSwitcher.test.jsx:

jsx
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import MerchantSwitcher from '../MerchantSwitcher'

// Mock the localStorage helper
vi.mock('../../../lib/currentMerchant', () => ({
  getRecentMerchantIds: vi.fn(() => []),
}))

// Mock firebase
vi.mock('../../../firebase', () => ({
  getMerchantData: vi.fn(),
}))

import { getRecentMerchantIds } from '../../../lib/currentMerchant'
import { getMerchantData } from '../../../firebase'

function renderSwitcher(path = '/merchants/all') {
  return render(
    <MemoryRouter initialEntries={[path]}>
      <Routes>
        <Route path="/merchants/all" element={<MerchantSwitcher />} />
        <Route path="/merchants/create" element={<div data-testid="landed">create</div>} />
        <Route path="/merchants/:id/overview" element={<MerchantSwitcher />} />
      </Routes>
    </MemoryRouter>
  )
}

beforeEach(() => {
  vi.clearAllMocks()
  getRecentMerchantIds.mockReturnValue([])
})

describe('MerchantSwitcher', () => {
  it('renders a trigger button', () => {
    renderSwitcher()
    expect(screen.getByRole('button', { name: /switch merchant/i })).toBeInTheDocument()
  })

  it('shows empty state when no recents and dropdown is open', async () => {
    const user = userEvent.setup()
    renderSwitcher()
    await user.click(screen.getByRole('button', { name: /switch merchant/i }))
    expect(screen.getByText(/no recent merchants/i)).toBeInTheDocument()
    expect(screen.getByText(/view all merchants/i)).toBeInTheDocument()
  })

  it('shows recent merchants when available', async () => {
    const user = userEvent.setup()
    getRecentMerchantIds.mockReturnValue(['merch_1', 'merch_2'])
    getMerchantData.mockImplementation((id) =>
      Promise.resolve({ merchant: { businessName: `Business ${id}` } })
    )

    renderSwitcher('/merchants/merch_1/overview')
    await user.click(screen.getByRole('button', { name: /switch merchant/i }))

    await waitFor(() => {
      expect(screen.getByText('Business merch_2')).toBeInTheDocument()
    })
  })

  it('closes dropdown when clicking outside', async () => {
    const user = userEvent.setup()
    renderSwitcher()
    await user.click(screen.getByRole('button', { name: /switch merchant/i }))
    expect(screen.getByText(/no recent merchants/i)).toBeInTheDocument()

    // Click outside
    await user.click(document.body)
    expect(screen.queryByText(/no recent merchants/i)).not.toBeInTheDocument()
  })
})
  • [ ] Step 2: Run tests to verify they fail

Run: npx vitest run apps/admin/src/components/merchants/__tests__/MerchantSwitcher.test.jsx

Expected: FAIL โ€” module not found.

  • [ ] Step 3: Implement MerchantSwitcher

Create apps/admin/src/components/merchants/MerchantSwitcher.jsx:

jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { ChevronDown, Check, Plus } from 'lucide-react'
import { getRecentMerchantIds } from '../../lib/currentMerchant'
import { getMerchantData } from '../../firebase'

const MAX_DISPLAY = 5

export default function MerchantSwitcher() {
  const navigate = useNavigate()
  const { merchantId: currentId } = useParams()
  const [open, setOpen] = useState(false)
  const [names, setNames] = useState({})
  const dropdownRef = useRef(null)

  // Close on outside click
  useEffect(() => {
    if (!open) return
    function handleClick(e) {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
        setOpen(false)
      }
    }
    document.addEventListener('mousedown', handleClick)
    return () => document.removeEventListener('mousedown', handleClick)
  }, [open])

  // Fetch names on open
  const handleOpen = useCallback(() => {
    setOpen((prev) => {
      const willOpen = !prev
      if (willOpen) {
        const ids = getRecentMerchantIds()
        const unknown = ids.filter((id) => !names[id])
        if (unknown.length > 0) {
          Promise.all(
            unknown.map((id) =>
              getMerchantData(id)
                .then((d) => [id, d.merchant?.businessName || id])
                .catch(() => [id, id])
            )
          ).then((entries) => {
            setNames((prev) => {
              const next = { ...prev }
              entries.forEach(([id, name]) => { next[id] = name })
              return next
            })
          })
        }
      }
      return willOpen
    })
  }, [names])

  const recentIds = getRecentMerchantIds()
  // Exclude the currently viewed merchant from the "recent" list
  const otherRecent = recentIds.filter((id) => id !== currentId).slice(0, MAX_DISPLAY)

  return (
    <div ref={dropdownRef} style={{ position: 'relative', display: 'inline-block' }}>
      <button
        type="button"
        className="btn btn-secondary btn-sm"
        onClick={handleOpen}
        aria-label="Switch merchant"
        aria-expanded={open}
        aria-haspopup="true"
      >
        <ChevronDown size={14} />
      </button>

      {open && (
        <div className="merchant-switcher-dropdown">
          {currentId && names[currentId] && (
            <>
              <div className="merchant-switcher-section-label">Currently viewing</div>
              <div className="merchant-switcher-item merchant-switcher-item--current">
                <Check size={12} />
                <span>{names[currentId]}</span>
              </div>
            </>
          )}

          {otherRecent.length > 0 ? (
            <>
              <div className="merchant-switcher-section-label">Recent</div>
              {otherRecent.map((id) => (
                <button
                  key={id}
                  type="button"
                  className="merchant-switcher-item"
                  onClick={() => {
                    navigate(`/merchants/${id}/overview`)
                    setOpen(false)
                  }}
                >
                  <span>{names[id] || id}</span>
                </button>
              ))}
            </>
          ) : (
            <div className="merchant-switcher-empty">No recent merchants</div>
          )}

          <div className="merchant-switcher-divider" />

          <button
            type="button"
            className="merchant-switcher-item"
            onClick={() => {
              navigate('/merchants/all')
              setOpen(false)
            }}
          >
            View all merchants โ†’
          </button>

          {otherRecent.length === 0 && (
            <button
              type="button"
              className="merchant-switcher-item"
              onClick={() => {
                navigate('/merchants/create')
                setOpen(false)
              }}
            >
              <Plus size={12} />
              <span>Create merchant</span>
            </button>
          )}
        </div>
      )}
    </div>
  )
}
  • [ ] Step 4: Add dropdown CSS

Add to apps/admin/src/styles.css, near the end of the merchant-related styles (or after .page-header-bar styles):

css
/* โ”€โ”€ Merchant Switcher dropdown โ”€โ”€ */

.merchant-switcher-dropdown {
  position: absolute;
  right: 0;
  top: calc(100% + 4px);
  min-width: 240px;
  background: var(--surface);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 8px;
  padding: var(--space-2) 0;
  z-index: 50;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}

.merchant-switcher-section-label {
  font-size: 0.6875rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--muted);
  padding: var(--space-2) var(--space-3) var(--space-1);
}

.merchant-switcher-item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
  padding: var(--space-2) var(--space-3);
  background: none;
  border: none;
  color: var(--text);
  font-size: 0.8125rem;
  cursor: pointer;
  text-align: left;
}

.merchant-switcher-item:hover {
  background: rgba(255, 255, 255, 0.05);
}

.merchant-switcher-item--current {
  color: var(--accent-600);
  cursor: default;
}

.merchant-switcher-empty {
  padding: var(--space-3);
  font-size: 0.8125rem;
  color: var(--muted);
  text-align: center;
}

.merchant-switcher-divider {
  height: 1px;
  background: rgba(255, 255, 255, 0.05);
  margin: var(--space-1) 0;
}
  • [ ] Step 5: Run tests to verify they pass

Run: npx vitest run apps/admin/src/components/merchants/__tests__/MerchantSwitcher.test.jsx

Expected: all 4 tests pass.

  • [ ] Step 6: Commit
bash
git add apps/admin/src/components/merchants/MerchantSwitcher.jsx apps/admin/src/components/merchants/__tests__/MerchantSwitcher.test.jsx apps/admin/src/styles.css
git commit -m "feat(admin): add MerchantSwitcher dropdown for quick merchant switching

Admin-only dropdown showing currently viewed merchant, recent list (up
to 5), and 'View all merchants' link. Names resolved lazily on open.
Positioned in PageHeader actions slot."

Task 5: Auth hydration โ€” load merchantId + clear on sign-out โ€‹

Files:

  • Modify: apps/admin/src/App.jsx

This task adds merchantId to the user object for merchant-role users so MerchantsIndexRedirect can route them to their own merchant. Also wires clearCurrentMerchant() into sign-out.

  • [ ] Step 1: Add merchantId to the auth hydration

Open apps/admin/src/App.jsx. Find the auth effect (lines 48โ€“91). After the checkMerchantRole result is set (line 63), add Firestore lookup for merchant-role users.

Change lines 58โ€“63 from:

jsx
        const [adminStatus, merchantStatus] = await Promise.all([
          checkAdminRole(firebaseUser),
          checkMerchantRole(firebaseUser),
        ])
        setIsAdmin(adminStatus)
        setIsMerchant(merchantStatus)

to:

jsx
        const [adminStatus, merchantStatus] = await Promise.all([
          checkAdminRole(firebaseUser),
          checkMerchantRole(firebaseUser),
        ])
        setIsAdmin(adminStatus)
        setIsMerchant(merchantStatus)

        // For merchant-role (non-admin) users, load their linked merchantId
        // so MerchantsIndexRedirect can route to their detail page.
        if (merchantStatus && !adminStatus) {
          const { doc, getDoc } = await import('firebase/firestore')
          const { db } = await import('./firebase')
          const userDoc = await getDoc(doc(db, 'users', firebaseUser.uid))
          if (userDoc.exists() && userDoc.data().merchantId) {
            firebaseUser.merchantId = userDoc.data().merchantId
          }
        }

Note: firebaseUser is a mutable object (Firebase Auth User instance). Attaching merchantId directly is simpler than creating a wrapper object that would need to be threaded through every consumer. This property is only read by MerchantsIndexRedirect.

Important: The db import uses a dynamic import to avoid circular dependency issues if firebase.js is already in the module graph at this point. Check if db is already imported at the top of App.jsx. If db is already available, use the static import instead:

jsx
import { db } from './firebase'

and use it directly without dynamic import:

jsx
        if (merchantStatus && !adminStatus) {
          const { doc, getDoc } = await import('firebase/firestore')
          const userDoc = await getDoc(doc(db, 'users', firebaseUser.uid))
          if (userDoc.exists() && userDoc.data().merchantId) {
            firebaseUser.merchantId = userDoc.data().merchantId
          }
        }

Verify by checking the imports at the top of App.jsx โ€” the existing imports (line 6โ€“11) import from './firebase' but may not include db. If db is not imported, add it to the existing import line.

  • [ ] Step 2: Add clearCurrentMerchant to sign-out

Find the handleSignOut function (around line 138). Change from:

jsx
  const handleSignOut = async () => {
    try {
      await signOut()
    } catch (err) {
      setError(err.message)
    }
  }

to:

jsx
  const handleSignOut = async () => {
    try {
      clearCurrentMerchant()
      await signOut()
    } catch (err) {
      setError(err.message)
    }
  }

Add the import at the top of App.jsx:

jsx
import { clearCurrentMerchant } from './lib/currentMerchant'
  • [ ] Step 3: Run admin tests

Run: npx vitest run --config apps/admin/vitest.config.js

Expected: all tests pass. The auth hydration change is an async side effect that existing tests don't exercise (they mock Firebase auth).

  • [ ] Step 4: Commit
bash
git add apps/admin/src/App.jsx
git commit -m "feat(admin): hydrate merchantId for merchant-role users, clear on sign-out

Loads users/{uid}.merchantId into the Firebase Auth user object during
auth hydration so MerchantsIndexRedirect can route merchant-role users
to their own detail page. Clears the currentMerchant localStorage entry
on sign-out to prevent stale state across sessions."

Task 6: Update MerchantDetail โ€” breadcrumb, switcher, remember/forget โ€‹

Files:

  • Modify: apps/admin/src/components/merchants/MerchantDetail.jsx

  • [ ] Step 1: Add new imports

Open apps/admin/src/components/merchants/MerchantDetail.jsx. Add to the existing imports:

jsx
import { ArrowLeft } from 'lucide-react'
import { Link } from 'react-router-dom'
import { rememberMerchant, forgetMerchant } from '../../lib/currentMerchant'
import MerchantSwitcher from './MerchantSwitcher'

ArrowLeft is for the breadcrumb. Link is for the breadcrumb anchor (accessible, right-click โ†’ open-in-new-tab friendly). rememberMerchant/forgetMerchant are for the localStorage side effects.

Also update the function signature to accept isAdmin:

Change:

jsx
export default function MerchantDetail() {

to:

jsx
export default function MerchantDetail({ isAdmin }) {
  • [ ] Step 2: Add rememberMerchant effect

After the existing data-fetching useEffect (around line 67), add:

jsx
  // Persist this merchant as "current" for admin's smart redirect + switcher.
  useEffect(() => {
    if (isAdmin && merchantId) {
      rememberMerchant(merchantId)
    }
  }, [isAdmin, merchantId])
  • [ ] Step 3: Update the 404 branch with self-healing

Find the error/404 branch (around lines 142โ€“159). Change from:

jsx
  if (error || !data.merchant) {
    return (
      <div className="user-management-container">
        <div className="user-management-main">
          <div className="page-header">
            <div>
              <h1>Merchant not found</h1>
              <p className="text-muted">
                {error || `No merchant account exists with ID ${merchantId}.`}
              </p>
            </div>
          </div>
          <button className="btn btn-secondary" onClick={() => navigate('/merchants/all')}>
            Back to All Merchants
          </button>
        </div>
      </div>
    )
  }

to:

jsx
  if (error || !data.merchant) {
    // If this was a stale localStorage redirect, silently evict and redirect.
    if (!error && !data.merchant && isAdmin) {
      forgetMerchant(merchantId)
      return <Navigate to="/merchants/all" replace />
    }

    return (
      <div className="user-management-container">
        <div className="user-management-main">
          <PageHeader
            title="Merchant not found"
            subtitle={error || `No merchant account exists with ID ${merchantId}.`}
          />
          {isAdmin ? (
            <button className="btn btn-secondary" onClick={() => navigate('/merchants/all')}>
              Back to All Merchants
            </button>
          ) : (
            <div className="feature-card" style={{ maxWidth: 480 }}>
              <h3>Your merchant account references a business that no longer exists.</h3>
              <p className="text-muted" style={{ marginTop: 'var(--space-2)' }}>
                Please contact support to resolve this.
              </p>
            </div>
          )}
        </div>
      </div>
    )
  }

Also add the Navigate import โ€” it's already in the import line from react-router-dom. Check line 1: if Navigate is not already imported, add it:

jsx
import { useParams, useSearchParams, useLocation, useNavigate, Outlet, Navigate } from 'react-router-dom'
  • [ ] Step 4: Add breadcrumb and switcher to the main render

Find the main render block's <PageHeader> usage (around lines 221โ€“225). Change from:

jsx
        <PageHeader
          title={businessName}
          subtitle="Merchant account details"
          actions={headerActions}
        />

to:

jsx
        <PageHeader
          breadcrumb={
            isAdmin ? (
              <Link to="/merchants/all">
                <ArrowLeft size={12} style={{ verticalAlign: 'middle', marginRight: 4 }} />
                Merchants
              </Link>
            ) : null
          }
          title={businessName}
          subtitle="Merchant account details"
          actions={
            <>
              {headerActions}
              {isAdmin && <MerchantSwitcher />}
            </>
          }
        />
  • [ ] Step 5: Run admin tests

Run: npx vitest run --config apps/admin/vitest.config.js

Expected: all tests pass.

  • [ ] Step 6: Commit
bash
git add apps/admin/src/components/merchants/MerchantDetail.jsx
git commit -m "feat(admin): add breadcrumb, switcher, and remember/forget to MerchantDetail

Admin-only breadcrumb 'โ† Merchants' links to /merchants/all. MerchantSwitcher
renders in the header actions. rememberMerchant(id) is called on mount/
param change to persist the working context. 404 branch self-heals stale
localStorage by calling forgetMerchant and redirecting to /merchants/all."

Task 7: Give MerchantsAll and MerchantsCreate their own headers โ€‹

Files:

  • Modify: apps/admin/src/components/merchants/MerchantsAll.jsx
  • Modify: apps/admin/src/components/merchants/MerchantsCreate.jsx

After MerchantsListLayout is deleted (Task 8), these pages need their own <PageHeader /> since no parent layout provides one.

  • [ ] Step 1: Update MerchantsAll.jsx

Open apps/admin/src/components/merchants/MerchantsAll.jsx.

Add imports at the top (after existing imports):

jsx
import { Plus, Store } from 'lucide-react'
import PageHeader from '../PageHeader'
import MerchantSwitcher from './MerchantSwitcher'

Wrap the existing return JSX in the page shell and add PageHeader. Change the return block from:

jsx
  return (
    <>
      <div className="form-group" style={{ marginBottom: 'var(--space-4)' }}>
        <input ... />
      </div>
      {loading && ...}
      {error && ...}
      {!loading && !error && filtered.length === 0 && (
        <div className="feature-card" style={{ maxWidth: 560 }}>
          <div className="feature-card-icon">๐Ÿช</div>
          ...

to:

jsx
  return (
    <div className="user-management-container">
      <div className="user-management-main">
        <PageHeader
          title="Merchants"
          subtitle="Select a merchant to view details, or create a new one."
          actions={
            <>
              <button
                className="btn btn-primary btn-sm"
                onClick={() => navigate('/merchants/create')}
              >
                <Plus size={14} />
                Create Merchant
              </button>
              <MerchantSwitcher />
            </>
          }
        />
        <div className="user-management-body">
          <div className="form-group" style={{ marginBottom: 'var(--space-4)' }}>
            <input ... />
          </div>
          {loading && ...}
          {error && ...}
          {!loading && !error && filtered.length === 0 && (
            <div className="feature-card" style={{ maxWidth: 560 }}>
              <div className="feature-card-icon"><Store size={32} /></div>
              ...

Full replacement of the return block โ€” here is the complete new return:

jsx
  return (
    <div className="user-management-container">
      <div className="user-management-main">
        <PageHeader
          title="Merchants"
          subtitle="Select a merchant to view details, or create a new one."
          actions={
            <>
              <button
                className="btn btn-primary btn-sm"
                onClick={() => navigate('/merchants/create')}
              >
                <Plus size={14} />
                Create Merchant
              </button>
              <MerchantSwitcher />
            </>
          }
        />
        <div className="user-management-body">
          <div className="form-group" style={{ marginBottom: 'var(--space-4)' }}>
            <input
              type="search"
              className="form-input"
              placeholder="Search by business name, email, or contactโ€ฆ"
              value={search}
              onChange={(e) => setSearch(e.target.value)}
            />
          </div>

          {loading && <p className="text-muted">Loading merchantsโ€ฆ</p>}
          {error && <div className="form-error">{error}</div>}

          {!loading && !error && filtered.length === 0 && (
            <div className="feature-card" style={{ maxWidth: 560 }}>
              <div className="feature-card-icon"><Store size={32} /></div>
              <h3>{search ? 'No merchants match your search' : 'No merchants yet'}</h3>
              <p>
                {search
                  ? 'Try a different search term, or clear the filter to see all merchants.'
                  : 'Create your first merchant account to get started.'}
              </p>
            </div>
          )}

          {!loading && !error && filtered.length > 0 && (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
              {filtered.map((m) => (
                <MerchantRow
                  key={m.merchantId}
                  merchant={m}
                  onClick={() => navigate(`/merchants/${m.merchantId}`)}
                />
              ))}
            </div>
          )}

          {hasMore && !search && (
            <div style={{ textAlign: 'center', marginTop: 'var(--space-4)' }}>
              <button
                className="btn btn-secondary"
                onClick={() => load({ append: true })}
                disabled={loadingMore}
              >
                {loadingMore ? 'Loadingโ€ฆ' : 'Load more'}
              </button>
            </div>
          )}
        </div>
      </div>
    </div>
  )
  • [ ] Step 2: Update MerchantsCreate.jsx

Open apps/admin/src/components/merchants/MerchantsCreate.jsx. Change from:

jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import CreateMerchantForm from '../CreateMerchantForm'

/**
 * Merchants > Create Merchant (admin-only).
 *
 * Renders the CreateMerchantForm inside the MerchantsListLayout shell. The layout
 * swaps its title to "Create Merchant" and hides the tab strip for this route.
 *
 * Route-level role gating happens upstream โ€” the merchant redirect guard blocks
 * direct URL access for non-admins.
 */
export default function MerchantsCreate() {
  return <CreateMerchantForm />
}

to:

jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { useNavigate } from 'react-router-dom'
import PageHeader from '../PageHeader'
import CreateMerchantForm from '../CreateMerchantForm'

/**
 * Merchants > Create Merchant (admin-only).
 *
 * Renders the CreateMerchantForm with its own PageHeader. Route-level role
 * gating happens upstream โ€” the merchant redirect guard blocks direct URL
 * access for non-admins.
 */
export default function MerchantsCreate() {
  const navigate = useNavigate()

  return (
    <div className="user-management-container">
      <div className="user-management-main">
        <PageHeader
          title="Create Merchant"
          actions={
            <button
              className="btn btn-secondary btn-sm"
              onClick={() => navigate('/merchants/all')}
            >
              Cancel
            </button>
          }
        />
        <div className="user-management-body">
          <CreateMerchantForm />
        </div>
      </div>
    </div>
  )
}
  • [ ] Step 3: Run admin tests

Run: npx vitest run --config apps/admin/vitest.config.js

Expected: all tests pass (these pages have no unit tests; this is a render-only change).

  • [ ] Step 4: Commit
bash
git add apps/admin/src/components/merchants/MerchantsAll.jsx apps/admin/src/components/merchants/MerchantsCreate.jsx
git commit -m "feat(admin): give MerchantsAll and MerchantsCreate their own PageHeaders

Each page renders its own header since the shared MerchantsListLayout is
being removed. MerchantsAll shows title, subtitle, Create CTA, and the
MerchantSwitcher. MerchantsCreate shows title and Cancel button. Also
replaces the empty-state emoji with a Lucide Store icon."

Task 8: Rewire routing and delete dead files โ€‹

Files:

  • Modify: apps/admin/src/components/AdminDashboard.jsx

  • Delete: apps/admin/src/components/merchants/MerchantsListLayout.jsx

  • Delete: apps/admin/src/components/merchants/__tests__/MerchantsListLayout.test.jsx

  • Delete: apps/admin/src/components/merchants/MerchantsOverview.jsx

  • Delete: apps/admin/src/components/merchants/MerchantsOffers.jsx

  • [ ] Step 1: Update imports in AdminDashboard.jsx

Open apps/admin/src/components/AdminDashboard.jsx. Find the merchant-related imports at the top.

Remove these imports:

jsx
import MerchantsListLayout from './merchants/MerchantsListLayout'
import MerchantsOverview from './merchants/MerchantsOverview'
import MerchantsOffers from './merchants/MerchantsOffers'

Add this import:

jsx
import MerchantsIndexRedirect from './merchants/MerchantsIndexRedirect'
  • [ ] Step 2: Update the merchants routing block

Find the merchants routing block (around lines 599โ€“615). Change from:

jsx
<Route path="/merchants">
  <Route element={<MerchantsListLayout isAdmin={isAdmin} />}>
    <Route index element={<Navigate to="/merchants/overview" replace />} />
    <Route path="overview" element={<MerchantsOverview />} />
    <Route path="offers" element={<MerchantsOffers isAdmin={isAdmin} />} />
    <Route path="all" element={<MerchantsAll />} />
    <Route path="create" element={<MerchantsCreate />} />
  </Route>
  <Route path=":merchantId" element={<MerchantDetail />}>
    <Route index element={<Navigate to="overview" replace />} />
    <Route path="overview" element={<MerchantOverviewTab />} />
    <Route path="venues" element={<MerchantVenuesTab />} />
    <Route path="notes" element={<MerchantNotesTab />} />
    <Route path="photos" element={<MerchantPhotosTab />} />
    <Route path="address" element={<MerchantAddressTab />} />
  </Route>
</Route>

to:

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 isAdmin={isAdmin} />}>
    <Route index element={<Navigate to="overview" replace />} />
    <Route path="overview" element={<MerchantOverviewTab />} />
    <Route path="venues" element={<MerchantVenuesTab />} />
    <Route path="notes" element={<MerchantNotesTab />} />
    <Route path="photos" element={<MerchantPhotosTab />} />
    <Route path="address" element={<MerchantAddressTab />} />
  </Route>
</Route>

Key changes:

  • Index route uses MerchantsIndexRedirect instead of Navigate to="/merchants/overview".

  • No MerchantsListLayout wrapper โ€” each page owns its own shell.

  • overview and offers routes removed.

  • MerchantDetail receives isAdmin prop.

  • [ ] Step 3: Update the merchantOnly redirect

Find the merchantOnly effect (around lines 159โ€“167). Change from:

jsx
  useEffect(() => {
    if (!merchantOnly) return
    const allowed = ['/merchants/overview', '/merchants/offers']
    if (!allowed.includes(location.pathname)) {
      navigate('/merchants/overview', { replace: true })
    }
  }, [merchantOnly, location.pathname, navigate])

to:

jsx
  useEffect(() => {
    if (!merchantOnly) return
    // Merchant-role users can only access /merchants (index redirect handles
    // routing to their own detail page) and /merchants/:theirMerchantId/*.
    if (
      location.pathname !== '/merchants' &&
      !location.pathname.startsWith(`/merchants/${user?.merchantId}`)
    ) {
      navigate('/merchants', { replace: true })
    }
  }, [merchantOnly, location.pathname, navigate, user?.merchantId])

This allows merchant-role users to access:

  • /merchants (redirects to their detail via MerchantsIndexRedirect)
  • /merchants/:theirMerchantId/* (their own detail page and all tabs)

Everything else (including /merchants/all, /merchants/create, other merchants' detail pages) redirects to /merchants.

  • [ ] Step 4: Delete the dead files

Run:

bash
rm apps/admin/src/components/merchants/MerchantsListLayout.jsx
rm apps/admin/src/components/merchants/__tests__/MerchantsListLayout.test.jsx
rm apps/admin/src/components/merchants/MerchantsOverview.jsx
rm apps/admin/src/components/merchants/MerchantsOffers.jsx
  • [ ] Step 5: Verify no dangling references

Run: grep -rn 'MerchantsListLayout\|MerchantsOverview\|MerchantsOffers' apps/admin/src/ --include='*.jsx' --include='*.js'

Expected: no output (no matches). If there are matches, remove or update them.

  • [ ] Step 6: Run admin tests

Run: npx vitest run --config apps/admin/vitest.config.js

Expected: all tests pass. The deleted MerchantsListLayout.test.jsx no longer runs. The MerchantsIndexRedirect tests from Task 3 should still pass.

  • [ ] Step 7: Commit
bash
git add apps/admin/src/components/AdminDashboard.jsx
git rm apps/admin/src/components/merchants/MerchantsListLayout.jsx apps/admin/src/components/merchants/__tests__/MerchantsListLayout.test.jsx apps/admin/src/components/merchants/MerchantsOverview.jsx apps/admin/src/components/merchants/MerchantsOffers.jsx
git commit -m "refactor(admin): replace shared merchants layout with per-route headers

Deletes MerchantsListLayout (the 3-tab shell) and the two placeholder
pages (MerchantsOverview, MerchantsOffers). Routes now use a smart index
redirect and each page owns its own PageHeader. Merchant-role redirect
updated from the deleted /merchants/overview to /merchants (which the
index redirect resolves to the user's own merchant detail page)."

Task 9: Full validation sweep โ€‹

No code changes โ€” validation and visual verification.

  • [ ] Step 1: Run the project-wide validator

Run: npm run validate -- --workspace apps/admin

Expected: all checks pass (lint, format, tests, build, audit).

If anything fails, fix inline and commit the fix separately.

  • [ ] Step 2: Visual smoke test (admin user)

Run: npm run dev -w apps/admin

Manual checks:

  1. /merchants with no localStorage โ†’ lands on /merchants/all. Header: "Merchants" / "Select a merchant..." / [+ Create Merchant] + switcher button. Switcher shows "No recent merchants" + "View all merchants" + "Create merchant".
  2. Click a merchant row โ†’ /merchants/:id/overview. Header: breadcrumb โ† Merchants, merchant name, [Back to All] [Edit] + switcher. PageTabs shows 5 tabs.
  3. Click switcher โ†’ "Currently viewing: (name)" + "View all merchants โ†’".
  4. Navigate to /merchants again โ†’ smart-redirects to the merchant you just viewed (localStorage current).
  5. Click [+ Create Merchant] on /merchants/all โ†’ /merchants/create. Header: "Create Merchant" / [Cancel]. Cancel โ†’ back to /merchants/all.
  6. Browser back/forward preserves correct state at each stop.
  7. Hard-reload on /merchants/:id/venues โ€” breadcrumb, switcher, and Venues tab all correct.
  • [ ] Step 3: Visual smoke test (merchant-role user)

If a merchant-role test account is available:

  1. Sign in โ†’ automatically lands on /merchants/:theirMerchantId/overview.
  2. No breadcrumb. No switcher. No [+ Create Merchant].
  3. PageTabs visible with all 5 tabs.
  4. Direct nav to /merchants/all โ†’ redirected to /merchants โ†’ redirected to own detail.
  5. Direct nav to /merchants/other-id/overview โ†’ redirected to /merchants โ†’ own detail.

If no merchant-role account is available, flag for PR reviewer.

  • [ ] Step 4: Cross-page regression

Confirm non-merchants pages unaffected:

  1. /users โ€” UserManagement loads, sidebar active.
  2. /billing โ€” Billing loads.
  3. /system โ€” SystemHealth loads.
  4. / โ€” DashboardHome renders. "Merchant Management" card navigates to /merchants.
  • [ ] Step 5: Sign-out verification
  1. Visit several merchants to populate localStorage.
  2. Sign out.
  3. Open devtools โ†’ Application โ†’ localStorage โ†’ lantern.admin.currentMerchant should be absent.
  4. Sign back in โ†’ /merchants โ†’ /merchants/all (no stored merchant).

Self-review โ€‹

Spec coverage check:

Spec sectionTask
ยง1 Two distinct chromesTasks 6 (per-merchant) + 7 (cross-merchant)
ยง2 URL/routing changesTask 8 step 2
ยง2 Routes removed (/merchants/overview, /merchants/offers)Task 8 steps 2, 4
ยง3 Current-merchant persistence (localStorage)Task 1
ยง3 getCurrentMerchantId / getRecentMerchantIds / rememberMerchant / forgetMerchant / clearCurrentMerchantTask 1 step 3
ยง3 Storage cap 8, display 5Task 1 (cap) + Task 4 (MAX_DISPLAY = 5)
ยง4 Smart redirect (MerchantsIndexRedirect)Task 3
ยง4 Optimistic redirect, self-healing 404Task 6 step 3
ยง4 Auth-state prerequisite (merchantId on user)Task 5 step 1
ยง4 MerchantAccountMisconfiguredTask 3 step 3
ยง5 Switcher dropdownTask 4
ยง5 "Currently viewing" from useParams, not localStorageTask 4 step 3 (uses useParams().merchantId)
ยง5 Name resolution via getMerchantDataTask 4 step 3
ยง5 Empty state (no recent)Task 4 step 3 + tests
ยง6 Breadcrumb (โ† Merchants โ†’ /merchants/all)Task 2 (prop) + Task 6 step 4 (usage)
ยง6 Breadcrumb hidden for merchant-roleTask 6 step 4 (isAdmin gate)
ยง7 Per-route headers for MerchantsAll / MerchantsCreateTask 7
ยง7 MerchantDetail gains breadcrumb + switcherTask 6 step 4
ยงโ€” clearCurrentMerchant on sign-outTask 5 step 2
ยงโ€” merchantOnly redirect updatedTask 8 step 3
ยงโ€” Delete MerchantsListLayout, MerchantsOverview, MerchantsOffersTask 8 steps 1, 4
ยงโ€” Delete MerchantsListLayout.test.jsxTask 8 step 4
ยงโ€” Automated tests: currentMerchant.test.jsTask 1
ยงโ€” Automated tests: MerchantsIndexRedirect.test.jsxTask 3
ยงโ€” Automated tests: MerchantSwitcher.test.jsxTask 4

No gaps.

Placeholder scan: No TBDs, TODOs, or vague instructions. Every code step has complete code.

Type consistency:

  • MerchantsIndexRedirect takes { user, isAdmin } โ€” matches Task 8 wiring (user={user} isAdmin={isAdmin}).
  • MerchantDetail takes { isAdmin } โ€” matches Task 8 wiring (isAdmin={isAdmin}).
  • MerchantSwitcher takes no props โ€” uses useParams().merchantId internally. Consistent across Tasks 4, 6, 7.
  • currentMerchant.js exports 5 functions โ€” all consumed by name in Tasks 3, 4, 5, 6.
  • PageHeader gains breadcrumb prop โ€” backward-compatible, used only by MerchantDetail (Task 6).
  • forgetMerchant / rememberMerchant / clearCurrentMerchant names match between Task 1 definition and Tasks 3, 5, 6 consumption.
  • Lucide icons: ArrowLeft, ChevronDown, Check, Plus, LogOut, Store โ€” all exist in lucide-react.

No inconsistencies found.

Built with VitePress