Offers Flush Layout & Tab Router 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 floating-card Create Offer form with a flush layout, add a routed View Offers / Create Offer tab strip backed by React Router, and clean up bureaucratic chrome (SECTIONS heading, dot-status legend, duplicate Cancel) โ all without touching the merchant shell or the existing PageHeader topbar.
Architecture: Convert apps/admin/src/merchant/tabs/Offers.jsx from a view-state-machine container into a React Router nested-routes container with <Routes> for index/create/:offerId/:offerId/edit. Add a tab strip component rendered between PageHeader and the route outlet. Restyle OfferForm, SidebarNav, and the input/label classes via CSS-only changes (no JSX changes inside section components). Field treatment shifts to baseline-underline. Section sidebar bleeds (only a hairline right divider). All existing behavior โ section navigation, per-placement sub-pages, preview rail, validation โ is preserved.
Tech Stack: React 18, React Router v6, Vitest + Testing Library React, plain CSS (no preprocessor) using existing CSS custom properties.
Spec reference: docs/planning/specs/2026-05-07-offers-flush-layout-and-tab-router-design.md
File Structure โ
| File | Action | Responsibility |
|---|---|---|
apps/admin/src/merchant/tabs/Offers.jsx | Modify (rewrite container body, keep PageHeader pattern) | Mounts the offers area: tab strip + <Routes> for index/create/detail/edit |
apps/admin/src/merchant/offers/OffersTabStrip.jsx | Create | Tab strip with View Offers / Create Offer tabs (uses NavLink) |
apps/admin/src/merchant/offers/OfferForm.jsx | Modify (small) | Remove .offer-form-v2__footer block; everything else unchanged |
apps/admin/src/merchant/offers/offerSections/SidebarNav.jsx | Modify | Remove SECTIONS heading + legend; swap glyph status icons for dot markup with aria-labels |
apps/admin/src/merchant/offers/OfferDetail.jsx | Modify (small) | Accept offerId and load the offer doc when no offer prop provided (for deep links) |
apps/admin/src/shared/styles/styles.css | Modify | Add .offer-tabs / .offer-tab styles; rewrite .offer-form-v2, .offer-form-v2__main, .offer-sidebar, .offer-sidebar__item* for flush + dot-style; add baseline-underline field classes scoped under .offer-form-v2; delete .offer-form-v2__footer, .offer-sidebar__heading, .offer-sidebar__legend rules |
apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx | Modify | Drop tests asserting footer Cancel; assert SECTIONS heading absence; update sidebar item locator to use new dot markup |
apps/admin/src/merchant/offers/__tests__/OffersTabStrip.test.jsx | Create | Tests for tab strip rendering, active state, NavLink targets |
apps/admin/src/merchant/__tests__/Offers.test.jsx | Create | Tests for nested routes โ index renders OffersList, /create renders OfferForm, /:offerId renders OfferDetail, /:offerId/edit renders OfferForm in edit mode |
Task 1: Tab strip component (TDD) โ
Files:
Create:
apps/admin/src/merchant/offers/OffersTabStrip.jsxTest:
apps/admin/src/merchant/offers/__tests__/OffersTabStrip.test.jsx[ ] Step 1: Write the failing test
Create apps/admin/src/merchant/offers/__tests__/OffersTabStrip.test.jsx:
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import OffersTabStrip from '../OffersTabStrip'
function renderAt(path) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/merchant/:merchantId/offers/*" element={<OffersTabStrip />} />
</Routes>
</MemoryRouter>
)
}
describe('OffersTabStrip', () => {
it('renders View Offers and Create Offer tabs as links', () => {
renderAt('/merchant/m_1/offers')
expect(screen.getByRole('link', { name: 'View Offers' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Create Offer' })).toBeInTheDocument()
})
it('marks View Offers active on the index route', () => {
renderAt('/merchant/m_1/offers')
const viewTab = screen.getByRole('link', { name: 'View Offers' })
expect(viewTab.className).toMatch(/offer-tab--active/)
})
it('marks Create Offer active on /create', () => {
renderAt('/merchant/m_1/offers/create')
const createTab = screen.getByRole('link', { name: 'Create Offer' })
expect(createTab.className).toMatch(/offer-tab--active/)
})
it('keeps View Offers active on /:offerId (detail) deep state', () => {
renderAt('/merchant/m_1/offers/o_42')
const viewTab = screen.getByRole('link', { name: 'View Offers' })
expect(viewTab.className).toMatch(/offer-tab--active/)
})
it('keeps View Offers active on /:offerId/edit deep state', () => {
renderAt('/merchant/m_1/offers/o_42/edit')
const viewTab = screen.getByRole('link', { name: 'View Offers' })
expect(viewTab.className).toMatch(/offer-tab--active/)
})
it('targets the index path when View Offers is clicked', () => {
renderAt('/merchant/m_1/offers/create')
const viewTab = screen.getByRole('link', { name: 'View Offers' })
expect(viewTab.getAttribute('href')).toBe('/merchant/m_1/offers')
})
it('targets /create when Create Offer is clicked', () => {
renderAt('/merchant/m_1/offers')
const createTab = screen.getByRole('link', { name: 'Create Offer' })
expect(createTab.getAttribute('href')).toBe('/merchant/m_1/offers/create')
})
})- [ ] Step 2: Run tests to verify they fail
Run: npm run test -- apps/admin/src/merchant/offers/__tests__/OffersTabStrip.test.jsx Expected: FAIL with "Cannot find module '../OffersTabStrip'"
- [ ] Step 3: Implement the component
Create apps/admin/src/merchant/offers/OffersTabStrip.jsx:
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { NavLink, useParams, useLocation } from 'react-router-dom'
/**
* OffersTabStrip โ routed View Offers / Create Offer tab nav.
*
* Sits between the offers PageHeader and the route content. Active state is
* URL-driven: the index, /:offerId, and /:offerId/edit routes all keep
* "View Offers" active because edits are a sub-flow of viewing.
*/
export default function OffersTabStrip() {
const { merchantId } = useParams()
const location = useLocation()
const base = `/merchant/${merchantId}/offers`
const isCreate = location.pathname === `${base}/create`
const isView = !isCreate
return (
<div className="offer-tabs" role="tablist">
<NavLink
to={base}
end
role="tab"
aria-selected={isView}
className={`offer-tab ${isView ? 'offer-tab--active' : ''}`}
>
View Offers
</NavLink>
<NavLink
to={`${base}/create`}
role="tab"
aria-selected={isCreate}
className={`offer-tab ${isCreate ? 'offer-tab--active' : ''}`}
>
Create Offer
</NavLink>
</div>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test -- apps/admin/src/merchant/offers/__tests__/OffersTabStrip.test.jsx Expected: PASS โ all 7 tests green.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/OffersTabStrip.jsx \
apps/admin/src/merchant/offers/__tests__/OffersTabStrip.test.jsx
git commit -m "feat(offer-form): add routed OffersTabStrip component"Task 2: Tab strip CSS โ
Files:
Modify:
apps/admin/src/shared/styles/styles.css(insert new block immediately above the existing.offer-form-v2 {rule near line 12691)[ ] Step 1: Add the tab strip styles
Find the line .offer-form-v2 { in apps/admin/src/shared/styles/styles.css (currently around line 12691). Insert this block directly above it:
/* โโ Offers tab strip (View Offers / Create Offer) โโโโ */
.offer-tabs {
display: flex;
align-items: stretch;
padding: 0 24px;
border-bottom: 1px solid var(--border);
gap: 0;
}
.offer-tab {
position: relative;
padding: 12px 0;
margin-right: 24px;
font-size: 13px;
font-weight: 500;
color: var(--muted-dark-2);
text-decoration: none;
background: transparent;
border: 0;
cursor: pointer;
font-family: inherit;
transition: color 120ms ease;
}
.offer-tab:hover { color: var(--muted); }
.offer-tab--active {
color: var(--text);
}
.offer-tab--active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
background: var(--accent-500);
}- [ ] Step 2: Visual smoke test
Run: npm run dev --workspace=apps/admin and navigate to /merchant/<id>/offers/create. Expected: At this point the tab strip won't render yet (Task 4 wires it in). Skip visual check; CSS is verified in Task 4. Just confirm npm run lint still passes:
npm run lint --workspace=apps/admin- [ ] Step 3: Commit
git add apps/admin/src/shared/styles/styles.css
git commit -m "style(offer-form): add .offer-tabs / .offer-tab styles"Task 3: Add OfferDetail deep-link support โ
Files:
- Modify:
apps/admin/src/merchant/offers/OfferDetail.jsx
This task lets OfferDetail accept either a hydrated offer prop (existing behavior) or an offerId prop (deep-link support). The existing Offers.jsx passes offer directly, so we add offerId as an alternative. The Offers.jsx rewrite (Task 5) will pass offerId from useParams().
- [ ] Step 1: Read the current OfferDetail component
Run: grep -n "export default" apps/admin/src/merchant/offers/OfferDetail.jsx to confirm prop shape.
Then Read the top ~30 lines of apps/admin/src/merchant/offers/OfferDetail.jsx to see the current props.
- [ ] Step 2: Write a failing test for the deep-link case
Create or extend apps/admin/src/merchant/offers/__tests__/OfferDetail.test.jsx:
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
vi.mock('../../../firebase', () => ({
getMerchantOffer: vi.fn().mockResolvedValue({
id: 'o_1', title: 'Loaded Offer', description: 'desc', status: 'draft',
}),
}))
import OfferDetail from '../OfferDetail'
describe('OfferDetail deep-link load', () => {
beforeEach(() => { vi.clearAllMocks() })
it('loads the offer document when only offerId is provided', async () => {
render(<OfferDetail merchantId="m_1" offerId="o_1" venues={[]} />)
await waitFor(() => expect(screen.getByText('Loaded Offer')).toBeInTheDocument())
})
it('uses the offer prop directly when provided', () => {
const offer = { id: 'o_2', title: 'Direct Offer', description: 'd', status: 'active' }
render(<OfferDetail merchantId="m_1" offer={offer} venues={[]} />)
expect(screen.getByText('Direct Offer')).toBeInTheDocument()
})
})- [ ] Step 3: Verify the test fails
Run: npm run test -- apps/admin/src/merchant/offers/__tests__/OfferDetail.test.jsx Expected: FAIL โ the deep-link test will fail because OfferDetail doesn't currently fetch on offerId.
- [ ] Step 4: Implement deep-link loading in OfferDetail
Open apps/admin/src/merchant/offers/OfferDetail.jsx. At the top of the component, add a useEffect that loads the offer when offer is undefined and offerId is provided. Sketch (adapt to actual current signature):
import { useEffect, useState } from 'react'
import { getMerchantOffer } from '../../firebase'
export default function OfferDetail({ merchantId, offer, offerId, venues, actionError }) {
const [loaded, setLoaded] = useState(offer ?? null)
useEffect(() => {
if (offer || !offerId) return
let cancelled = false
;(async () => {
try {
const doc = await getMerchantOffer(merchantId, offerId)
if (!cancelled) setLoaded(doc)
} catch {
if (!cancelled) setLoaded(null)
}
})()
return () => { cancelled = true }
}, [merchantId, offerId, offer])
const current = offer ?? loaded
if (!current) return <div className="loading">Loading offerโฆ</div>
// ... existing render using `current` instead of `offer`
}If getMerchantOffer doesn't exist, check apps/admin/src/firebase.js for the function that fetches a single offer. If only a list-fetch exists, add a single-doc helper next to it (e.g., based on getMerchantOffers). Keep this minimal โ one function returning a single offer document by id.
- [ ] Step 5: Run tests to verify they pass
Run: npm run test -- apps/admin/src/merchant/offers/__tests__/OfferDetail.test.jsx Expected: PASS โ both tests green.
- [ ] Step 6: Commit
git add apps/admin/src/merchant/offers/OfferDetail.jsx \
apps/admin/src/merchant/offers/__tests__/OfferDetail.test.jsx \
apps/admin/src/firebase.js
git commit -m "feat(offers): support deep-link load in OfferDetail via offerId"Task 4: Convert Offers.jsx to nested routes (TDD) โ
Files:
- Modify:
apps/admin/src/merchant/tabs/Offers.jsx - Test:
apps/admin/src/merchant/__tests__/Offers.test.jsx
This replaces the view state machine with React Router nested routes. The <PageHeader> rendering is preserved (only title/subtitle/actions content changes per route), and OffersTabStrip is rendered directly underneath it on every route.
- [ ] Step 1: Write the failing tests
Create apps/admin/src/merchant/__tests__/Offers.test.jsx:
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
vi.mock('../../firebase', () => ({
getMerchantData: vi.fn().mockResolvedValue({ venues: [{ id: 'v_1', name: 'Cafe' }] }),
getMerchantOffer: vi.fn().mockResolvedValue({
id: 'o_1', title: 'Offer One', description: 'desc', status: 'draft',
}),
deleteMerchantOffer: vi.fn().mockResolvedValue(undefined),
updateMerchantOffer: vi.fn().mockResolvedValue(undefined),
createMerchantOffer: vi.fn().mockResolvedValue({ id: 'new' }),
}))
vi.mock('../offers/OffersList', () => ({
default: () => <div data-testid="offers-list">Offers List</div>,
}))
vi.mock('../offers/OfferForm', () => ({
default: ({ offer }) => <div data-testid="offer-form">{offer ? 'Edit Form' : 'Create Form'}</div>,
}))
vi.mock('../offers/OfferDetail', () => ({
default: ({ offerId, offer }) => (
<div data-testid="offer-detail">Detail {offerId ?? offer?.id}</div>
),
}))
import Offers from '../tabs/Offers'
function renderAt(path) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/merchant/:merchantId/offers/*" element={<Offers />} />
</Routes>
</MemoryRouter>
)
}
describe('Offers nested routes', () => {
beforeEach(() => { vi.clearAllMocks() })
it('renders OffersList on the index route', async () => {
renderAt('/merchant/m_1/offers')
await waitFor(() => expect(screen.getByTestId('offers-list')).toBeInTheDocument())
})
it('renders OfferForm in create mode on /create', async () => {
renderAt('/merchant/m_1/offers/create')
expect(screen.getByTestId('offer-form')).toHaveTextContent('Create Form')
})
it('renders OfferDetail on /:offerId', async () => {
renderAt('/merchant/m_1/offers/o_1')
await waitFor(() => expect(screen.getByTestId('offer-detail')).toHaveTextContent('Detail o_1'))
})
it('renders OfferForm in edit mode on /:offerId/edit', async () => {
renderAt('/merchant/m_1/offers/o_1/edit')
await waitFor(() => expect(screen.getByTestId('offer-form')).toHaveTextContent('Edit Form'))
})
it('renders the OffersTabStrip on every route', async () => {
renderAt('/merchant/m_1/offers/create')
expect(screen.getByRole('link', { name: 'View Offers' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Create Offer' })).toBeInTheDocument()
})
})- [ ] Step 2: Run tests to verify they fail
Run: npm run test -- apps/admin/src/merchant/__tests__/Offers.test.jsx Expected: FAIL โ current Offers.jsx renders OffersList for all paths because it doesn't use nested routes yet.
- [ ] Step 3: Rewrite Offers.jsx to use nested routes
Replace the contents of apps/admin/src/merchant/tabs/Offers.jsx with:
// eslint-disable-next-line unused-imports/no-unused-imports
import React, { useEffect, useState, useCallback } from 'react'
import { Routes, Route, useParams, useNavigate } from 'react-router-dom'
import { Pencil, Trash2, Archive, Send } from 'lucide-react'
import {
getMerchantData,
getMerchantOffer,
deleteMerchantOffer,
updateMerchantOffer,
} from '../../firebase'
import OffersList from '../offers/OffersList'
import OfferForm from '../offers/OfferForm'
import OfferDetail from '../offers/OfferDetail'
import OffersTabStrip from '../offers/OffersTabStrip'
import PageHeader from '../../shared/components/PageHeader'
/**
* Offers tab โ routed container for /merchant/:merchantId/offers/*.
*
* Routes:
* index โ OffersList
* /create โ OfferForm (create mode)
* /:offerId โ OfferDetail
* /:offerId/edit โ OfferForm (edit mode)
*
* Self-loads venues for OfferForm. The OffersTabStrip is rendered for every
* route between the PageHeader and the route content.
*/
export default function Offers() {
const { merchantId } = useParams()
const [venues, setVenues] = useState([])
useEffect(() => {
let cancelled = false
;(async () => {
try {
const result = await getMerchantData(merchantId)
if (!cancelled) setVenues(result.venues || [])
} catch {
// venues remain empty
}
})()
return () => { cancelled = true }
}, [merchantId])
return (
<Routes>
<Route index element={<OffersIndex merchantId={merchantId} venues={venues} />} />
<Route path="create" element={<OffersCreate merchantId={merchantId} venues={venues} />} />
<Route path=":offerId" element={<OffersDetail merchantId={merchantId} venues={venues} />} />
<Route path=":offerId/edit" element={<OffersEdit merchantId={merchantId} venues={venues} />} />
</Routes>
)
}
function OffersIndex({ merchantId, venues }) {
const navigate = useNavigate()
return (
<>
<PageHeader
title="Offers"
subtitle="Manage promotional offers for your venues"
actions={
<button
className="btn btn-primary btn-sm"
onClick={() => navigate(`/merchant/${merchantId}/offers/create`)}
>
Create Offer
</button>
}
/>
<OffersTabStrip />
<div className="merchant-page-body">
<OffersList
merchantId={merchantId}
venues={venues}
onCreateClick={() => navigate(`/merchant/${merchantId}/offers/create`)}
onOfferClick={(offer) =>
navigate(`/merchant/${merchantId}/offers/${offer.id}`)
}
/>
</div>
</>
)
}
function OffersCreate({ merchantId, venues }) {
const navigate = useNavigate()
return (
<>
<PageHeader
title="Create Offer"
subtitle="Define a new promotional offer"
actions={
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/merchant/${merchantId}/offers`)}
>
Cancel
</button>
}
/>
<OffersTabStrip />
<div className="merchant-page-body">
<OfferForm
merchantId={merchantId}
venues={venues}
onSaved={() => navigate(`/merchant/${merchantId}/offers`)}
onCancel={() => navigate(`/merchant/${merchantId}/offers`)}
/>
</div>
</>
)
}
function OffersDetail({ merchantId, venues }) {
const { offerId } = useParams()
const navigate = useNavigate()
const [offer, setOffer] = useState(null)
const [acting, setActing] = useState(false)
const [actionError, setActionError] = useState(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const doc = await getMerchantOffer(merchantId, offerId)
if (!cancelled) setOffer(doc)
} catch (err) {
if (!cancelled) setActionError(err?.message || 'Failed to load offer')
}
})()
return () => { cancelled = true }
}, [merchantId, offerId])
const handleDelete = useCallback(async () => {
if (!offer) return
const confirmMsg =
offer.status === 'draft'
? 'Delete this draft? This cannot be undone.'
: 'Archive this offer? It will be hidden from the default list.'
if (!window.confirm(confirmMsg)) return
try {
setActing(true); setActionError(null)
await deleteMerchantOffer(merchantId, offer.id)
navigate(`/merchant/${merchantId}/offers`)
} catch (err) {
setActionError(err?.message || 'Failed to delete offer')
} finally { setActing(false) }
}, [merchantId, offer, navigate])
const handlePublish = useCallback(async () => {
if (!offer) return
try {
setActing(true); setActionError(null)
await updateMerchantOffer(merchantId, offer.id, { status: 'active' })
navigate(`/merchant/${merchantId}/offers`)
} catch (err) {
setActionError(err?.message || 'Failed to publish offer')
} finally { setActing(false) }
}, [merchantId, offer, navigate])
return (
<>
<PageHeader
title={offer?.title || 'Offer'}
subtitle={offer?.description || ''}
actions={
<>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/merchant/${merchantId}/offers`)}
>
Back
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/merchant/${merchantId}/offers/${offerId}/edit`)}
disabled={acting || !offer}
>
<Pencil size={14} />
Edit
</button>
{offer?.status === 'draft' && (
<button
className="btn btn-primary btn-sm"
onClick={handlePublish}
disabled={acting}
>
<Send size={14} />
{acting ? 'Publishingโฆ' : 'Publish'}
</button>
)}
<button
className="btn btn-danger btn-sm"
onClick={handleDelete}
disabled={acting || !offer}
>
{offer?.status === 'draft' ? <Trash2 size={14} /> : <Archive size={14} />}
{offer?.status === 'draft' ? 'Delete' : 'Archive'}
</button>
</>
}
/>
<OffersTabStrip />
<div className="merchant-page-body">
<OfferDetail
merchantId={merchantId}
offerId={offerId}
offer={offer}
venues={venues}
actionError={actionError}
/>
</div>
</>
)
}
function OffersEdit({ merchantId, venues }) {
const { offerId } = useParams()
const navigate = useNavigate()
const [offer, setOffer] = useState(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const doc = await getMerchantOffer(merchantId, offerId)
if (!cancelled) setOffer(doc)
} catch {
// leave offer null; OfferForm will not be hydrated
}
})()
return () => { cancelled = true }
}, [merchantId, offerId])
return (
<>
<PageHeader
title="Edit Offer"
subtitle={offer?.title || ''}
actions={
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/merchant/${merchantId}/offers/${offerId}`)}
>
Cancel
</button>
}
/>
<OffersTabStrip />
<div className="merchant-page-body">
{offer && (
<OfferForm
merchantId={merchantId}
venues={venues}
offer={offer}
onSaved={() => navigate(`/merchant/${merchantId}/offers`)}
onCancel={() => navigate(`/merchant/${merchantId}/offers/${offerId}`)}
/>
)}
</div>
</>
)
}If getMerchantOffer doesn't exist in apps/admin/src/firebase.js, add it as part of Task 3. (Verify before continuing.)
- [ ] Step 4: Run tests to verify they pass
Run: npm run test -- apps/admin/src/merchant/__tests__/Offers.test.jsx Expected: PASS โ all 5 nested-routes tests green.
- [ ] Step 5: Run the full offer-form test suite to confirm no regressions
Run: npm run test --workspace=apps/admin -- --run Expected: All admin tests pass. Existing OfferForm.test.jsx will still pass because it renders OfferForm directly (not via Offers.jsx).
- [ ] Step 6: Commit
git add apps/admin/src/merchant/tabs/Offers.jsx \
apps/admin/src/merchant/__tests__/Offers.test.jsx
git commit -m "refactor(offers): replace view state-machine with nested routes + tab strip"Task 5: Strip OfferForm footer Cancel button โ
Files:
Modify:
apps/admin/src/merchant/offers/OfferForm.jsx(delete the.offer-form-v2__footerblock)Modify:
apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx[ ] Step 1: Write the failing test
Append to apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx:
describe('OfferForm chrome', () => {
it('does not render a footer Cancel button (PageHeader owns Cancel)', () => {
render(<OfferForm merchantId="m_1" venues={venues} onSaved={() => {}} onCancel={() => {}} />)
// OfferForm should have no Cancel button of its own
expect(screen.queryByRole('button', { name: /^Cancel$/ })).not.toBeInTheDocument()
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test -- apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx Expected: FAIL โ currently renders <button>Cancel</button> in .offer-form-v2__footer.
- [ ] Step 3: Remove the footer block from OfferForm.jsx
In apps/admin/src/merchant/offers/OfferForm.jsx, delete lines containing:
<div className="offer-form-v2__footer">
<button type="button" className="btn btn--secondary" onClick={onCancel}>Cancel</button>
</div>Also remove the now-unused onCancel parameter from the destructuring at the top of the component:
export default function OfferForm({ merchantId, venues, offer, onSaved }) {(Callers in Offers.jsx already removed onCancel since cancel is handled at the route level.)
- [ ] Step 4: Run tests to verify they pass
Run: npm run test -- apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/OfferForm.jsx \
apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx
git commit -m "refactor(offer-form): remove redundant footer Cancel button"Task 6: SidebarNav โ remove SECTIONS heading + legend, swap to dot status (TDD) โ
Files:
Modify:
apps/admin/src/merchant/offers/offerSections/SidebarNav.jsxModify:
apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx[ ] Step 1: Write the failing tests
Append to apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx:
describe('OfferForm sidebar chrome', () => {
it('does not render the SECTIONS heading', () => {
render(<OfferForm merchantId="m_1" venues={venues} onSaved={() => {}} />)
expect(screen.queryByText('SECTIONS')).not.toBeInTheDocument()
})
it('does not render the dot-status legend', () => {
render(<OfferForm merchantId="m_1" venues={venues} onSaved={() => {}} />)
expect(screen.queryByText(/done ยท .* editing ยท .* empty ยท .* error/)).not.toBeInTheDocument()
})
it('renders status dots with aria-labels instead of glyph icons', () => {
render(<OfferForm merchantId="m_1" venues={venues} onSaved={() => {}} />)
// Active section should have a dot with aria-label "active"
const overviewBtn = screen.getByRole('button', { name: /Overview/ })
const activeDot = overviewBtn.querySelector('.offer-sidebar__dot--active')
expect(activeDot).toBeInTheDocument()
expect(activeDot.getAttribute('aria-label')).toBe('active')
})
})- [ ] Step 2: Run tests to verify they fail
Run: npm run test -- apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx Expected: FAIL on all three new tests (SECTIONS still present, legend still present, no dot markup yet).
- [ ] Step 3: Rewrite SidebarNav.jsx
Replace the contents of apps/admin/src/merchant/offers/offerSections/SidebarNav.jsx with:
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { PLACEMENT_LABELS } from '../constants/placements'
const STATUS_ARIA = {
complete: 'complete',
editing: 'active',
empty: 'empty',
error: 'error',
}
const SECTIONS = [
{ key: 'overview', label: 'Overview' },
{ key: 'schedule', label: 'Schedule' },
{ key: 'placements', label: 'Placements' },
{ key: 'targeting', label: 'Targeting' },
{ key: 'review', label: 'Review' },
]
function StatusDot({ status, active }) {
const dotStatus = active ? 'active' : status
return (
<span
className={`offer-sidebar__dot offer-sidebar__dot--${dotStatus}`}
aria-label={STATUS_ARIA[dotStatus] || dotStatus}
/>
)
}
function SidebarItem({ active, status, label, onClick, indent = false, count }) {
const classes = [
'offer-sidebar__item',
active ? 'offer-sidebar__item--active' : '',
indent ? 'offer-sidebar__item--indent' : '',
status ? `offer-sidebar__item--${status}` : '',
].filter(Boolean).join(' ')
return (
<button type="button" className={classes} onClick={onClick}>
<StatusDot status={status} active={active} />
<span className="offer-sidebar__label">{label}</span>
{count != null && <span className="offer-sidebar__count">{count}</span>}
</button>
)
}
export function SidebarNav({ validation, selectedPlacements, activeSection, onSelect }) {
return (
<nav className="offer-sidebar">
{SECTIONS.map((section) => {
const status = section.key === 'review'
? (validation.canPublish ? 'complete' : 'empty')
: validation.sections[section.key]?.status
const isActive = section.key === 'placements'
? activeSection === 'placements'
: activeSection === section.key
return (
<React.Fragment key={section.key}>
<SidebarItem
active={isActive}
status={status}
label={section.label}
count={section.key === 'placements' ? selectedPlacements.length || null : null}
onClick={() => onSelect(section.key)}
/>
{section.key === 'placements' &&
selectedPlacements.map((p) => (
<SidebarItem
key={p}
active={activeSection === `placements.${p}`}
label={PLACEMENT_LABELS[p]}
indent
onClick={() => onSelect(`placements.${p}`)}
/>
))}
</React.Fragment>
)
})}
</nav>
)
}Note the changes vs original:
The
<div className="offer-sidebar__heading">SECTIONS</div>block is gone.The
<div className="offer-sidebar__legend">โฆ</div>block is gone.STATUS_ICONglyph map is replaced bySTATUS_ARIAaria-label map.New
StatusDotcomponent renders a<span>with classoffer-sidebar__dot--<state>and an aria-label.Active state is now reflected on the dot (active wins over status).
[ ] Step 4: Update existing test that locates sidebar buttons by glyph
Inside apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx, the existing test 'toggling a placement adds a sub-page entry to the sidebar' (and similar) finds buttons by visible text including the glyph. Those still work because the label text is intact โ the glyph was a separate <span>. But check by running the suite:
Run: npm run test -- apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx Expected: All tests pass โ the new dot tests pass, and pre-existing tests remain green because they query by label text, not by glyph.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/offerSections/SidebarNav.jsx \
apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx
git commit -m "refactor(offer-form): replace SECTIONS heading + legend with dot-status sidebar"Task 7: Add baseline-underline field styles + flush form/sidebar layout (CSS) โ
Files:
- Modify:
apps/admin/src/shared/styles/styles.css(rewrite existing.offer-form-v2*and.offer-sidebar*blocks; delete.offer-sidebar__heading,.offer-sidebar__legend,.offer-form-v2__footer)
This is a CSS-only task. Visual smoke test at the end.
- [ ] Step 1: Confirm
--border-softtoken exists
Run: grep -n "border-soft\|--border-soft" apps/admin/src/shared/styles/styles.css | head
If no result, the token does not exist. Add it to the :root token block (search for --border: to find the block) by inserting:
--border-soft: rgba(255, 255, 255, 0.05);Directly under the existing --border: declaration.
- [ ] Step 2: Rewrite the
.offer-form-v2block
Find the existing block starting at .offer-form-v2 { (around line 12691 in apps/admin/src/shared/styles/styles.css). Replace from that opening selector through the closing } of .offer-form-v2__footer { โฆ } (currently around line 12739) with:
.offer-form-v2 {
display: flex;
min-height: calc(100vh - 200px); /* fills viewport below PageHeader + tab strip */
background: transparent;
border: 0;
border-radius: 0;
margin: 0;
overflow: visible;
}
.offer-form-v2__main {
flex: 1;
min-width: 0;
background: transparent;
padding: 28px 32px 28px 28px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.offer-form-v2__main--with-preview {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 24px;
padding: 28px 32px 28px 28px;
}
.offer-form-v2__main--with-preview > .preview-rail {
align-self: stretch;
}
.offer-form-v2__content {
display: flex;
flex-direction: column;
min-width: 0;
}
.offer-form-v2__main:not(.offer-form-v2__main--with-preview) .offer-form-v2__content {
flex: 1;
}Note: the .offer-form-v2__footer block is intentionally deleted โ Task 5 removed the corresponding markup.
Also remove the .merchant-page-body wrapper's effect for the offers page if it adds padding that creates the floating gap. Check by searching:
grep -n "merchant-page-body" apps/admin/src/shared/styles/styles.cssIf it has padding: โฆ that creates the gap on the offers page, the cleanest fix is to scope the page body padding off for offers content: add a new class merchant-page-body--flush to the wrapper. But first check the actual padding value โ if it's small (e.g. matches the desired 28px 32px), we leave it alone and the form-internal padding gets reduced. Resolve at implementation time based on what's there.
- [ ] Step 3: Rewrite the
.offer-sidebarblock
Replace the existing .offer-sidebar block (currently lines ~12742โ12830, ending at the closing } of .offer-sidebar__legend) with:
.offer-sidebar {
width: 200px;
flex-shrink: 0;
background: transparent;
border: 0;
border-right: 1px solid var(--border-soft);
padding: 22px 18px 22px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.offer-sidebar__item {
display: flex;
align-items: center;
gap: 12px;
padding: 0;
background: transparent;
border: 0;
width: 100%;
text-align: left;
font: inherit;
font-size: 12.5px;
font-weight: 500;
color: var(--muted-dark-2);
cursor: pointer;
transition: color 120ms ease;
}
.offer-sidebar__item:hover { color: var(--muted); }
.offer-sidebar__item--active { color: var(--text); }
.offer-sidebar__item--error { color: var(--error, #ef4444); }
.offer-sidebar__item--indent {
padding-left: 18px;
font-size: 12px;
}
.offer-sidebar__label { flex: 1; }
.offer-sidebar__count {
margin-left: auto;
font-size: 10px;
color: var(--muted-dark-2);
background: var(--surface);
padding: 1px 6px;
border-radius: 3px;
font-variant-numeric: tabular-nums;
}
/* Dots */
.offer-sidebar__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted-dark-2);
flex-shrink: 0;
display: inline-block;
}
.offer-sidebar__dot--active {
background: var(--accent-500);
width: 7px;
height: 7px;
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15);
}
.offer-sidebar__dot--complete {
background: var(--muted);
}
.offer-sidebar__dot--error {
background: var(--error, #ef4444);
width: 7px;
height: 7px;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.18);
}Note: .offer-sidebar__heading, .offer-sidebar__icon, .offer-sidebar__item--complete .offer-sidebar__icon, .offer-sidebar__item--error .offer-sidebar__icon, and .offer-sidebar__legend rules are intentionally deleted (the corresponding markup is gone).
- [ ] Step 4: Add baseline-underline field styles scoped under .offer-form-v2
Append a new block at the end of the .offer-sidebar* rules (or place near the .offer-form-v2* block):
/* Baseline-underline field treatment, scoped to the offer form */
.offer-form-v2 .field { margin-bottom: 22px; max-width: 640px; }
.offer-form-v2 .field-row {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
}
.offer-form-v2 .field-label {
font-size: 10.5px;
color: var(--muted);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.offer-form-v2 .field-counter {
font-size: 10px;
color: var(--muted-dark-2);
font-variant-numeric: tabular-nums;
}
.offer-form-v2 .field-counter--warn { color: var(--accent-500); }
.offer-form-v2 .field-counter--error { color: var(--error, #ef4444); }
.offer-form-v2 input[type='text'],
.offer-form-v2 input[type='number'],
.offer-form-v2 input[type='date'],
.offer-form-v2 input[type='email'],
.offer-form-v2 select,
.offer-form-v2 textarea {
background: transparent;
border: 0;
border-bottom: 1px solid var(--border);
border-radius: 0;
padding: 6px 0 8px 0;
font-size: 14px;
color: var(--muted-dark-2);
width: 100%;
font-family: inherit;
transition: border-color 120ms ease, color 120ms ease;
}
.offer-form-v2 input:focus,
.offer-form-v2 select:focus,
.offer-form-v2 textarea:focus {
outline: none;
border-bottom-color: var(--accent-500);
color: var(--text);
}
.offer-form-v2 textarea {
resize: vertical;
min-height: 56px;
}
.offer-form-v2 .field--error input,
.offer-form-v2 .field--error select,
.offer-form-v2 .field--error textarea {
border-bottom-color: var(--error, #ef4444);
}
.offer-form-v2 input:disabled,
.offer-form-v2 select:disabled,
.offer-form-v2 textarea:disabled {
border-bottom-color: var(--border-soft);
color: var(--muted-dark-2, #52585f);
}
/* Section content typography */
.offer-form-v2 .section-title {
font-size: 22px;
font-weight: 600;
letter-spacing: -0.01em;
margin-bottom: 6px;
}
.offer-form-v2 .section-helper {
font-size: 12.5px;
color: var(--muted);
margin-bottom: 28px;
max-width: 540px;
line-height: 1.55;
}Note the broad input[type=โฆ] / select / textarea selectors are scoped under .offer-form-v2 so they don't leak to other admin pages.
- [ ] Step 5: Verify section components don't render their own SECTION eyebrow
Quickly grep for the SECTION eyebrow that the spec calls out for removal:
grep -rn '"SECTION"\|className=".*section-eyebrow\|className=".*section-h"' apps/admin/src/merchant/offers/offerSections/If there are eyebrow elements, remove them in this task (they're dead chrome). The Overview heading still comes from <h2> or <h3> tags inside each section component โ those map to .section-title only if the styles target tag names. Check:
head -40 apps/admin/src/merchant/offers/offerSections/OverviewSection.jsxIf section titles are rendered with <h2>/<h3> and not the .section-title class, add a small style rule that targets those tags directly within .offer-form-v2:
.offer-form-v2 h2 { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; margin: 0 0 6px 0; }
.offer-form-v2 h3 { font-size: 14px; font-weight: 600; margin: 16px 0 8px 0; }Adapt to the actual heading levels used. Do NOT modify the section JSX files in this task โ CSS-only.
- [ ] Step 6: Run lint + tests
Run:
npm run lint --workspace=apps/admin
npm run test --workspace=apps/admin -- --runExpected: lint clean, all tests pass. Tests don't directly exercise CSS, so no test failures expected from this task.
- [ ] Step 7: Visual smoke test
Run: npm run dev --workspace=apps/admin. Navigate to /merchant/<id>/offers/create. Verify:
- [ ] Form runs flush against the merchant left nav and the topbar (no surrounding gap on the create-offer page)
- [ ] Tab strip "View Offers" / "Create Offer" sits below the topbar; "Create Offer" tab has the orange underline
- [ ] Section sidebar has only a hairline right divider โ no border on top/left/bottom, no card background
- [ ] No "SECTIONS" header in the sidebar; no
โ done ยท โ editingโฆlegend at the bottom - [ ] Section item active state is dot-only (orange dot with halo + brighter text), no background pill, no left/right edge bar
- [ ] Inputs are baseline-underline (no box border), turn orange on focus
- [ ] No bottom-right Cancel button inside the form (top-right Cancel in PageHeader still works)
- [ ] Click "View Offers" tab โ URL changes to
/merchant/<id>/offers, OffersList renders, "View Offers" tab is now active - [ ] Click on an existing offer in the list โ URL changes to
/merchant/<id>/offers/<offerId>, OfferDetail renders, "View Offers" stays active
If any check fails, fix the corresponding rule and re-verify before committing.
- [ ] Step 8: Commit
git add apps/admin/src/shared/styles/styles.css
git commit -m "style(offer-form): flush layout, baseline-underline fields, dot-status sidebar"Task 8: Final validation pass โ
Files: none modified โ verification only.
- [ ] Step 1: Run the full validate orchestrator
Run: npm run validate Expected: All checks pass (lint, type-check, tests, build). If any fail, fix locally before opening a PR per repo convention (rule #3 in AGENTS.md).
- [ ] Step 2: Check the existing offer-form integration tests
Run: npm run test --workspace=apps/admin -- --run apps/admin/src/merchant/offers Expected: All offer-related tests pass โ OfferForm.test.jsx, OffersTabStrip.test.jsx, Offers.test.jsx, OfferDetail.test.jsx, PreviewRail.test.jsx, MerchantShell.test.jsx.
- [ ] Step 3: Manual smoke walk-through
With npm run dev --workspace=apps/admin running, exercise these flows:
[ ] Land on
/merchant/<id>/offersโ list renders, tab strip shows View Offers active[ ] Click "Create Offer" tab โ URL becomes
/merchant/<id>/offers/create, form renders, tab active swaps[ ] Fill in venue + title + description โ Overview section dot turns from "empty" to "complete"
[ ] Click "Placements" sidebar item โ Placements section renders, can multi-select
[ ] Select Hero Rail โ Hero Rail nested item appears in sidebar, click it โ per-placement page renders, preview rail appears on the right
[ ] Click top-right Cancel โ URL returns to
/merchant/<id>/offers[ ] Click an existing offer โ detail renders, tab strip stays on View Offers
[ ] Click Edit on detail โ URL becomes
/merchant/<id>/offers/<id>/edit, form renders pre-populated, tab strip stays on View Offers[ ] Browser back/forward buttons navigate the history correctly through the routes
[ ] Browser refresh on
/merchant/<id>/offers/<id>/editrehydrates the form (Task 3 / 4 deep-link logic)[ ] Step 4: Push branch (do NOT open PR โ user controls PR timing per AGENTS.md rule #11)
git push origin feat/offer-form-redesign- [ ] Step 5: Final report
Summarize completion to the user: what's done, what's covered by tests, and that the PR is theirs to open whenever ready.
Self-Review Notes โ
- Spec coverage: Each spec section maps to a task โ Tab strip = Task 1+2; Routing = Task 4 (with OfferDetail deep-link enabling = Task 3); Cancel cleanup = Task 5; Sidebar SECTIONS/legend cleanup = Task 6; Flush form + baseline-underline fields = Task 7; Validation = Task 8. Don't-touch zones (merchant shell, PageHeader topbar) are not modified anywhere.
- Hard constraints: No task touches
MerchantShell.jsx,PageHeader.jsx, or merchant shell CSS. Only files listed in the File Structure are modified. - Type/method consistency:
getMerchantOfferis referenced in Tasks 3 and 4. If it doesn't already exist inapps/admin/src/firebase.js, Task 3 Step 4 instructs to add it. Task 4 assumes it exists by then. - No placeholders: All steps have concrete code, exact commands, and expected outputs. The single place that says "adapt to actual current signature" (Task 3 Step 4) is bounded to a known small surface (component prop signature) and includes a sketch.