Skip to content

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 โ€‹

FileActionResponsibility
apps/admin/src/merchant/tabs/Offers.jsxModify (rewrite container body, keep PageHeader pattern)Mounts the offers area: tab strip + <Routes> for index/create/detail/edit
apps/admin/src/merchant/offers/OffersTabStrip.jsxCreateTab strip with View Offers / Create Offer tabs (uses NavLink)
apps/admin/src/merchant/offers/OfferForm.jsxModify (small)Remove .offer-form-v2__footer block; everything else unchanged
apps/admin/src/merchant/offers/offerSections/SidebarNav.jsxModifyRemove SECTIONS heading + legend; swap glyph status icons for dot markup with aria-labels
apps/admin/src/merchant/offers/OfferDetail.jsxModify (small)Accept offerId and load the offer doc when no offer prop provided (for deep links)
apps/admin/src/shared/styles/styles.cssModifyAdd .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.jsxModifyDrop 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.jsxCreateTests for tab strip rendering, active state, NavLink targets
apps/admin/src/merchant/__tests__/Offers.test.jsxCreateTests 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.jsx

  • Test: 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:

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:

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
bash
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:

css
/* โ”€โ”€ 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:

bash
npm run lint --workspace=apps/admin
  • [ ] Step 3: Commit
bash
git add apps/admin/src/shared/styles/styles.css
git commit -m "style(offer-form): add .offer-tabs / .offer-tab styles"

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:

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):

jsx
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
bash
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:

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:

jsx
// 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
bash
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"

Files:

  • Modify: apps/admin/src/merchant/offers/OfferForm.jsx (delete the .offer-form-v2__footer block)

  • 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:

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:

jsx
          <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:

jsx
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
bash
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.jsx

  • Modify: 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:

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:

jsx
// 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_ICON glyph map is replaced by STATUS_ARIA aria-label map.

  • New StatusDot component renders a <span> with class offer-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
bash
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-soft token 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:

css
  --border-soft: rgba(255, 255, 255, 0.05);

Directly under the existing --border: declaration.

  • [ ] Step 2: Rewrite the .offer-form-v2 block

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:

css
.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:

bash
grep -n "merchant-page-body" apps/admin/src/shared/styles/styles.css

If 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-sidebar block

Replace the existing .offer-sidebar block (currently lines ~12742โ€“12830, ending at the closing } of .offer-sidebar__legend) with:

css
.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):

css
/* 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:

bash
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:

bash
head -40 apps/admin/src/merchant/offers/offerSections/OverviewSection.jsx

If 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:

css
.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:

bash
npm run lint --workspace=apps/admin
npm run test --workspace=apps/admin -- --run

Expected: 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
bash
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>/edit rehydrates 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)

bash
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: getMerchantOffer is referenced in Tasks 3 and 4. If it doesn't already exist in apps/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.

Built with VitePress