Merchants Page Patterns β Design β
Date: 2026-04-24 Scope: apps/admin/ β Lantern admin portal Context: Applies the patterns from ADMIN_PAGE_PATTERNS.md to the Merchants section. Reference implementation is apps/admin/src/components/UserManagement.jsx.
Goal β
Bring the Merchants section (and the dashboard landing page) into alignment with the admin portal page-pattern reference. After this change:
- The
/merchants/*routes use<PageHeader />and<PageTabs />instead of ad-hoc inline headers and<TabBar />. - The sidebar collapses four merchant sub-items into a single entry; the sub-navigation moves into the page as
<PageTabs />. - URL routes are preserved (deep linking still works) and active tab state is derived from the URL rather than local state.
DashboardHomefeature cards use Lucide icons instead of emojis, satisfying the "no emojis in UI chrome" rule.
The rest of the admin pages (SystemHealth, Billing, FeatureTracker, MyAdminProfile, ConfigOverview, ApiOverview, analytics pages, docs/API embeds) are explicitly out of scope for this spec and will be handled as a separate maintenance pass.
Why now β
Merchants is the actively-worked surface. Any further feature work there will either reinforce or diverge from the new pattern β doing the alignment first avoids reverting later. DashboardHome is an inexpensive rider because it's a mechanical emoji-to-Lucide swap and is the single most-visited page in the portal.
Architecture β
1. DashboardHome (inside AdminDashboard.jsx) β
A non-structural change. The DashboardHome function currently renders 10 feature cards, each with an emoji in a .feature-card-icon div. Replace each emoji with the matching Lucide icon (already imported at the top of AdminDashboard.jsx for the sidebar):
| Card | Emoji | Lucide |
|---|---|---|
| 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 |
The "Merchant Management" card currently has the coming-soon modifier and no click handler; flip it to available and wire onClick={() => onNavigate('merchants')} since the routes are live.
CSS verification: .feature-card-icon is currently sized for an emoji glyph. Inspect the rule in apps/admin/src/styles.css and confirm the container still renders sensibly with a 32px Lucide icon inside. If the rule implicitly relied on emoji font sizing, add an explicit svg { width: 32px; height: 32px; } to the class.
2. Merchants list shell β MerchantsListLayout β
New component: apps/admin/src/components/merchants/MerchantsListLayout.jsx
A layout route that wraps the four list-style merchant pages (overview, offers, all, create). Renders:
<PageHeader />with title "Merchants" (or "Create Merchant" when on the create route), optional subtitle, and a[+ Create Merchant]primary button in actions (admins only, hidden on the create view).<PageTabs />with three tabs: Overview Β· Offers Β· All Merchants. Filtered by role: merchants only see Overview + Offers; admins see all three.<Outlet />for the sub-route content, inside.user-management-body.
The active tab is derived from location.pathname.split('/')[2], defaulting to 'overview' if the segment isn't recognized (guards against future non-tab sub-routes). Tab changes call navigate('/merchants/' + id), which preserves browser history.
Create is deliberately not a tab β it's a primary-button CTA in the header, matching the UserManagement.jsx pattern for "Create Admin" / "Create Merchant". When the user is on /merchants/create, the page title switches to "Create Merchant" and the header's Create button is hidden (no duplicate CTA). The tab strip stays visible with no tab active β users in a long form need their primary navigation to remain reachable, and the unhighlighted state communicates "you're in a sub-flow rather than one of the named sections." This is a deliberate divergence from the UserManagement.jsx reference (which hides the tab strip on its create views); the reference should be revisited in a later pass for consistency.
Tab definitions:
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'] },
]Role safety: The existing merchantOnly redirect in AdminDashboard.jsx:163-169 already forces merchant-only users to /merchants/overview if they hit an admin-only path. Unchanged.
3. Routing change in AdminDashboard.jsx β
The <Route path="/merchants"> block gains an element-less layout route that wraps the four list-style children. The :merchantId detail route stays a sibling β it does not inherit the list shell because its header, tabs, and actions are entity-specific.
<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 />}>
{/* nested detail tabs unchanged */}
</Route>
</Route>4. Individual list pages β
MerchantsOverview, MerchantsOffers, MerchantsAll, MerchantsCreate all currently wrap their content in a .user-management-container + .user-management-main + inline <div class="page-header"> shell. The outer layout now owns that shell, so each page strips those wrappers and becomes pure content.
This is the bulk of the mechanical work in the spec. No logic changes β just removing wrappers.
5. Sidebar change in AdminDashboard.jsx β
The merchants nav item collapses from expandable-with-four-subItems to a single non-expandable entry:
// After
{
id: 'merchants',
label: 'Merchants',
icon: <Store size={18} />,
visibleForRoles: ['admin', 'merchant'],
}Also remove the if (location.pathname.startsWith('/merchants')) initial.merchants = true line from the expandedSections initializer β it's dead code after the subItems go away.
6. Merchant Detail shell β MerchantDetail.jsx β
Swap the inline header and <TabBar /> for <PageHeader /> and <PageTabs />. Routing unchanged.
Tab definitions:
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} /> },
]
// Active tab from URL segment: /merchants/:id/<tab>
const activeTab = location.pathname.split('/')[3] || 'overview'The Venues tab keeps its dynamic count: render the label as `Venues (${venues.length})` when mapping DETAIL_TABS into the PageTabs props.
Header actions consolidation. Today the page has Edit on top right, Save/Cancel at the bottom, and "Back to All Merchants" also at the bottom. Consolidate all four into the <PageHeader /> actions slot:
| State | Actions |
|---|---|
| Viewing | [Back to All] (secondary) Β· [Edit] (secondary) |
| Editing | [Cancel] (secondary) Β· [Save changes] (primary) |
Delete the bottom action-buttons block entirely. The "Changes saved" transient banner stays where it is (below the header, above the tabs).
Emoji β Lucide:
β Changes savedβ<CheckCircle size={14} />+ textβ Merchant account created.inJustCreatedBannerβ same swap
7. Delete TabBar.jsx β
Verified: TabBar has exactly one consumer (MerchantDetail.jsx). After the swap it becomes dead code. Delete apps/admin/src/components/TabBar.jsx to prevent future drift between it and <PageTabs />.
Data flow β
No changes. Routing already carries tab state via URL; the new layout components read it via useLocation() and write it via useNavigate(). MerchantDetail continues to own the merchant fetch and edit state and pass them via useOutletContext() to the tab components.
Error handling β
Unchanged behavior:
- Merchant Detail 404 (
error || !data.merchant) still renders the "Merchant not found" path with a "Back to All Merchants" button β that branch is rendered before the layout shell and doesn't route through<PageHeader />. - Tab segment not in
TABS(e.g., a mistyped URL) falls through thesplit('/')[2] || 'overview'default, so no active state is highlighted but the page doesn't crash. A future enhancement couldNavigateto/merchants/overviewon unrecognized segments, but that's out of scope.
Testing plan β
Manual verification (no new automated tests β this is a UI restructure and the existing pages have no test coverage):
- Direct-load each route:
/merchants/overview,/merchants/offers,/merchants/all,/merchants/create,/merchants/:id/overview,/merchants/:id/venues,/merchants/:id/notes,/merchants/:id/photos,/merchants/:id/address. Active tab state must match the URL on initial render. - Tab clicks update the URL (visible in the address bar) and the back button returns to the previous tab.
- Sign in as a merchant-only user. Sidebar shows a single "Merchants" entry. Tabs show only Overview + Offers. Header shows no Create button. Direct navigation to
/merchants/allor/merchants/createredirects to/merchants/overview. - Sign in as an admin. All three tabs visible. Create Merchant button in header routes to
/merchants/create. On the create page, the tab strip is hidden and the title reads "Create Merchant". - Merchant Detail: enter edit mode, confirm action buttons swap to Cancel/Save. Cancel returns to view state; Save persists and shows the "Changes saved" banner.
npm run validatepasses (ESLint, Prettier, tests, production audit).
Files touched β
| File | Change |
|---|---|
apps/admin/src/components/AdminDashboard.jsx | DashboardHome emojiβLucide; sidebar merchants item collapsed; routing wraps list children in <MerchantsListLayout>; drop expandedSections.merchants initializer |
apps/admin/src/components/merchants/MerchantsListLayout.jsx | new |
apps/admin/src/components/merchants/MerchantsOverview.jsx | Strip outer shell + inline page-header |
apps/admin/src/components/merchants/MerchantsOffers.jsx | Strip outer shell + inline page-header |
apps/admin/src/components/merchants/MerchantsAll.jsx | Strip outer shell + inline page-header |
apps/admin/src/components/merchants/MerchantsCreate.jsx | Strip outer shell + inline page-header |
apps/admin/src/components/merchants/MerchantDetail.jsx | <TabBar> β <PageTabs>; inline header β <PageHeader>; consolidate action buttons into header slot; emojiβLucide; delete bottom action-button block |
apps/admin/src/components/TabBar.jsx | delete |
Out of scope β
- Other admin pages (
SystemHealth,Billing,FeatureTracker,MyAdminProfile,ConfigOverview,ApiOverview, analytics pages, docs/API-ref embeds). Separate maintenance pass. CreateMerchantForm.jsxinternal form-section emojis (π§,πͺ,π). Separate cleanup.- Automated tests for the new layout. Could be added later alongside the existing
apps/admin/src/components/__tests__/setup.
Risks β
- URL-to-tab mismatch on future sub-routes. If a future contributor adds a non-tab merchant sub-route (e.g.,
/merchants/settings),activeTabsilently falls back to'overview'in the layout but the new route still renders. Mitigated by the default, but worth flagging in code comments near the TABS definition. .feature-card-iconCSS assumptions. If the class relied on emoji font sizing, the Lucide swap may need an SVG sizing rule. Verify visually in the preview build.- Admin-only Create visibility. The header button is gated by
isAdmin, but if a future change adds a non-admin "Create" variant, the current gating would hide it. Not a concern today; flagged for future reviewers.