Skip to content

Merchants Page Patterns 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: Align the admin portal's Merchants section with ADMIN_PAGE_PATTERNS.md (using UserManagement.jsx as the reference), and swap emoji icons on the dashboard landing for Lucide.

Architecture: Introduce MerchantsListLayout as a layout route wrapping /merchants/{overview,offers,all,create}, replacing per-page inline headers with a shared <PageHeader /> + <PageTabs /> shell. The tab active state is derived from the URL (not local state), preserving deep linking. MerchantDetail swaps its <TabBar /> for <PageTabs /> and consolidates scattered action buttons into the <PageHeader /> actions slot. The <TabBar /> component becomes dead code and is deleted.

Tech Stack: React 19, React Router v6, Vitest + Testing Library React, lucide-react icons, existing <PageHeader /> and <PageTabs /> components in apps/admin/src/components/.

Spec: docs/superpowers/specs/2026-04-24-merchants-page-patterns-design.md


Before you start โ€‹

  • Branch. Current working branch is claude/merchant-ad-placeholders-y2pxu, which doesn't match this work. Confirm with the user whether to (a) keep this branch, (b) create a new branch off dev, or (c) use a git worktree. Default: new branch claude/admin-merchants-page-patterns off dev.
  • Commit policy. CLAUDE.md says "never commit unless explicitly asked." The user invoked superpowers, which implies the full workflow including commits. Confirm commit permission at the first commit step; if denied, pause after each task and let the user commit manually.
  • Run mode. Dev server at port 3001 (npm run dev -w apps/admin) is required for manual verification at the end of tasks 1, 3, and 4.

File structure โ€‹

New files โ€‹

PathResponsibility
apps/admin/src/components/merchants/MerchantsListLayout.jsxLayout route. Renders <PageHeader /> + <PageTabs /> + <Outlet />. Derives active tab from URL. Role-filters tabs. Gates Create CTA on isAdmin.
apps/admin/src/components/merchants/__tests__/MerchantsListLayout.test.jsxUnit tests for the layout's URLโ†’tab derivation, role filtering, and create-view branch.

Modified files โ€‹

PathChange
apps/admin/src/components/AdminDashboard.jsxDashboardHome: 10 emoji โ†’ Lucide. Sidebar: collapse merchants expandable โ†’ single entry. Routes: wrap list children in <MerchantsListLayout />. Remove expandedSections.merchants initializer.
apps/admin/src/components/merchants/MerchantsOverview.jsxRemove outer .user-management-container/__main + inline .page-header wrapper. Return pure content.
apps/admin/src/components/merchants/MerchantsOffers.jsxSame โ€” remove outer wrapper + inline header.
apps/admin/src/components/merchants/MerchantsAll.jsxSame โ€” remove outer wrapper + inline header.
apps/admin/src/components/merchants/MerchantsCreate.jsxSame โ€” remove outer wrapper + inline header.
apps/admin/src/components/merchants/MerchantDetail.jsx<TabBar> โ†’ <PageTabs> (URL-driven). Inline header โ†’ <PageHeader /> with consolidated action buttons (Back, Edit / Cancel, Save). Remove bottom action-button block. โœ… โ†’ <CheckCircle />.

Deleted files โ€‹

PathReason
apps/admin/src/components/TabBar.jsxSole consumer (MerchantDetail.jsx) swapped to <PageTabs />.

Task 1: DashboardHome emoji โ†’ Lucide โ€‹

Files:

  • Modify: apps/admin/src/components/AdminDashboard.jsx (the DashboardHome function, ~lines 678โ€“827)

All target Lucide icons are already imported at the top of AdminDashboard.jsx for the sidebar โ€” nothing new to add to the import block.

  • [ ] Step 1: Confirm Lucide imports already exist

Run: grep -E 'FileText|Puzzle|BarChart3|Store|Users|Wrench|DollarSign|CheckSquare|Radio|Settings' apps/admin/src/components/AdminDashboard.jsx | head -5

Expected: matches found in the import block at the top of the file.

  • [ ] Step 2: Inspect .feature-card-icon CSS rule to understand sizing

Run: grep -n 'feature-card-icon' apps/admin/src/styles.css

Expected: a CSS rule that sizes the glyph (likely font-size for emojis). If the rule only uses font-size, it won't size an SVG โ€” you'll need to add .feature-card-icon svg { width: 32px; height: 32px; } in step 4.

  • [ ] Step 3: Replace emoji divs with Lucide icons

For each feature card in DashboardHome (there are 10), change:

jsx
<div className="feature-card-icon">๐Ÿ“</div>

to:

jsx
<div className="feature-card-icon"><FileText size={32} /></div>

Using this mapping, in order of appearance in the file:

Card label (verbatim)Target icon
Documentation EditorFileText
StorybookPuzzle
Analytics DashboardBarChart3
Merchant ManagementStore
User ManagementUsers
System HealthWrench
BillingDollarSign
Task TrackerCheckSquare
API ReferenceRadio
ConfigurationSettings

Also flip the "Merchant Management" card from coming-soon to available and add the click handler. Change:

jsx
{/* Merchant Management - Coming Soon */}
<div className="feature-card coming-soon">
  <div className="feature-card-icon">๐Ÿช</div>
  <h3>Merchant Management</h3>
  <p>Review merchant applications, manage listings, and moderate content.</p>
</div>

to:

jsx
{/* Merchant Management - Available */}
<div
  className="feature-card available"
  onClick={() => onNavigate('merchants')}
  style={{ cursor: 'pointer' }}
>
  <div className="feature-card-icon"><Store size={32} /></div>
  <h3>Merchant Management</h3>
  <p>Review merchant applications, manage listings, and moderate content.</p>
  <span className="badge badge-info" style={{ marginTop: '8px' }}>
    Manage Merchants
  </span>
</div>
  • [ ] Step 4: Add SVG sizing rule if needed

Only if step 2 revealed .feature-card-icon relies on font-size for sizing: append to apps/admin/src/styles.css in the section where .feature-card-icon is defined:

css
.feature-card-icon svg {
  width: 32px;
  height: 32px;
}

Skip this step if the rule already sets explicit width/height or uses a flex/grid layout that will accept the SVG at its size={32} prop value.

  • [ ] Step 5: Start dev server and verify visually

Run: npm run dev -w apps/admin

Navigate to http://localhost:3001/. Every feature card must show a Lucide icon (not an emoji). Click "Merchant Management" โ€” it should route to /merchants/overview. Stop the dev server when done (Ctrl+C).

  • [ ] Step 6: Run admin tests (existing AdminDashboard.test.jsx must still pass)

Run: npm test -w apps/admin -- --run

Expected: all tests pass (the existing router-integration tests don't assert on emojis, so the swap shouldn't break them).

  • [ ] Step 7: Commit
bash
git add apps/admin/src/components/AdminDashboard.jsx apps/admin/src/styles.css
git commit -m "style(admin): replace dashboard feature-card emojis with Lucide icons

Brings the dashboard landing page into compliance with the admin-page-
pattern rule against emojis in UI chrome. Also flips the Merchant
Management card from 'coming soon' to 'available' now that the routes
are live."

Task 2: Create MerchantsListLayout with tests โ€‹

TDD here โ€” this is the only genuinely new logic in the spec. The mechanical shell swaps in later tasks don't warrant tests.

Files:

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

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

  • [ ] Step 1: Write the failing test

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

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

function renderAt(path, { isAdmin }) {
  return render(
    <MemoryRouter initialEntries={[path]}>
      <Routes>
        <Route path="/merchants" element={<MerchantsListLayout isAdmin={isAdmin} />}>
          <Route path="overview" element={<div data-testid="route-body">overview-body</div>} />
          <Route path="offers" element={<div data-testid="route-body">offers-body</div>} />
          <Route path="all" element={<div data-testid="route-body">all-body</div>} />
          <Route path="create" element={<div data-testid="route-body">create-body</div>} />
        </Route>
      </Routes>
    </MemoryRouter>
  )
}

describe('MerchantsListLayout', () => {
  it('renders Merchants title and all 3 tabs for admin on overview', () => {
    renderAt('/merchants/overview', { isAdmin: true })
    expect(screen.getByRole('heading', { name: /Merchants/i })).toBeInTheDocument()
    expect(screen.getByRole('tab', { name: /Overview/i })).toHaveAttribute('aria-selected', 'true')
    expect(screen.getByRole('tab', { name: /Offers/i })).toBeInTheDocument()
    expect(screen.getByRole('tab', { name: /All Merchants/i })).toBeInTheDocument()
    expect(screen.getByRole('button', { name: /Create Merchant/i })).toBeInTheDocument()
    expect(screen.getByTestId('route-body')).toHaveTextContent('overview-body')
  })

  it('shows only Overview and Offers tabs for non-admin (merchant) user', () => {
    renderAt('/merchants/overview', { isAdmin: false })
    expect(screen.getByRole('tab', { name: /Overview/i })).toBeInTheDocument()
    expect(screen.getByRole('tab', { name: /Offers/i })).toBeInTheDocument()
    expect(screen.queryByRole('tab', { name: /All Merchants/i })).not.toBeInTheDocument()
    expect(screen.queryByRole('button', { name: /Create Merchant/i })).not.toBeInTheDocument()
  })

  it('marks the Offers tab active when on /merchants/offers', () => {
    renderAt('/merchants/offers', { isAdmin: true })
    expect(screen.getByRole('tab', { name: /Offers/i })).toHaveAttribute('aria-selected', 'true')
    expect(screen.getByRole('tab', { name: /Overview/i })).toHaveAttribute('aria-selected', 'false')
  })

  it('marks the All Merchants tab active when on /merchants/all', () => {
    renderAt('/merchants/all', { isAdmin: true })
    expect(screen.getByRole('tab', { name: /All Merchants/i })).toHaveAttribute('aria-selected', 'true')
  })

  it('on create view: hides tabs, switches title to "Create Merchant", hides Create button', () => {
    renderAt('/merchants/create', { isAdmin: true })
    expect(screen.getByRole('heading', { name: /Create Merchant/i })).toBeInTheDocument()
    // Tab strip hidden entirely on create view
    expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
    // Create button should not render when already on create view
    expect(screen.queryByRole('button', { name: /Create Merchant/i })).not.toBeInTheDocument()
    expect(screen.getByTestId('route-body')).toHaveTextContent('create-body')
  })

  it('renders Outlet child on all list routes', () => {
    renderAt('/merchants/all', { isAdmin: true })
    expect(screen.getByTestId('route-body')).toHaveTextContent('all-body')
  })
})
  • [ ] Step 2: Run tests to verify they fail

Run: npm test -w apps/admin -- --run src/components/merchants/__tests__/MerchantsListLayout.test.jsx

Expected: FAIL with "Cannot find module '../MerchantsListLayout'" or similar โ€” the component doesn't exist yet.

  • [ ] Step 3: Implement MerchantsListLayout

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

jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { ClipboardList, Gift, ContactRound, Plus } from 'lucide-react'
import PageHeader from '../PageHeader'
import PageTabs from '../PageTabs'

const TABS = [
  { id: 'overview', label: 'Overview',      icon: <ClipboardList size={16} />, visibleForRoles: ['admin', 'merchant'] },
  { id: 'offers',   label: 'Offers',        icon: <Gift size={16} />,          visibleForRoles: ['admin', 'merchant'] },
  { id: 'all',      label: 'All Merchants', icon: <ContactRound size={16} />,  visibleForRoles: ['admin'] },
]

export default function MerchantsListLayout({ isAdmin }) {
  const location = useLocation()
  const navigate = useNavigate()

  const role = isAdmin ? 'admin' : 'merchant'
  const visibleTabs = TABS.filter((t) => t.visibleForRoles.includes(role))
  const tabIds = TABS.map((t) => t.id)

  const segment = location.pathname.split('/')[2]
  const activeTab = tabIds.includes(segment) ? segment : 'overview'
  const isCreateView = segment === 'create'

  const title = isCreateView ? 'Create Merchant' : 'Merchants'
  const subtitle = isCreateView
    ? null
    : isAdmin
      ? 'Manage platform merchants'
      : 'Manage your venue'

  const actions =
    !isCreateView && isAdmin ? (
      <button className="btn btn-primary btn-sm" onClick={() => navigate('/merchants/create')}>
        <Plus size={14} />
        Create Merchant
      </button>
    ) : null

  return (
    <div className="user-management-container">
      <div className="user-management-main">
        <PageHeader title={title} subtitle={subtitle} actions={actions} />
        {!isCreateView && (
          <PageTabs
            tabs={visibleTabs}
            activeTab={activeTab}
            onTabChange={(id) => navigate(`/merchants/${id}`)}
          />
        )}
        <div className="user-management-body">
          <Outlet />
        </div>
      </div>
    </div>
  )
}
  • [ ] Step 4: Run tests to verify they pass

Run: npm test -w apps/admin -- --run src/components/merchants/__tests__/MerchantsListLayout.test.jsx

Expected: all 6 tests pass.

  • [ ] Step 5: Commit
bash
git add apps/admin/src/components/merchants/MerchantsListLayout.jsx apps/admin/src/components/merchants/__tests__/MerchantsListLayout.test.jsx
git commit -m "feat(admin): add MerchantsListLayout route shell

Introduces a URL-driven layout route for the Merchants section. Renders
PageHeader + PageTabs + Outlet, filters tabs by role, and gates the
Create Merchant CTA on isAdmin. Active tab derived from the URL so
deep linking and browser history work correctly."

Task 3: Wire layout, strip list-page shells, collapse sidebar โ€‹

Files:

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

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

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

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

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

  • [ ] Step 1: Import MerchantsListLayout in AdminDashboard.jsx

At the top of apps/admin/src/components/AdminDashboard.jsx, add to the existing merchants imports:

jsx
import MerchantsListLayout from './merchants/MerchantsListLayout'
  • [ ] Step 2: Wrap the list routes in the layout

Find the <Route path="/merchants"> block (around line 628). Change from:

jsx
<Route path="/merchants">
  <Route index element={<Navigate to="/merchants/overview" replace />} />
  <Route path="overview" element={<MerchantsOverview user={user} isAdmin={isAdmin} />} />
  <Route path="offers" element={<MerchantsOffers isAdmin={isAdmin} />} />
  <Route path="all" element={<MerchantsAll />} />
  <Route path="create" element={<MerchantsCreate />} />
  <Route path=":merchantId" element={<MerchantDetail />}>
    ...
  </Route>
</Route>

to:

jsx
<Route path="/merchants">
  <Route element={<MerchantsListLayout isAdmin={isAdmin} />}>
    <Route index element={<Navigate to="/merchants/overview" replace />} />
    <Route path="overview" element={<MerchantsOverview user={user} isAdmin={isAdmin} />} />
    <Route path="offers" element={<MerchantsOffers isAdmin={isAdmin} />} />
    <Route path="all" element={<MerchantsAll />} />
    <Route path="create" element={<MerchantsCreate />} />
  </Route>
  <Route path=":merchantId" element={<MerchantDetail />}>
    ...
  </Route>
</Route>

(Only the layout wrapper is added; the :merchantId detail route stays a sibling of the layout route, not a child.)

  • [ ] Step 3: Collapse sidebar merchants item

Find the merchants nav item in navSections (around line 313). Change from:

jsx
{
  id: 'merchants',
  label: 'Merchants',
  icon: <Store size={18} />,
  expandable: true,
  visibleForRoles: ['admin', 'merchant'],
  subItems: [
    {
      id: 'merchants/overview',
      label: 'Overview',
      icon: <ClipboardList size={16} />,
      visibleForRoles: ['admin', 'merchant'],
    },
    {
      id: 'merchants/offers',
      label: 'Offers',
      icon: <Gift size={16} />,
      visibleForRoles: ['admin', 'merchant'],
    },
    {
      id: 'merchants/all',
      label: 'All Merchants',
      icon: <ContactRound size={16} />,
      visibleForRoles: ['admin'],
    },
    {
      id: 'merchants/create',
      label: 'Create Merchant',
      icon: <Plus size={16} />,
      visibleForRoles: ['admin'],
    },
  ],
},

to:

jsx
{
  id: 'merchants',
  label: 'Merchants',
  icon: <Store size={18} />,
  visibleForRoles: ['admin', 'merchant'],
},
  • [ ] Step 4: Remove the merchants auto-expand initializer

Find expandedSections useState initializer (around line 108โ€“128). Remove the line:

jsx
if (location.pathname.startsWith('/merchants')) initial.merchants = true

(Leave the other section auto-expansions โ€” docs, api, config, analytics โ€” untouched.)

  • [ ] Step 5: Strip MerchantsOverview.jsx outer shell

Change from:

jsx
export default function MerchantsOverview({ user, isAdmin }) {
  return (
    <div className="user-management-container">
      <div className="user-management-main">
        <div className="page-header">
          <div>
            <h1>Merchants Overview</h1>
            <p className="text-muted">...</p>
          </div>
        </div>
        <div className="feature-card coming-soon" style={{ maxWidth: 560 }}>
          ...
        </div>
      </div>
    </div>
  )
}

to:

jsx
export default function MerchantsOverview({ user, isAdmin }) {
  return (
    <div className="feature-card coming-soon" style={{ maxWidth: 560 }}>
      <div className="feature-card-icon">๐Ÿ“Š</div>
      <h3>Metrics dashboard coming soon</h3>
      <p>
        Impressions, clicks, redemptions, and average order value will live here โ€” tuned to
        whichever merchant you&apos;re viewing.
      </p>
    </div>
  )
}

(The user/isAdmin props are no longer used in the content since the subtitle moved into the layout header. Remove them from the function signature if nothing inside the body uses them. Double-check by reading the full current file before trimming.)

  • [ ] Step 6: Strip MerchantsOffers.jsx outer shell

Open apps/admin/src/components/merchants/MerchantsOffers.jsx. Find the outermost <div className="user-management-container">...<div className="user-management-main">...</div></div> and the inner <div className="page-header"> block. Remove them, returning only the inner content.

If the page currently renders <h1>...</h1> and <p className="text-muted">...</p> inside an inline header, those become redundant (the layout's <PageHeader /> owns the title). Delete them.

The function signature keeps { isAdmin } since offer-rendering logic may still need it.

  • [ ] Step 7: Strip MerchantsAll.jsx outer shell

Same pattern โ€” remove .user-management-container/__main + inline page-header. Preserve all content below.

  • [ ] Step 8: Strip MerchantsCreate.jsx outer shell

Same pattern. The create page will now render inside a layout that switches its title to "Create Merchant" and hides the tab strip.

  • [ ] Step 9: Start dev server and verify visually

Run: npm run dev -w apps/admin

Manual checks (admin user):

  1. Navigate to http://localhost:3001/merchants/overview. Header shows "Merchants" + "Manage platform merchants". Tab strip shows 3 tabs. Overview is active. [+ Create Merchant] button visible in header.
  2. Click "Offers" tab. URL updates to /merchants/offers. Offers tab active.
  3. Click "All Merchants" tab. URL updates to /merchants/all. All Merchants tab active.
  4. Click [+ Create Merchant] button. URL updates to /merchants/create. Title changes to "Create Merchant". Tab strip hidden. Header button hidden.
  5. Browser back button returns to /merchants/all with correct tab state.
  6. Sidebar shows single "Merchants" entry (no expand arrow, no sub-items).
  7. Hard-reload on /merchants/offers โ€” tab state restored from URL.

Stop the dev server (Ctrl+C).

  • [ ] Step 10: Run tests

Run: npm test -w apps/admin -- --run

Expected: all tests pass. The existing AdminDashboard.test.jsx tests do not exercise the merchants routes, so they're unaffected.

  • [ ] Step 11: Commit
bash
git add apps/admin/src/components/AdminDashboard.jsx apps/admin/src/components/merchants/MerchantsOverview.jsx apps/admin/src/components/merchants/MerchantsOffers.jsx apps/admin/src/components/merchants/MerchantsAll.jsx apps/admin/src/components/merchants/MerchantsCreate.jsx
git commit -m "refactor(admin): wire MerchantsListLayout, strip per-page shells

Replaces four near-identical page headers on the merchant list routes
with a shared layout. Collapses the merchants sidebar item from a
4-subitem expandable to a single entry โ€” the sub-navigation now lives
inside the page as PageTabs. URL routes preserved; deep linking and
browser history continue to work."

Task 4: Merchant Detail shell swap โ€‹

Files:

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

  • Delete: apps/admin/src/components/TabBar.jsx

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

Change:

jsx
import React, { useEffect, useState } from 'react'
import { useParams, useSearchParams, useLocation, useNavigate, Outlet } from 'react-router-dom'
import { getMerchantData, updateMerchantUser, disassociateVenueFromMerchant } from '../../firebase'
import TabBar from '../TabBar'

to:

jsx
import React, { useEffect, useState } from 'react'
import { useParams, useSearchParams, useLocation, useNavigate, Outlet } from 'react-router-dom'
import { ClipboardList, MapPin, StickyNote, Image, Home, CheckCircle } from 'lucide-react'
import { getMerchantData, updateMerchantUser, disassociateVenueFromMerchant } from '../../firebase'
import PageHeader from '../PageHeader'
import PageTabs from '../PageTabs'
  • [ ] Step 2: Add the DETAIL_TABS definition at module scope

Above the export default function MerchantDetail() line, add:

jsx
const DETAIL_TABS = [
  { id: 'overview', label: 'Overview', icon: <ClipboardList size={16} /> },
  { id: 'venues',   label: 'Venues',   icon: <MapPin size={16} /> },
  { id: 'notes',    label: 'Notes',    icon: <StickyNote size={16} /> },
  { id: 'photos',   label: 'Photos',   icon: <Image size={16} /> },
  { id: 'address',  label: 'Address',  icon: <Home size={16} /> },
]
  • [ ] Step 3: Replace the main render block

Find the main return block (around line 182โ€“247, inside the if (loading) / if (error) guards). Change from:

jsx
return (
  <div className="user-management-container">
    <div className="user-management-main">
      <div className="page-header">
        <div>
          <h1>{businessName}</h1>
          <p className="text-muted">Merchant account details</p>
        </div>
        <div className="page-actions">
          {!editing && (
            <button className="btn btn-secondary" onClick={handleEnterEdit}>
              Edit
            </button>
          )}
        </div>
      </div>

      {wasJustCreated && !editing && (
        <JustCreatedBanner user={primaryOwner} state={creationState} />
      )}

      {savedAt && !editing && (
        <div
          className="form-success"
          style={{ marginBottom: 'var(--space-4)', padding: 'var(--space-3)' }}
        >
          <strong>โœ… Changes saved</strong> at {savedAt.toLocaleTimeString()}.
        </div>
      )}

      {saveError && <div className="form-error">{saveError}</div>}

      <TabBar items={tabs} />

      <Outlet context={outletContext} />

      <div
        style={{
          marginTop: 'var(--space-6)',
          display: 'flex',
          gap: 'var(--space-2)',
          flexWrap: 'wrap',
        }}
      >
        {editing ? (
          <>
            <button
              className="btn btn-primary"
              onClick={handleSave}
              disabled={saving || !formData?.businessName?.trim()}
            >
              {saving ? 'Savingโ€ฆ' : 'Save changes'}
            </button>
            <button className="btn btn-secondary" onClick={handleCancel} disabled={saving}>
              Cancel
            </button>
          </>
        ) : (
          <button className="btn btn-secondary" onClick={() => navigate('/merchants/all')}>
            Back to All Merchants
          </button>
        )}
      </div>
    </div>
  </div>
)

to:

jsx
const activeTab = location.pathname.split('/')[3] || 'overview'
const tabs = DETAIL_TABS.map((t) =>
  t.id === 'venues' ? { ...t, label: `Venues (${venues.length})` } : t
)

const headerActions = editing ? (
  <>
    <button className="btn btn-secondary btn-sm" onClick={handleCancel} disabled={saving}>
      Cancel
    </button>
    <button
      className="btn btn-primary btn-sm"
      onClick={handleSave}
      disabled={saving || !formData?.businessName?.trim()}
    >
      {saving ? 'Savingโ€ฆ' : 'Save changes'}
    </button>
  </>
) : (
  <>
    <button
      className="btn btn-secondary btn-sm"
      onClick={() => navigate('/merchants/all')}
    >
      Back to All
    </button>
    <button className="btn btn-secondary btn-sm" onClick={handleEnterEdit}>
      Edit
    </button>
  </>
)

return (
  <div className="user-management-container">
    <div className="user-management-main">
      <PageHeader
        title={businessName}
        subtitle="Merchant account details"
        actions={headerActions}
      />

      {wasJustCreated && !editing && (
        <JustCreatedBanner user={primaryOwner} state={creationState} />
      )}

      {savedAt && !editing && (
        <div
          className="form-success"
          style={{ marginBottom: 'var(--space-4)', padding: 'var(--space-3)' }}
        >
          <strong>
            <CheckCircle size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
            Changes saved
          </strong>{' '}
          at {savedAt.toLocaleTimeString()}.
        </div>
      )}

      {saveError && <div className="form-error">{saveError}</div>}

      <PageTabs
        tabs={tabs}
        activeTab={activeTab}
        onTabChange={(id) => navigate(`/merchants/${merchantId}/${id}`)}
      />

      <div className="user-management-body">
        <Outlet context={outletContext} />
      </div>
    </div>
  </div>
)

Notes on the diff:

  • The old tabs array ([{ to, label }, ...]) that was passed to <TabBar /> is deleted โ€” replaced by the DETAIL_TABS-derived array above.

  • The bottom action-buttons <div style={...}> block is gone; actions live in headerActions.

  • Back to All Merchants shortened to Back to All to fit the compact header.

  • โœ… โ†’ <CheckCircle /> with inline vertical alignment so it aligns with the text baseline.

  • Outlet is wrapped in .user-management-body for consistent padding with the list pages.

  • [ ] Step 4: Swap โœ… in JustCreatedBanner

The JustCreatedBanner function at the bottom of MerchantDetail.jsx (around line 250) also uses โœ…. Change:

jsx
<strong>โœ… Merchant account created.</strong>

to:

jsx
<strong>
  <CheckCircle size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
  Merchant account created.
</strong>
  • [ ] Step 5: Delete TabBar.jsx

Run: rm apps/admin/src/components/TabBar.jsx

  • [ ] Step 6: Verify no lingering TabBar references

Run: grep -rn "TabBar" apps/admin/src/ --include='*.jsx' --include='*.js'

Expected: no output (no matches).

  • [ ] Step 7: Start dev server and verify visually

Run: npm run dev -w apps/admin

Manual checks:

  1. Navigate to /merchants/all, click any merchant row โ†’ detail page loads.
  2. Header shows merchant name as title, "Merchant account details" as subtitle. Right side shows [Back to All] [Edit].
  3. PageTabs shows 5 tabs: Overview ยท Venues (n) ยท Notes ยท Photos ยท Address. Overview is active.
  4. Click "Venues" โ†’ URL updates to /merchants/:id/venues. Venues tab active.
  5. Hard-reload on /merchants/:id/notes โ€” Notes tab active on direct load.
  6. Click Edit โ†’ header actions swap to [Cancel] [Save changes]. Save is primary (amber).
  7. Edit a field, click Cancel โ†’ returns to view mode. Click Edit again, make a change, click Save โ†’ "Changes saved" banner renders with a Lucide check icon (no emoji). Actions return to [Back to All] [Edit].
  8. Click [Back to All] โ†’ returns to /merchants/all.
  9. Create a new merchant (via /merchants/create) and on the detail redirect, verify the "Merchant account created" banner shows the Lucide check icon.

Stop the dev server (Ctrl+C).

  • [ ] Step 8: Run tests

Run: npm test -w apps/admin -- --run

Expected: all tests pass, including the 6 MerchantsListLayout tests from Task 2 and the existing AdminDashboard.test.jsx tests.

  • [ ] Step 9: Commit
bash
git add apps/admin/src/components/merchants/MerchantDetail.jsx
git rm apps/admin/src/components/TabBar.jsx
git commit -m "refactor(admin): adopt PageHeader+PageTabs in MerchantDetail

Swaps the bespoke TabBar for the canonical PageTabs, moves the inline
<h1>+edit button into PageHeader, and consolidates the scattered
Edit / Cancel / Save / Back buttons into the header actions slot.
Active tab is derived from the URL segment to preserve deep linking.
TabBar.jsx is deleted โ€” MerchantDetail was its only consumer."

Task 5: Full validation sweep โ€‹

No code changes in this task โ€” just validation and the final PR preparation.

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

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

Expected: all checks pass (lint, format, tests, admin build, audit). Admin's validate script is vite build, so this catches any type or import errors.

If anything fails, fix inline and commit the fix with an amending message like fix(admin): correct missing import after page-pattern refactor.

  • [ ] Step 2: Role-gating manual test (merchant-only user)

This can't be automated easily (requires a merchant-role Firebase session). If a merchant-role test account is available:

  1. Sign out of admin, sign back in as the merchant-role user.
  2. Sidebar should show only the "Merchants" entry (plus whatever else is visibleForRoles: ['merchant']).
  3. /merchants/overview shows title "Merchants" + subtitle "Manage your venue".
  4. Tabs show only Overview + Offers. No "All Merchants" tab. No [+ Create Merchant] button.
  5. Manually navigate to /merchants/all โ€” should be redirected to /merchants/overview by the existing merchantOnly effect (AdminDashboard.jsx:163-169).
  6. Manually navigate to /merchants/create โ€” same redirect.

If no merchant-role account is available, flag this for the PR reviewer to test.

  • [ ] Step 3: Cross-page regression smoke test

Confirm the non-merchants pages aren't affected by sidebar/routing changes:

  1. /users โ€” still loads UserManagement. Sidebar "Users" still active.
  2. /billing โ€” still loads Billing.
  3. /system โ€” still loads SystemHealth.
  4. /docs/editor โ€” still loads DocsEditor with active sidebar group auto-expanded.
  5. /api/overview โ€” still loads ApiOverview.
  6. /analytics/overview โ€” still loads AnalyticsOverview.
  7. /config/overview โ€” still loads ConfigOverview.
  • [ ] Step 4: Verify DashboardHome change persists

Navigate to / โ€” all 10 feature cards render Lucide icons. Merchant Management card is clickable and routes to /merchants/overview.

  • [ ] Step 5: Push branch and open PR
bash
git push -u origin HEAD
gh pr create --base dev --title "Merchants page patterns + Dashboard Lucide icons" --body "$(cat <<'EOF'
## Summary
- Brings the Merchants section (list pages + detail page) into compliance with ADMIN_PAGE_PATTERNS.md
- Introduces `MerchantsListLayout` as a layout route wrapping `/merchants/{overview,offers,all,create}`
- Swaps `MerchantDetail`'s bespoke `TabBar` for the canonical `PageTabs`; deletes `TabBar.jsx`
- Consolidates scattered action buttons in MerchantDetail into the `PageHeader` actions slot
- Replaces the 10 emoji feature-card icons on `DashboardHome` with matching Lucide icons
- Collapses the merchants sidebar entry from a 4-subitem expandable to a single entry

See [spec](docs/superpowers/specs/2026-04-24-merchants-page-patterns-design.md) and [plan](docs/superpowers/plans/2026-04-24-merchants-page-patterns.md).

## Test plan
- [x] Unit tests for MerchantsListLayout (6 tests, URL-driven tab state + role filtering)
- [x] Manual: all 5 list routes (`overview`, `offers`, `all`, `create`) + detail tabs (`overview`, `venues`, `notes`, `photos`, `address`)
- [x] Manual: browser back/forward + hard-reload preserve tab state
- [x] Manual: admin sees Create CTA + 3 tabs; merchant sees 2 tabs + no CTA
- [x] Manual: merchant-only user redirected from `/merchants/all` and `/merchants/create` to `/merchants/overview`
- [x] Manual: Merchant Detail Edit / Cancel / Save / Back button flow via header actions
- [x] `npm run validate -- --workspace apps/admin` passes
EOF
)"

Self-review โ€‹

Spec coverage check:

Spec sectionTask
ยง1 DashboardHome emoji โ†’ LucideTask 1
ยง1 feature-card-icon CSS verificationTask 1 step 2 + 4
ยง1 Merchant Management card: coming-soon โ†’ availableTask 1 step 3
ยง2 MerchantsListLayout new componentTask 2
ยง2 URL-driven active tabTask 2 step 3 (location.pathname.split('/')[2])
ยง2 Role-filtered tabsTask 2 (covered by tests)
ยง2 Create button in header, not as tabTask 2 step 3
ยง2 Role safety redirectUnchanged โ€” called out in Task 5 step 2
ยง3 Routing changeTask 3 step 2
ยง4 List pages strip shellTask 3 steps 5โ€“8
ยง5 Sidebar collapseTask 3 step 3
ยง5 Drop expandedSections.merchantsTask 3 step 4
ยง6 MerchantDetail TabBar โ†’ PageTabsTask 4 steps 1, 3
ยง6 Inline header โ†’ PageHeaderTask 4 step 3
ยง6 Venues dynamic countTask 4 step 3 (tabs map)
ยง6 Action consolidationTask 4 step 3 (headerActions)
ยง6 Delete bottom action blockTask 4 step 3 (no longer in new render)
ยง6 โœ… โ†’ CheckCircle in "Changes saved"Task 4 step 3
ยง6 โœ… โ†’ CheckCircle in JustCreatedBannerTask 4 step 4
ยง7 Delete TabBar.jsxTask 4 steps 5โ€“6

No gaps.

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

Type consistency:

  • MerchantsListLayout takes { isAdmin } โ€” used consistently across tests and wiring.
  • TABS/DETAIL_TABS tab shape matches <PageTabs /> props ({ id, label, icon, disabled? }).
  • Function names used (handleEnterEdit, handleCancel, handleSave) match existing names in MerchantDetail.jsx โ€” confirmed against the read of the file.
  • Lucide icons named in the plan (ClipboardList, MapPin, StickyNote, Image, Home, CheckCircle, FileText, Puzzle, BarChart3, Store, Users, Wrench, DollarSign, CheckSquare, Radio, Settings, Plus, ContactRound, Gift) all exist in lucide-react@0.563.0.

No inconsistencies found.

Built with VitePress