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 offdev, or (c) use a git worktree. Default: new branchclaude/admin-merchants-page-patternsoffdev. - 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 โ
| Path | Responsibility |
|---|---|
apps/admin/src/components/merchants/MerchantsListLayout.jsx | Layout 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.jsx | Unit tests for the layout's URLโtab derivation, role filtering, and create-view branch. |
Modified files โ
| Path | Change |
|---|---|
apps/admin/src/components/AdminDashboard.jsx | DashboardHome: 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.jsx | Remove outer .user-management-container/__main + inline .page-header wrapper. Return pure content. |
apps/admin/src/components/merchants/MerchantsOffers.jsx | Same โ remove outer wrapper + inline header. |
apps/admin/src/components/merchants/MerchantsAll.jsx | Same โ remove outer wrapper + inline header. |
apps/admin/src/components/merchants/MerchantsCreate.jsx | Same โ 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 โ
| Path | Reason |
|---|---|
apps/admin/src/components/TabBar.jsx | Sole consumer (MerchantDetail.jsx) swapped to <PageTabs />. |
Task 1: DashboardHome emoji โ Lucide โ
Files:
- Modify:
apps/admin/src/components/AdminDashboard.jsx(theDashboardHomefunction, ~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-iconCSS 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:
<div className="feature-card-icon">๐</div>to:
<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 Editor | FileText |
| Storybook | Puzzle |
| Analytics Dashboard | BarChart3 |
| Merchant Management | Store |
| User Management | Users |
| System Health | Wrench |
| Billing | DollarSign |
| Task Tracker | CheckSquare |
| API Reference | Radio |
| Configuration | Settings |
Also flip the "Merchant Management" card from coming-soon to available and add the click handler. Change:
{/* 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:
{/* 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:
.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.jsxmust 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
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.jsxCreate:
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:
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:
// 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
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.jsxModify:
apps/admin/src/components/merchants/MerchantsOverview.jsxModify:
apps/admin/src/components/merchants/MerchantsOffers.jsxModify:
apps/admin/src/components/merchants/MerchantsAll.jsxModify:
apps/admin/src/components/merchants/MerchantsCreate.jsx[ ] Step 1: Import
MerchantsListLayoutinAdminDashboard.jsx
At the top of apps/admin/src/components/AdminDashboard.jsx, add to the existing merchants imports:
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:
<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:
<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
merchantsitem
Find the merchants nav item in navSections (around line 313). Change from:
{
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:
{
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:
if (location.pathname.startsWith('/merchants')) initial.merchants = true(Leave the other section auto-expansions โ docs, api, config, analytics โ untouched.)
- [ ] Step 5: Strip
MerchantsOverview.jsxouter shell
Change from:
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:
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'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.jsxouter 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.jsxouter shell
Same pattern โ remove .user-management-container/__main + inline page-header. Preserve all content below.
- [ ] Step 8: Strip
MerchantsCreate.jsxouter 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):
- 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. - Click "Offers" tab. URL updates to
/merchants/offers. Offers tab active. - Click "All Merchants" tab. URL updates to
/merchants/all. All Merchants tab active. - Click
[+ Create Merchant]button. URL updates to/merchants/create. Title changes to "Create Merchant". Tab strip hidden. Header button hidden. - Browser back button returns to
/merchants/allwith correct tab state. - Sidebar shows single "Merchants" entry (no expand arrow, no sub-items).
- 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
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.jsxDelete:
apps/admin/src/components/TabBar.jsx[ ] Step 1: Update imports in
MerchantDetail.jsx
Change:
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:
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:
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:
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:
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
tabsarray ([{ to, label }, ...]) that was passed to<TabBar />is deleted โ replaced by theDETAIL_TABS-derived array above.The bottom action-buttons
<div style={...}>block is gone; actions live inheaderActions.Back to All Merchantsshortened toBack to Allto fit the compact header.โโ<CheckCircle />with inline vertical alignment so it aligns with the text baseline.Outlet is wrapped in
.user-management-bodyfor consistent padding with the list pages.[ ] Step 4: Swap
โinJustCreatedBanner
The JustCreatedBanner function at the bottom of MerchantDetail.jsx (around line 250) also uses โ
. Change:
<strong>โ
Merchant account created.</strong>to:
<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
TabBarreferences
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:
- Navigate to
/merchants/all, click any merchant row โ detail page loads. - Header shows merchant name as title, "Merchant account details" as subtitle. Right side shows
[Back to All][Edit]. - PageTabs shows 5 tabs: Overview ยท Venues (n) ยท Notes ยท Photos ยท Address. Overview is active.
- Click "Venues" โ URL updates to
/merchants/:id/venues. Venues tab active. - Hard-reload on
/merchants/:id/notesโ Notes tab active on direct load. - Click Edit โ header actions swap to
[Cancel][Save changes]. Save is primary (amber). - 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]. - Click
[Back to All]โ returns to/merchants/all. - 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
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:
- Sign out of admin, sign back in as the merchant-role user.
- Sidebar should show only the "Merchants" entry (plus whatever else is
visibleForRoles: ['merchant']). /merchants/overviewshows title "Merchants" + subtitle "Manage your venue".- Tabs show only Overview + Offers. No "All Merchants" tab. No
[+ Create Merchant]button. - Manually navigate to
/merchants/allโ should be redirected to/merchants/overviewby the existingmerchantOnlyeffect (AdminDashboard.jsx:163-169). - 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:
/usersโ still loads UserManagement. Sidebar "Users" still active./billingโ still loads Billing./systemโ still loads SystemHealth./docs/editorโ still loads DocsEditor with active sidebar group auto-expanded./api/overviewโ still loads ApiOverview./analytics/overviewโ still loads AnalyticsOverview./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
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 section | Task |
|---|---|
| ยง1 DashboardHome emoji โ Lucide | Task 1 |
| ยง1 feature-card-icon CSS verification | Task 1 step 2 + 4 |
| ยง1 Merchant Management card: coming-soon โ available | Task 1 step 3 |
| ยง2 MerchantsListLayout new component | Task 2 |
| ยง2 URL-driven active tab | Task 2 step 3 (location.pathname.split('/')[2]) |
| ยง2 Role-filtered tabs | Task 2 (covered by tests) |
| ยง2 Create button in header, not as tab | Task 2 step 3 |
| ยง2 Role safety redirect | Unchanged โ called out in Task 5 step 2 |
| ยง3 Routing change | Task 3 step 2 |
| ยง4 List pages strip shell | Task 3 steps 5โ8 |
| ยง5 Sidebar collapse | Task 3 step 3 |
| ยง5 Drop expandedSections.merchants | Task 3 step 4 |
| ยง6 MerchantDetail TabBar โ PageTabs | Task 4 steps 1, 3 |
| ยง6 Inline header โ PageHeader | Task 4 step 3 |
| ยง6 Venues dynamic count | Task 4 step 3 (tabs map) |
| ยง6 Action consolidation | Task 4 step 3 (headerActions) |
| ยง6 Delete bottom action block | Task 4 step 3 (no longer in new render) |
| ยง6 โ โ CheckCircle in "Changes saved" | Task 4 step 3 |
| ยง6 โ โ CheckCircle in JustCreatedBanner | Task 4 step 4 |
| ยง7 Delete TabBar.jsx | Task 4 steps 5โ6 |
No gaps.
Placeholder scan: No TBDs, TODOs, or vague instructions. Every step has exact code or exact commands.
Type consistency:
MerchantsListLayouttakes{ isAdmin }โ used consistently across tests and wiring.TABS/DETAIL_TABStab shape matches<PageTabs />props ({ id, label, icon, disabled? }).- Function names used (
handleEnterEdit,handleCancel,handleSave) match existing names inMerchantDetail.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 inlucide-react@0.563.0.
No inconsistencies found.