Create Offer Form Redesign โ 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: Refactor apps/admin/src/merchant/offers/OfferForm.jsx from a single-page form to a sectioned form with sidebar navigation, per-placement sub-tabs (Content / Configs / Design-soon), and a contextual scroll-spy preview rail. Multi-select placements are supported with per-placement content overrides and per-placement overrides of offer-level toggles.
Architecture: State lives in a single offerForm object with offer-level fields plus a placements map keyed by placement type (each with content/configs sub-objects). null content values and 'inherit' config values mean "read through to the offer-level default." Pure helper modules (inheritance, validators) derive the resolved values and validation state from this object. UI is composed of focused section components rendered by a top-level container based on the active section. The preview rail is rendered as a sibling of the section pane and only appears on placement sub-pages.
Tech Stack: React (hooks, functional components), Vitest + @testing-library/react for tests, plain JS validation (no Zod on the frontend), CSS via the existing apps/admin/src/shared/styles/styles.css file using established --bg / --surface-dark / --surface / --surface-elevated / --accent-500 custom properties.
Spec: docs/planning/specs/2026-05-07-create-offer-form-redesign-design.md
File Structure โ
Created:
| File | Responsibility |
|---|---|
apps/admin/src/merchant/offers/constants/placements.js | Placement enum, labels, descriptions, char limits per placement |
apps/admin/src/merchant/offers/state/offerFormState.js | Initial state factory + state-to-payload converter |
apps/admin/src/merchant/offers/state/inheritance.js | Pure helpers that resolve content and config values for a given placement, walking the inheritance chain |
apps/admin/src/merchant/offers/state/validators.js | Per-section validators that return { status, errors } |
apps/admin/src/merchant/offers/components/CharCounter.jsx | Color-shifting character counter |
apps/admin/src/merchant/offers/components/SegmentedControl.jsx | 2- or 3-state segmented control |
apps/admin/src/merchant/offers/components/OverrideRow.jsx | Configs-tab row composing SegmentedControl + inheritance badge |
apps/admin/src/merchant/offers/offerSections/SidebarNav.jsx | Sidebar with status icons + nested placement children |
apps/admin/src/merchant/offers/offerSections/OverviewSection.jsx | Venue + default title/description |
apps/admin/src/merchant/offers/offerSections/PlacementsSection.jsx | Multi-select grid |
apps/admin/src/merchant/offers/offerSections/PerPlacementPage.jsx | Header + sub-tab nav + active sub-tab pane |
apps/admin/src/merchant/offers/offerSections/perPlacement/ContentTab.jsx | Title/description overrides + Hero photo (when applicable) |
apps/admin/src/merchant/offers/offerSections/perPlacement/ConfigsTab.jsx | OverrideRow stack |
apps/admin/src/merchant/offers/offerSections/TargetingSection.jsx | Audience + geofence + per-user limit |
apps/admin/src/merchant/offers/offerSections/ScheduleSection.jsx | Expires + Live Event toggle (offer-level) + while-supplies-last |
apps/admin/src/merchant/offers/offerSections/ReviewSection.jsx | Read-only summary + Publish |
apps/admin/src/merchant/offers/previewRail/usePreviewVariants.js | Hook that derives variant list from offer state |
apps/admin/src/merchant/offers/previewRail/PreviewRail.jsx | Sticky pill nav + scrollable variant container |
Modified:
| File | Change |
|---|---|
apps/admin/src/merchant/offers/OfferForm.jsx | Replaced wholesale: top-level container that owns state and switches between sections |
apps/admin/src/merchant/tabs/Offers.jsx | No prop changes (already passes merchantId, venues, offer, onSaved, onCancel); confirm import resolves |
apps/admin/src/shared/styles/styles.css | New section: .offer-form-v2-* classes for sidebar/sub-tab/preview rail layout |
packages/shared/lib/offerNormalizer.js | Add normalizeOfferToFormV2() and normalizeFormV2ToOffer() for the new shape; keep existing normalizeFormToOffer() for the preview rail to consume |
Deleted:
| File | Reason |
|---|---|
apps/admin/src/merchant/offers/HeroStateSwitcher.jsx | Replaced by scroll-spy pill nav inside PreviewRail |
apps/admin/src/merchant/offers/LiveEventSection.jsx | Live event details moved into ScheduleSection.jsx; the toggle moves to ConfigsTab.jsx as an OverrideRow |
HeroPhotoSection.jsx is kept and rendered inside ContentTab.jsx when the active placement is hero.
Conventions โ
- All commits use Conventional Commits with scope
offer-form:feat(offer-form): ...,test(offer-form): ...,refactor(offer-form): .... - Test commands run from the admin workspace:
npm run test --workspace apps/admin -- <path>. - After each task's tests pass, run
npm run lint --workspace apps/admin(no errors expected). - Imports use the
@/alias forapps/admin/src/paths. - All new components must use CSS custom properties (
--bg,--surface-dark,--surface,--surface-elevated,--accent-500) for colors โ no hex literals.
Task 1: Placement constants module โ
Files:
Create:
apps/admin/src/merchant/offers/constants/placements.jsTest:
apps/admin/src/merchant/offers/constants/__tests__/placements.test.js[ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/constants/__tests__/placements.test.js
import { describe, it, expect } from 'vitest'
import {
PLACEMENTS,
PLACEMENT_LABELS,
PLACEMENT_DESCRIPTIONS,
PLACEMENT_CHAR_LIMITS,
isValidPlacement,
} from '../placements'
describe('placement constants', () => {
it('exports the four supported placement keys', () => {
expect(PLACEMENTS).toEqual(['hero', 'inline', 'chat', 'feed'])
})
it('provides a human label for each placement', () => {
expect(PLACEMENT_LABELS.hero).toBe('Hero Rail')
expect(PLACEMENT_LABELS.inline).toBe('Inline Card')
expect(PLACEMENT_LABELS.chat).toBe('Chat Pill')
expect(PLACEMENT_LABELS.feed).toBe('Feed Insertion')
})
it('provides a short description for each placement', () => {
PLACEMENTS.forEach((p) => {
expect(typeof PLACEMENT_DESCRIPTIONS[p]).toBe('string')
expect(PLACEMENT_DESCRIPTIONS[p].length).toBeGreaterThan(0)
})
})
it('exposes char limits per placement', () => {
expect(PLACEMENT_CHAR_LIMITS.hero).toEqual({ title: 40, description: 80 })
expect(PLACEMENT_CHAR_LIMITS.inline).toEqual({ title: 30, description: 60 })
expect(PLACEMENT_CHAR_LIMITS.chat).toEqual({ title: 20, description: 40 })
expect(PLACEMENT_CHAR_LIMITS.feed).toEqual({ title: 30, description: 60 })
})
it('isValidPlacement returns true only for known keys', () => {
expect(isValidPlacement('hero')).toBe(true)
expect(isValidPlacement('unknown')).toBe(false)
expect(isValidPlacement(undefined)).toBe(false)
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/constants/__tests__/placements.test.js Expected: FAIL โ Cannot find module '../placements'
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/constants/placements.js
export const PLACEMENTS = ['hero', 'inline', 'chat', 'feed']
export const PLACEMENT_LABELS = {
hero: 'Hero Rail',
inline: 'Inline Card',
chat: 'Chat Pill',
feed: 'Feed Insertion',
}
export const PLACEMENT_DESCRIPTIONS = {
hero: 'Top sponsored slot on the user dashboard',
inline: 'Card embedded in the venue feed',
chat: 'Pill suggestion inside the chat assistant',
feed: 'Random insertion in the main offer feed',
}
export const PLACEMENT_CHAR_LIMITS = {
hero: { title: 40, description: 80 },
inline: { title: 30, description: 60 },
chat: { title: 20, description: 40 },
feed: { title: 30, description: 60 },
}
export function isValidPlacement(value) {
return PLACEMENTS.includes(value)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/constants/__tests__/placements.test.js Expected: PASS โ all 5 tests green.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/constants/placements.js \
apps/admin/src/merchant/offers/constants/__tests__/placements.test.js
git commit -m "feat(offer-form): add placement constants module"Task 2: Inheritance helpers (resolve content + configs) โ
Files:
- Create:
apps/admin/src/merchant/offers/state/inheritance.js - Test:
apps/admin/src/merchant/offers/state/__tests__/inheritance.test.js
The functions in this module are pure and central to the override model. Tests prove that null/'inherit' reads through to the offer-level default and that explicit values override.
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/state/__tests__/inheritance.test.js
import { describe, it, expect } from 'vitest'
import {
resolveContent,
resolveConfig,
isOverridden,
} from '../inheritance'
const baseOffer = {
defaultTitle: 'Brunch deal',
defaultDescription: 'Default description',
schedule: { isLiveEvent: true },
placements: {
hero: {
content: { title: null, description: null },
configs: { isLiveEvent: 'inherit' },
},
inline: {
content: { title: 'Tighter copy', description: null },
configs: { isLiveEvent: false },
},
},
}
describe('resolveContent', () => {
it('returns the offer-level default when override is null', () => {
expect(resolveContent(baseOffer, 'hero', 'title')).toBe('Brunch deal')
expect(resolveContent(baseOffer, 'hero', 'description')).toBe('Default description')
})
it('returns the override when it is set', () => {
expect(resolveContent(baseOffer, 'inline', 'title')).toBe('Tighter copy')
expect(resolveContent(baseOffer, 'inline', 'description')).toBe('Default description')
})
it('returns undefined when the placement has no entry', () => {
expect(resolveContent(baseOffer, 'chat', 'title')).toBe('Brunch deal')
})
})
describe('resolveConfig', () => {
it('reads through to the offer-level default when override is "inherit"', () => {
expect(resolveConfig(baseOffer, 'hero', 'isLiveEvent')).toBe(true)
})
it('returns the explicit override when set', () => {
expect(resolveConfig(baseOffer, 'inline', 'isLiveEvent')).toBe(false)
})
})
describe('isOverridden', () => {
it('content field: null means inheriting', () => {
expect(isOverridden(baseOffer, 'hero', 'content', 'title')).toBe(false)
expect(isOverridden(baseOffer, 'inline', 'content', 'title')).toBe(true)
})
it('config field: "inherit" means inheriting', () => {
expect(isOverridden(baseOffer, 'hero', 'configs', 'isLiveEvent')).toBe(false)
expect(isOverridden(baseOffer, 'inline', 'configs', 'isLiveEvent')).toBe(true)
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/state/__tests__/inheritance.test.js Expected: FAIL โ module not found.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/state/inheritance.js
const CONTENT_DEFAULT_KEYS = {
title: 'defaultTitle',
description: 'defaultDescription',
}
const CONFIG_OFFER_PATHS = {
isLiveEvent: ['schedule', 'isLiveEvent'],
}
function readPath(obj, path) {
return path.reduce((acc, key) => (acc == null ? acc : acc[key]), obj)
}
export function resolveContent(offer, placement, field) {
const placementEntry = offer.placements?.[placement]
const overrideValue = placementEntry?.content?.[field]
if (overrideValue != null && overrideValue !== '') return overrideValue
const defaultKey = CONTENT_DEFAULT_KEYS[field]
return defaultKey ? offer[defaultKey] : undefined
}
export function resolveConfig(offer, placement, field) {
const overrideValue = offer.placements?.[placement]?.configs?.[field]
if (overrideValue !== 'inherit' && overrideValue !== undefined) return overrideValue
const path = CONFIG_OFFER_PATHS[field]
return path ? readPath(offer, path) : undefined
}
export function isOverridden(offer, placement, kind, field) {
const value = offer.placements?.[placement]?.[kind]?.[field]
if (kind === 'content') return value != null && value !== ''
if (kind === 'configs') return value !== 'inherit' && value !== undefined
return false
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/state/__tests__/inheritance.test.js Expected: PASS โ all tests green.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/state/inheritance.js \
apps/admin/src/merchant/offers/state/__tests__/inheritance.test.js
git commit -m "feat(offer-form): add inheritance resolvers for content and configs"Task 3: Form state factory + initial-state shape โ
Files:
- Create:
apps/admin/src/merchant/offers/state/offerFormState.js - Test:
apps/admin/src/merchant/offers/state/__tests__/offerFormState.test.js
The factory builds an initial state from either an empty offer (create mode) or an existing offer document (edit mode, including legacy single-placement offers).
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/state/__tests__/offerFormState.test.js
import { describe, it, expect } from 'vitest'
import { buildInitialFormState } from '../offerFormState'
describe('buildInitialFormState', () => {
it('returns an empty form when no offer is provided', () => {
const state = buildInitialFormState()
expect(state.venueId).toBe('')
expect(state.defaultTitle).toBe('')
expect(state.defaultDescription).toBe('')
expect(state.selectedPlacements).toEqual([])
expect(state.placements).toEqual({})
expect(state.schedule.isLiveEvent).toBe(false)
})
it('migrates a legacy single-placement offer into the new shape', () => {
const legacyOffer = {
id: 'offer_1',
venueId: 'v_1',
title: '20% off brunch',
description: 'Friendly copy',
placement: 'hero',
heroPhotoUrl: 'https://example.com/p.jpg',
heroLayout: 'full',
liveEvent: null,
targetAudience: 'nearby',
radius: 1500,
per_user_limit: 1,
budget: 50,
expiresAt: '2026-06-01T00:00:00.000Z',
showDisclaimerWhileSuppliesLast: false,
}
const state = buildInitialFormState(legacyOffer)
expect(state.defaultTitle).toBe('20% off brunch')
expect(state.defaultDescription).toBe('Friendly copy')
expect(state.selectedPlacements).toEqual(['hero'])
expect(state.placements.hero).toBeDefined()
expect(state.placements.hero.content.title).toBeNull()
expect(state.placements.hero.content.description).toBeNull()
expect(state.placements.hero.content.heroPhotoUrl).toBe('https://example.com/p.jpg')
expect(state.placements.hero.content.heroLayout).toBe('full')
expect(state.placements.hero.configs.isLiveEvent).toBe('inherit')
expect(state.targeting.audience).toBe('nearby')
expect(state.schedule.isLiveEvent).toBe(false)
})
it('hydrates a v2-shape offer without re-migrating', () => {
const v2 = {
id: 'offer_2',
venueId: 'v_2',
defaultTitle: 'Already migrated',
defaultDescription: 'Already migrated desc',
selectedPlacements: ['hero', 'inline'],
placements: {
hero: { content: { title: null, description: null }, configs: { isLiveEvent: 'inherit' } },
inline: { content: { title: 'Tight', description: null }, configs: { isLiveEvent: false } },
},
targeting: { audience: 'nearby', geofenceRadius: 1500, geofenceUnit: 'm', perUserLimit: 1 },
schedule: { expiresAt: '2026-06-01T00:00:00.000Z', isLiveEvent: true, liveEventDetails: { genre: 'edm' }, whileSuppliesLast: false },
}
const state = buildInitialFormState(v2)
expect(state.selectedPlacements).toEqual(['hero', 'inline'])
expect(state.placements.inline.content.title).toBe('Tight')
expect(state.placements.inline.configs.isLiveEvent).toBe(false)
expect(state.schedule.liveEventDetails.genre).toBe('edm')
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/state/__tests__/offerFormState.test.js Expected: FAIL โ module not found.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/state/offerFormState.js
import { PLACEMENTS } from '../constants/placements'
function emptyPlacementEntry() {
return {
content: {
title: null,
description: null,
heroPhotoUrl: null,
heroLayout: null,
photoMode: null,
},
configs: {
isLiveEvent: 'inherit',
},
}
}
function isV2Shape(offer) {
return offer && Array.isArray(offer.selectedPlacements) && typeof offer.placements === 'object'
}
function migrateLegacyOffer(legacy) {
const placement = legacy.placement
const placements = {}
if (PLACEMENTS.includes(placement)) {
placements[placement] = {
content: {
title: null,
description: null,
heroPhotoUrl: legacy.heroPhotoUrl ?? null,
heroLayout: legacy.heroLayout ?? null,
photoMode: legacy.photoMode ?? null,
},
configs: { isLiveEvent: 'inherit' },
}
}
return {
id: legacy.id,
venueId: legacy.venueId ?? '',
defaultTitle: legacy.title ?? '',
defaultDescription: legacy.description ?? '',
selectedPlacements: PLACEMENTS.includes(placement) ? [placement] : [],
placements,
targeting: {
audience: legacy.targetAudience ?? '',
geofenceRadius: legacy.radius ?? '',
geofenceUnit: 'm',
perUserLimit: legacy.per_user_limit ?? 1,
},
schedule: {
expiresAt: legacy.expiresAt ?? '',
whileSuppliesLast: legacy.showDisclaimerWhileSuppliesLast ?? false,
isLiveEvent: legacy.liveEvent != null,
liveEventDetails: legacy.liveEvent ?? null,
},
budget: legacy.budget ?? 50,
}
}
function emptyState() {
return {
id: undefined,
venueId: '',
defaultTitle: '',
defaultDescription: '',
selectedPlacements: [],
placements: {},
targeting: { audience: '', geofenceRadius: '', geofenceUnit: 'm', perUserLimit: 1 },
schedule: { expiresAt: '', whileSuppliesLast: false, isLiveEvent: false, liveEventDetails: null },
budget: 50,
}
}
export function buildInitialFormState(offer) {
if (!offer) return emptyState()
if (isV2Shape(offer)) return offer
return migrateLegacyOffer(offer)
}
export { emptyPlacementEntry }- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/state/__tests__/offerFormState.test.js Expected: PASS โ all 3 tests green.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/state/offerFormState.js \
apps/admin/src/merchant/offers/state/__tests__/offerFormState.test.js
git commit -m "feat(offer-form): build initial form state with legacy migration"Task 4: Per-section validators โ
Files:
- Create:
apps/admin/src/merchant/offers/state/validators.js - Test:
apps/admin/src/merchant/offers/state/__tests__/validators.test.js
Validators return one of 'complete', 'editing', 'empty', 'error' per section. The sidebar uses these to render status icons.
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/state/__tests__/validators.test.js
import { describe, it, expect } from 'vitest'
import {
validateOverview,
validatePlacements,
validatePlacementSubpage,
validateTargeting,
validateSchedule,
validateAll,
} from '../validators'
import { buildInitialFormState } from '../offerFormState'
function fullState(overrides = {}) {
const base = buildInitialFormState()
return {
...base,
venueId: 'v_1',
defaultTitle: 'Brunch',
defaultDescription: 'Default copy',
selectedPlacements: ['inline'],
placements: {
inline: { content: { title: null, description: null }, configs: { isLiveEvent: 'inherit' } },
},
targeting: { audience: 'nearby', geofenceRadius: 1500, geofenceUnit: 'm', perUserLimit: 1 },
schedule: { expiresAt: '2026-06-01T00:00:00.000Z', whileSuppliesLast: false, isLiveEvent: false, liveEventDetails: null },
...overrides,
}
}
describe('validateOverview', () => {
it('empty when nothing has been entered', () => {
expect(validateOverview(buildInitialFormState()).status).toBe('empty')
})
it('complete when venue + title + description are set', () => {
expect(validateOverview(fullState()).status).toBe('complete')
})
it('editing when only some fields are set', () => {
expect(validateOverview(fullState({ defaultDescription: '' })).status).toBe('editing')
})
})
describe('validatePlacements', () => {
it('empty when nothing selected', () => {
expect(validatePlacements(fullState({ selectedPlacements: [] })).status).toBe('empty')
})
it('complete when at least one placement is selected and each sub-page is valid', () => {
expect(validatePlacements(fullState()).status).toBe('complete')
})
it('error when a hero placement is missing a photo', () => {
const state = fullState({
selectedPlacements: ['hero'],
placements: {
hero: { content: { title: null, description: null, heroPhotoUrl: null }, configs: { isLiveEvent: 'inherit' } },
},
})
expect(validatePlacements(state).status).toBe('error')
})
})
describe('validatePlacementSubpage', () => {
it('hero requires a photo', () => {
const state = fullState({
selectedPlacements: ['hero'],
placements: {
hero: { content: { title: null, description: null, heroPhotoUrl: null }, configs: { isLiveEvent: 'inherit' } },
},
})
expect(validatePlacementSubpage(state, 'hero').status).toBe('error')
})
it('inline is complete when defaults are inherited', () => {
expect(validatePlacementSubpage(fullState(), 'inline').status).toBe('complete')
})
})
describe('validateTargeting', () => {
it('empty when no audience set', () => {
expect(validateTargeting(buildInitialFormState()).status).toBe('empty')
})
it('complete when audience is set', () => {
expect(validateTargeting(fullState()).status).toBe('complete')
})
})
describe('validateSchedule', () => {
it('empty when no expires set', () => {
expect(validateSchedule(buildInitialFormState()).status).toBe('empty')
})
it('complete when expires is set and live event is off', () => {
expect(validateSchedule(fullState()).status).toBe('complete')
})
it('error when live event is on but no details supplied', () => {
expect(validateSchedule(fullState({
schedule: { expiresAt: '2026-06-01T00:00:00.000Z', whileSuppliesLast: false, isLiveEvent: true, liveEventDetails: null },
})).status).toBe('error')
})
})
describe('validateAll', () => {
it('returns true canPublish when every section is complete', () => {
expect(validateAll(fullState()).canPublish).toBe(true)
})
it('returns false canPublish when any section is not complete', () => {
expect(validateAll(fullState({ defaultTitle: '' })).canPublish).toBe(false)
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/state/__tests__/validators.test.js Expected: FAIL โ module not found.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/state/validators.js
import { resolveContent } from './inheritance'
const STATUS = {
EMPTY: 'empty',
EDITING: 'editing',
COMPLETE: 'complete',
ERROR: 'error',
}
function classify(filled, total, errors) {
if (errors.length > 0) return STATUS.ERROR
if (filled === 0) return STATUS.EMPTY
if (filled < total) return STATUS.EDITING
return STATUS.COMPLETE
}
export function validateOverview(state) {
const fields = [state.venueId, state.defaultTitle, state.defaultDescription]
const filled = fields.filter((v) => v != null && v !== '').length
return { status: classify(filled, fields.length, []), errors: [] }
}
export function validatePlacementSubpage(state, placement) {
const errors = []
if (placement === 'hero') {
const photo = state.placements?.hero?.content?.heroPhotoUrl
if (!photo) errors.push('Hero placement requires a photo.')
}
// resolved title and description must exist (inheritance walks to defaults)
if (!resolveContent(state, placement, 'title')) errors.push('Title is required.')
if (!resolveContent(state, placement, 'description')) errors.push('Description is required.')
return {
status: errors.length ? STATUS.ERROR : STATUS.COMPLETE,
errors,
}
}
export function validatePlacements(state) {
if (!state.selectedPlacements?.length) {
return { status: STATUS.EMPTY, errors: [] }
}
const subpageStatuses = state.selectedPlacements.map((p) => validatePlacementSubpage(state, p))
const anyError = subpageStatuses.some((s) => s.status === STATUS.ERROR)
if (anyError) return { status: STATUS.ERROR, errors: subpageStatuses.flatMap((s) => s.errors) }
return { status: STATUS.COMPLETE, errors: [] }
}
export function validateTargeting(state) {
const fields = [state.targeting?.audience]
const filled = fields.filter((v) => v != null && v !== '').length
return { status: classify(filled, fields.length, []), errors: [] }
}
export function validateSchedule(state) {
const errors = []
const filled = state.schedule?.expiresAt ? 1 : 0
if (state.schedule?.isLiveEvent && !state.schedule?.liveEventDetails) {
errors.push('Live event details are required when Live Event is on.')
}
return { status: classify(filled, 1, errors), errors }
}
export function validateAll(state) {
const overview = validateOverview(state)
const placements = validatePlacements(state)
const targeting = validateTargeting(state)
const schedule = validateSchedule(state)
const sections = { overview, placements, targeting, schedule }
const canPublish = Object.values(sections).every((s) => s.status === STATUS.COMPLETE)
return { sections, canPublish }
}
export { STATUS }- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/state/__tests__/validators.test.js Expected: PASS โ all tests green.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/state/validators.js \
apps/admin/src/merchant/offers/state/__tests__/validators.test.js
git commit -m "feat(offer-form): add per-section validators with rolling status"Task 5: CharCounter component โ
Files:
Create:
apps/admin/src/merchant/offers/components/CharCounter.jsxTest:
apps/admin/src/merchant/offers/components/__tests__/CharCounter.test.jsx[ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/components/__tests__/CharCounter.test.jsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { CharCounter } from '../CharCounter'
describe('CharCounter', () => {
it('renders count over limit', () => {
render(<CharCounter value="hi" limit={40} />)
expect(screen.getByText('2 / 40')).toBeInTheDocument()
})
it('uses ok class below 75%', () => {
const { container } = render(<CharCounter value="abc" limit={40} />)
expect(container.firstChild).toHaveClass('char-counter--ok')
})
it('uses warn class at or above 75%', () => {
const value = 'a'.repeat(30) // 30/40 = 75%
const { container } = render(<CharCounter value={value} limit={40} />)
expect(container.firstChild).toHaveClass('char-counter--warn')
})
it('uses danger class at or above 95%', () => {
const value = 'a'.repeat(38) // 38/40 = 95%
const { container } = render(<CharCounter value={value} limit={40} />)
expect(container.firstChild).toHaveClass('char-counter--danger')
})
it('treats null/undefined value as length 0', () => {
render(<CharCounter value={null} limit={40} />)
expect(screen.getByText('0 / 40')).toBeInTheDocument()
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/components/__tests__/CharCounter.test.jsx Expected: FAIL โ module not found.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/components/CharCounter.jsx
import React from 'react'
export function CharCounter({ value, limit }) {
const length = (value ?? '').length
const ratio = length / limit
let level = 'ok'
if (ratio >= 0.95) level = 'danger'
else if (ratio >= 0.75) level = 'warn'
return (
<span className={`char-counter char-counter--${level}`}>
{length} / {limit}
</span>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/components/__tests__/CharCounter.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/components/CharCounter.jsx \
apps/admin/src/merchant/offers/components/__tests__/CharCounter.test.jsx
git commit -m "feat(offer-form): add CharCounter with threshold color levels"Task 6: SegmentedControl component โ
Files:
Create:
apps/admin/src/merchant/offers/components/SegmentedControl.jsxTest:
apps/admin/src/merchant/offers/components/__tests__/SegmentedControl.test.jsx[ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/components/__tests__/SegmentedControl.test.jsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SegmentedControl } from '../SegmentedControl'
const options = [
{ value: 'inherit', label: 'Inherit' },
{ value: 'on', label: 'On' },
{ value: 'off', label: 'Off' },
]
describe('SegmentedControl', () => {
it('renders all option labels', () => {
render(<SegmentedControl options={options} value="inherit" onChange={() => {}} />)
expect(screen.getByRole('button', { name: 'Inherit' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'On' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Off' })).toBeInTheDocument()
})
it('marks the active option with aria-pressed=true', () => {
render(<SegmentedControl options={options} value="on" onChange={() => {}} />)
expect(screen.getByRole('button', { name: 'On' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Off' })).toHaveAttribute('aria-pressed', 'false')
})
it('calls onChange with the selected value', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<SegmentedControl options={options} value="inherit" onChange={onChange} />)
await user.click(screen.getByRole('button', { name: 'Off' }))
expect(onChange).toHaveBeenCalledWith('off')
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/components/__tests__/SegmentedControl.test.jsx Expected: FAIL โ module not found.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/components/SegmentedControl.jsx
import React from 'react'
export function SegmentedControl({ options, value, onChange }) {
return (
<div className="segmented-control" role="group">
{options.map((opt) => {
const active = opt.value === value
return (
<button
key={opt.value}
type="button"
className={`segmented-control__option${active ? ' is-active' : ''}`}
aria-pressed={active}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
)
})}
</div>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/components/__tests__/SegmentedControl.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/components/SegmentedControl.jsx \
apps/admin/src/merchant/offers/components/__tests__/SegmentedControl.test.jsx
git commit -m "feat(offer-form): add SegmentedControl button group"Task 7: OverrideRow component โ
Files:
- Create:
apps/admin/src/merchant/offers/components/OverrideRow.jsx - Test:
apps/admin/src/merchant/offers/components/__tests__/OverrideRow.test.jsx
This composes SegmentedControl and renders the inheritance/override badge + caption + revert link. Used inside ConfigsTab.jsx.
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/components/__tests__/OverrideRow.test.jsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { OverrideRow } from '../OverrideRow'
describe('OverrideRow (boolean kind)', () => {
const boolProps = {
icon: 'โก',
label: 'Show as live event',
sourceLabel: 'Schedule โ Live Event',
inheritedValue: true,
value: 'inherit',
kind: 'boolean',
onChange: vi.fn(),
}
it('renders the label and inherited badge when not overridden', () => {
render(<OverrideRow {...boolProps} />)
expect(screen.getByText('Show as live event')).toBeInTheDocument()
expect(screen.getByText(/inheriting ยท on/i)).toBeInTheDocument()
})
it('shows three options: Inherit, On, Off', () => {
render(<OverrideRow {...boolProps} />)
expect(screen.getByRole('button', { name: 'Inherit' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'On' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Off' })).toBeInTheDocument()
})
it('renders override badge when value is explicit', () => {
render(<OverrideRow {...boolProps} value={false} />)
expect(screen.getByText(/overridden/i)).toBeInTheDocument()
})
it('calls onChange with new boolean override', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<OverrideRow {...boolProps} onChange={onChange} />)
await user.click(screen.getByRole('button', { name: 'Off' }))
expect(onChange).toHaveBeenCalledWith(false)
})
it('reverts to inherit on revert click', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<OverrideRow {...boolProps} value={false} onChange={onChange} />)
await user.click(screen.getByText(/revert/i))
expect(onChange).toHaveBeenCalledWith('inherit')
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/components/__tests__/OverrideRow.test.jsx Expected: FAIL โ module not found.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/components/OverrideRow.jsx
import React from 'react'
import { SegmentedControl } from './SegmentedControl'
const BOOLEAN_OPTIONS = [
{ value: 'inherit', label: 'Inherit' },
{ value: true, label: 'On' },
{ value: false, label: 'Off' },
]
function describeBool(v) {
if (v === true) return 'on'
if (v === false) return 'off'
return ''
}
export function OverrideRow({
icon,
label,
sourceLabel,
inheritedValue,
value,
kind,
onChange,
}) {
const overridden = value !== 'inherit'
return (
<div className={`override-row${overridden ? ' override-row--overridden' : ''}`}>
<div className="override-row__main">
<div className="override-row__heading">
<span className="override-row__label">
<span className="override-row__icon">{icon}</span>
{label}
</span>
{overridden ? (
<span className="override-row__badge override-row__badge--override">overridden</span>
) : (
<span className="override-row__badge override-row__badge--inherit">
inheriting ยท {describeBool(inheritedValue)}
</span>
)}
</div>
<div className="override-row__caption">
Source: {sourceLabel} = {describeBool(inheritedValue)}
{overridden && (
<>
{' ยท '}
<button
type="button"
className="override-row__revert"
onClick={() => onChange('inherit')}
>
โบ revert
</button>
</>
)}
</div>
</div>
<div className="override-row__control">
{kind === 'boolean' && (
<SegmentedControl options={BOOLEAN_OPTIONS} value={value} onChange={onChange} />
)}
</div>
</div>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/components/__tests__/OverrideRow.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/components/OverrideRow.jsx \
apps/admin/src/merchant/offers/components/__tests__/OverrideRow.test.jsx
git commit -m "feat(offer-form): add OverrideRow for Configs inheritance pattern"Task 8: SidebarNav component โ
Files:
- Create:
apps/admin/src/merchant/offers/offerSections/SidebarNav.jsx - Test:
apps/admin/src/merchant/offers/offerSections/__tests__/SidebarNav.test.jsx
The sidebar gets validation (from validateAll), selectedPlacements, activeSection, and onSelect. Sub-section selection uses dotted keys: placements.hero for the Hero sub-page.
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/offerSections/__tests__/SidebarNav.test.jsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SidebarNav } from '../SidebarNav'
const baseProps = {
validation: {
sections: {
overview: { status: 'complete' },
placements: { status: 'editing' },
targeting: { status: 'empty' },
schedule: { status: 'empty' },
},
canPublish: false,
},
selectedPlacements: ['hero', 'inline'],
activeSection: 'placements.hero',
onSelect: vi.fn(),
}
describe('SidebarNav', () => {
it('renders all 5 top-level sections', () => {
render(<SidebarNav {...baseProps} />)
expect(screen.getByRole('button', { name: /Overview/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Placements/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Targeting/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Schedule/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Review/ })).toBeInTheDocument()
})
it('renders selected placements as nested items under Placements', () => {
render(<SidebarNav {...baseProps} />)
expect(screen.getByRole('button', { name: /Hero Rail/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Inline Card/ })).toBeInTheDocument()
})
it('marks the active section', () => {
render(<SidebarNav {...baseProps} />)
const heroBtn = screen.getByRole('button', { name: /Hero Rail/ })
expect(heroBtn).toHaveClass('sidebar-nav__item--active')
})
it('calls onSelect with the section key', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(<SidebarNav {...baseProps} onSelect={onSelect} />)
await user.click(screen.getByRole('button', { name: /Targeting/ }))
expect(onSelect).toHaveBeenCalledWith('targeting')
})
it('calls onSelect with dotted sub-section key when a placement child is clicked', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(<SidebarNav {...baseProps} onSelect={onSelect} />)
await user.click(screen.getByRole('button', { name: /Inline Card/ }))
expect(onSelect).toHaveBeenCalledWith('placements.inline')
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/SidebarNav.test.jsx Expected: FAIL.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/offerSections/SidebarNav.jsx
import React from 'react'
import { PLACEMENT_LABELS } from '../constants/placements'
const STATUS_ICON = {
complete: 'โ',
editing: 'โ',
empty: 'โ',
error: '!',
}
const SECTIONS = [
{ key: 'overview', label: 'Overview' },
{ key: 'placements', label: 'Placements' },
{ key: 'targeting', label: 'Targeting' },
{ key: 'schedule', label: 'Schedule' },
{ key: 'review', label: 'Review' },
]
function SidebarItem({ active, status, label, onClick, indent = false, count }) {
const classes = [
'sidebar-nav__item',
active ? 'sidebar-nav__item--active' : '',
indent ? 'sidebar-nav__item--indent' : '',
status ? `sidebar-nav__item--${status}` : '',
].filter(Boolean).join(' ')
return (
<button type="button" className={classes} onClick={onClick}>
{status && <span className="sidebar-nav__icon">{STATUS_ICON[status]}</span>}
<span className="sidebar-nav__label">{label}</span>
{count != null && <span className="sidebar-nav__count">{count}</span>}
</button>
)
}
export function SidebarNav({ validation, selectedPlacements, activeSection, onSelect }) {
return (
<nav className="sidebar-nav">
<div className="sidebar-nav__heading">SECTIONS</div>
{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>
)
})}
<div className="sidebar-nav__legend">โ done ยท โ editing ยท โ empty ยท ! error</div>
</nav>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/SidebarNav.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/offerSections/SidebarNav.jsx \
apps/admin/src/merchant/offers/offerSections/__tests__/SidebarNav.test.jsx
git commit -m "feat(offer-form): add SidebarNav with status icons and nested placements"Task 9: OverviewSection component โ
Files:
Create:
apps/admin/src/merchant/offers/offerSections/OverviewSection.jsxTest:
apps/admin/src/merchant/offers/offerSections/__tests__/OverviewSection.test.jsx[ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/offerSections/__tests__/OverviewSection.test.jsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { OverviewSection } from '../OverviewSection'
const venues = [
{ id: 'v_1', name: 'Cafe One' },
{ id: 'v_2', name: 'Cafe Two' },
]
describe('OverviewSection', () => {
it('renders venue, title, and description fields', () => {
render(<OverviewSection state={{ venueId: '', defaultTitle: '', defaultDescription: '' }} venues={venues} onChange={() => {}} />)
expect(screen.getByLabelText(/venue/i)).toBeInTheDocument()
expect(screen.getByLabelText(/default title/i)).toBeInTheDocument()
expect(screen.getByLabelText(/default description/i)).toBeInTheDocument()
})
it('emits patches via onChange when a field updates', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<OverviewSection state={{ venueId: '', defaultTitle: '', defaultDescription: '' }} venues={venues} onChange={onChange} />)
await user.type(screen.getByLabelText(/default title/i), 'Hi')
expect(onChange).toHaveBeenCalled()
const lastCall = onChange.mock.calls.at(-1)[0]
expect(lastCall).toHaveProperty('defaultTitle')
})
it('renders char counter with the most permissive limit (40 / 80)', () => {
render(<OverviewSection state={{ venueId: '', defaultTitle: 'abc', defaultDescription: '' }} venues={venues} onChange={() => {}} />)
expect(screen.getByText('3 / 40')).toBeInTheDocument()
expect(screen.getByText('0 / 80')).toBeInTheDocument()
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/OverviewSection.test.jsx Expected: FAIL.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/offerSections/OverviewSection.jsx
import React from 'react'
import { CharCounter } from '../components/CharCounter'
const TITLE_LIMIT = 40
const DESC_LIMIT = 80
export function OverviewSection({ state, venues, onChange }) {
return (
<div className="offer-section">
<header className="offer-section__header">
<div className="offer-section__eyebrow">SECTION</div>
<h2 className="offer-section__title">Overview</h2>
<p className="offer-section__hint">
These defaults apply to every selected placement. Each placement can override on its sub-page.
</p>
</header>
<div className="form-group">
<label htmlFor="overview-venue" className="form-label">Venue *</label>
<select
id="overview-venue"
className="form-input"
value={state.venueId}
onChange={(e) => onChange({ venueId: e.target.value })}
>
<option value="">Select venue</option>
{venues.map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</div>
<div className="form-group">
<div className="form-group__heading">
<label htmlFor="overview-title" className="form-label">Default Title *</label>
<CharCounter value={state.defaultTitle} limit={TITLE_LIMIT} />
</div>
<input
id="overview-title"
type="text"
className="form-input"
maxLength={TITLE_LIMIT}
value={state.defaultTitle}
onChange={(e) => onChange({ defaultTitle: e.target.value })}
/>
</div>
<div className="form-group">
<div className="form-group__heading">
<label htmlFor="overview-description" className="form-label">Default Description *</label>
<CharCounter value={state.defaultDescription} limit={DESC_LIMIT} />
</div>
<textarea
id="overview-description"
className="form-input"
maxLength={DESC_LIMIT}
rows={3}
value={state.defaultDescription}
onChange={(e) => onChange({ defaultDescription: e.target.value })}
/>
</div>
</div>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/OverviewSection.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/offerSections/OverviewSection.jsx \
apps/admin/src/merchant/offers/offerSections/__tests__/OverviewSection.test.jsx
git commit -m "feat(offer-form): add OverviewSection with venue and default content"Task 10: PlacementsSection (multi-select grid) โ
Files:
- Create:
apps/admin/src/merchant/offers/offerSections/PlacementsSection.jsx - Test:
apps/admin/src/merchant/offers/offerSections/__tests__/PlacementsSection.test.jsx
When a placement is toggled on, the parent must be told to add a default placement entry to state.placements and to add the key to state.selectedPlacements. Toggling off removes both. The onTogglePlacement(placement, isSelected) prop encapsulates this โ the OfferForm container will translate it to the right state patch.
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/offerSections/__tests__/PlacementsSection.test.jsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PlacementsSection } from '../PlacementsSection'
describe('PlacementsSection', () => {
it('renders all four placement cards', () => {
render(<PlacementsSection selectedPlacements={[]} onTogglePlacement={() => {}} />)
expect(screen.getByRole('checkbox', { name: /Hero Rail/ })).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: /Inline Card/ })).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: /Chat Pill/ })).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: /Feed Insertion/ })).toBeInTheDocument()
})
it('marks selected placements as checked', () => {
render(<PlacementsSection selectedPlacements={['hero']} onTogglePlacement={() => {}} />)
expect(screen.getByRole('checkbox', { name: /Hero Rail/ })).toBeChecked()
expect(screen.getByRole('checkbox', { name: /Inline Card/ })).not.toBeChecked()
})
it('calls onTogglePlacement with placement and next-state when toggled', async () => {
const user = userEvent.setup()
const onToggle = vi.fn()
render(<PlacementsSection selectedPlacements={[]} onTogglePlacement={onToggle} />)
await user.click(screen.getByRole('checkbox', { name: /Hero Rail/ }))
expect(onToggle).toHaveBeenCalledWith('hero', true)
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/PlacementsSection.test.jsx Expected: FAIL.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/offerSections/PlacementsSection.jsx
import React from 'react'
import { PLACEMENTS, PLACEMENT_LABELS, PLACEMENT_DESCRIPTIONS } from '../constants/placements'
export function PlacementsSection({ selectedPlacements, onTogglePlacement }) {
return (
<div className="offer-section">
<header className="offer-section__header">
<div className="offer-section__eyebrow">SECTION</div>
<h2 className="offer-section__title">Placements</h2>
<p className="offer-section__hint">
Pick which surfaces show this offer. Each becomes a sub-page in the sidebar where you can override defaults.
</p>
</header>
<div className="placements-grid">
{PLACEMENTS.map((p) => {
const checked = selectedPlacements.includes(p)
return (
<label key={p} className={`placements-card${checked ? ' placements-card--selected' : ''}`}>
<input
type="checkbox"
aria-label={PLACEMENT_LABELS[p]}
checked={checked}
onChange={(e) => onTogglePlacement(p, e.target.checked)}
/>
<div className="placements-card__body">
<div className="placements-card__title">{PLACEMENT_LABELS[p]}</div>
<div className="placements-card__description">{PLACEMENT_DESCRIPTIONS[p]}</div>
</div>
</label>
)
})}
</div>
</div>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/PlacementsSection.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/offerSections/PlacementsSection.jsx \
apps/admin/src/merchant/offers/offerSections/__tests__/PlacementsSection.test.jsx
git commit -m "feat(offer-form): add PlacementsSection multi-select grid"Task 11: ScheduleSection (with offer-level Live Event) โ
Files:
- Create:
apps/admin/src/merchant/offers/offerSections/ScheduleSection.jsx - Test:
apps/admin/src/merchant/offers/offerSections/__tests__/ScheduleSection.test.jsx
This section owns the offer-level isLiveEvent toggle and the conditional liveEventDetails form (genre, start/end, headline, sub-copy). When the toggle is off, the details collapse out.
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/offerSections/__tests__/ScheduleSection.test.jsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ScheduleSection } from '../ScheduleSection'
const baseSchedule = {
expiresAt: '',
whileSuppliesLast: false,
isLiveEvent: false,
liveEventDetails: null,
}
describe('ScheduleSection', () => {
it('renders expires, disclaimer, and live event toggle', () => {
render(<ScheduleSection schedule={baseSchedule} onChange={() => {}} />)
expect(screen.getByLabelText(/expires/i)).toBeInTheDocument()
expect(screen.getByLabelText(/while supplies last/i)).toBeInTheDocument()
expect(screen.getByLabelText(/live event/i)).toBeInTheDocument()
})
it('does not render live event details when toggle is off', () => {
render(<ScheduleSection schedule={baseSchedule} onChange={() => {}} />)
expect(screen.queryByLabelText(/genre/i)).not.toBeInTheDocument()
})
it('renders live event details when toggle is on', () => {
render(<ScheduleSection schedule={{ ...baseSchedule, isLiveEvent: true, liveEventDetails: { genre: '', startsAt: '', endsAt: '', liveHeadline: '', liveSubcopy: '' } }} onChange={() => {}} />)
expect(screen.getByLabelText(/genre/i)).toBeInTheDocument()
})
it('emits a patch when expires changes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ScheduleSection schedule={baseSchedule} onChange={onChange} />)
await user.type(screen.getByLabelText(/expires/i), '2026-06-01')
expect(onChange).toHaveBeenCalled()
})
it('emits patch when live event toggle is flipped on', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ScheduleSection schedule={baseSchedule} onChange={onChange} />)
await user.click(screen.getByLabelText(/live event/i))
const lastCall = onChange.mock.calls.at(-1)[0]
expect(lastCall.schedule.isLiveEvent).toBe(true)
expect(lastCall.schedule.liveEventDetails).toEqual({ genre: '', startsAt: '', endsAt: '', liveHeadline: '', liveSubcopy: '' })
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/ScheduleSection.test.jsx Expected: FAIL.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/offerSections/ScheduleSection.jsx
import React from 'react'
const GENRES = ['disco', 'edm', 'rock', 'jazz', 'hiphop']
const EMPTY_LIVE_EVENT = {
genre: '',
startsAt: '',
endsAt: '',
liveHeadline: '',
liveSubcopy: '',
}
export function ScheduleSection({ schedule, onChange }) {
function patchSchedule(patch) {
onChange({ schedule: { ...schedule, ...patch } })
}
function patchLiveEventDetails(patch) {
onChange({
schedule: { ...schedule, liveEventDetails: { ...(schedule.liveEventDetails ?? EMPTY_LIVE_EVENT), ...patch } },
})
}
function toggleLiveEvent(checked) {
onChange({
schedule: {
...schedule,
isLiveEvent: checked,
liveEventDetails: checked ? (schedule.liveEventDetails ?? EMPTY_LIVE_EVENT) : null,
},
})
}
return (
<div className="offer-section">
<header className="offer-section__header">
<div className="offer-section__eyebrow">SECTION</div>
<h2 className="offer-section__title">Schedule</h2>
</header>
<div className="form-group">
<label htmlFor="schedule-expires" className="form-label">Expires *</label>
<input
id="schedule-expires"
type="date"
className="form-input"
value={schedule.expiresAt ? schedule.expiresAt.slice(0, 10) : ''}
onChange={(e) => patchSchedule({ expiresAt: e.target.value ? new Date(e.target.value).toISOString() : '' })}
/>
</div>
<label className="form-checkbox">
<input
type="checkbox"
checked={schedule.whileSuppliesLast}
onChange={(e) => patchSchedule({ whileSuppliesLast: e.target.checked })}
/>
Show "While supplies last" disclaimer
</label>
<label className="form-checkbox">
<input
type="checkbox"
checked={schedule.isLiveEvent}
onChange={(e) => toggleLiveEvent(e.target.checked)}
aria-label="Live event"
/>
โก This is a live event
</label>
{schedule.isLiveEvent && schedule.liveEventDetails && (
<div className="schedule-live-event">
<div className="form-group">
<label htmlFor="le-genre" className="form-label">Genre</label>
<select
id="le-genre"
className="form-input"
value={schedule.liveEventDetails.genre}
onChange={(e) => patchLiveEventDetails({ genre: e.target.value })}
>
<option value="">Select genre</option>
{GENRES.map((g) => (<option key={g} value={g}>{g}</option>))}
</select>
</div>
<div className="form-group">
<label htmlFor="le-starts" className="form-label">Starts at</label>
<input
id="le-starts"
type="datetime-local"
className="form-input"
value={schedule.liveEventDetails.startsAt}
onChange={(e) => patchLiveEventDetails({ startsAt: e.target.value })}
/>
</div>
<div className="form-group">
<label htmlFor="le-ends" className="form-label">Ends at</label>
<input
id="le-ends"
type="datetime-local"
className="form-input"
value={schedule.liveEventDetails.endsAt}
onChange={(e) => patchLiveEventDetails({ endsAt: e.target.value })}
/>
</div>
<div className="form-group">
<label htmlFor="le-headline" className="form-label">Live headline</label>
<input
id="le-headline"
type="text"
className="form-input"
value={schedule.liveEventDetails.liveHeadline}
onChange={(e) => patchLiveEventDetails({ liveHeadline: e.target.value })}
/>
</div>
<div className="form-group">
<label htmlFor="le-subcopy" className="form-label">Live sub-copy</label>
<input
id="le-subcopy"
type="text"
className="form-input"
value={schedule.liveEventDetails.liveSubcopy}
onChange={(e) => patchLiveEventDetails({ liveSubcopy: e.target.value })}
/>
</div>
</div>
)}
</div>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/ScheduleSection.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/offerSections/ScheduleSection.jsx \
apps/admin/src/merchant/offers/offerSections/__tests__/ScheduleSection.test.jsx
git commit -m "feat(offer-form): move live event to ScheduleSection (offer-level)"Task 12: TargetingSection โ
Files:
Create:
apps/admin/src/merchant/offers/offerSections/TargetingSection.jsxTest:
apps/admin/src/merchant/offers/offerSections/__tests__/TargetingSection.test.jsx[ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/offerSections/__tests__/TargetingSection.test.jsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TargetingSection } from '../TargetingSection'
const baseTargeting = { audience: '', geofenceRadius: '', geofenceUnit: 'm', perUserLimit: 1 }
describe('TargetingSection', () => {
it('renders audience, radius (with unit), and per-user limit', () => {
render(<TargetingSection targeting={baseTargeting} onChange={() => {}} />)
expect(screen.getByLabelText(/target audience/i)).toBeInTheDocument()
expect(screen.getByLabelText(/geofence radius/i)).toBeInTheDocument()
expect(screen.getByLabelText(/per user limit/i)).toBeInTheDocument()
})
it('emits a targeting patch when audience changes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<TargetingSection targeting={baseTargeting} onChange={onChange} />)
await user.selectOptions(screen.getByLabelText(/target audience/i), 'nearby')
const lastCall = onChange.mock.calls.at(-1)[0]
expect(lastCall.targeting.audience).toBe('nearby')
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/TargetingSection.test.jsx Expected: FAIL.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/offerSections/TargetingSection.jsx
import React from 'react'
const AUDIENCES = [
{ value: 'nearby', label: 'Nearby Users' },
{ value: 'active_lantern_holders', label: 'Active Lantern Holders' },
{ value: 'frequent_visitors', label: 'Frequent Visitors' },
{ value: 'new_users', label: 'New Users' },
]
export function TargetingSection({ targeting, onChange }) {
function patch(p) {
onChange({ targeting: { ...targeting, ...p } })
}
return (
<div className="offer-section">
<header className="offer-section__header">
<div className="offer-section__eyebrow">SECTION</div>
<h2 className="offer-section__title">Targeting</h2>
</header>
<div className="form-group">
<label htmlFor="targeting-audience" className="form-label">Target Audience *</label>
<select
id="targeting-audience"
className="form-input"
value={targeting.audience}
onChange={(e) => patch({ audience: e.target.value })}
>
<option value="">Select audience</option>
{AUDIENCES.map((a) => (<option key={a.value} value={a.value}>{a.label}</option>))}
</select>
</div>
<div className="form-group">
<label htmlFor="targeting-radius" className="form-label">Geofence Radius</label>
<div className="form-row">
<input
id="targeting-radius"
type="number"
className="form-input"
placeholder="Max 2500"
value={targeting.geofenceRadius}
onChange={(e) => patch({ geofenceRadius: e.target.value })}
/>
<select
aria-label="Radius unit"
className="form-input form-input--narrow"
value={targeting.geofenceUnit}
onChange={(e) => patch({ geofenceUnit: e.target.value })}
>
<option value="m">m</option>
<option value="ft">ft</option>
<option value="mi">mi</option>
</select>
</div>
</div>
<div className="form-group">
<label htmlFor="targeting-limit" className="form-label">Per User Limit</label>
<input
id="targeting-limit"
type="number"
min={1}
className="form-input"
value={targeting.perUserLimit}
onChange={(e) => patch({ perUserLimit: Number(e.target.value) })}
/>
</div>
</div>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/TargetingSection.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/offerSections/TargetingSection.jsx \
apps/admin/src/merchant/offers/offerSections/__tests__/TargetingSection.test.jsx
git commit -m "feat(offer-form): add TargetingSection"Task 13: ContentTab (per-placement) โ
Files:
- Create:
apps/admin/src/merchant/offers/offerSections/perPlacement/ContentTab.jsx - Test:
apps/admin/src/merchant/offers/offerSections/perPlacement/__tests__/ContentTab.test.jsx
The ContentTab renders title/description override fields with placeholders showing the inherited Overview value, plus the Hero photo block when the placement is hero. It re-uses the existing HeroPhotoSection.jsx.
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/offerSections/perPlacement/__tests__/ContentTab.test.jsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContentTab } from '../ContentTab'
const offerState = {
defaultTitle: 'Default brunch',
defaultDescription: 'Default desc',
placements: {
hero: { content: { title: null, description: null, heroPhotoUrl: null, heroLayout: 'full', photoMode: null }, configs: { isLiveEvent: 'inherit' } },
inline: { content: { title: 'Tight', description: null }, configs: { isLiveEvent: 'inherit' } },
},
}
describe('ContentTab', () => {
it('renders inherited placeholder for title when not overridden', () => {
render(<ContentTab placement="hero" state={offerState} onChange={() => {}} />)
expect(screen.getByLabelText(/title override/i)).toHaveAttribute('placeholder', 'Default brunch')
})
it('shows the override value when set', () => {
render(<ContentTab placement="inline" state={offerState} onChange={() => {}} />)
expect(screen.getByLabelText(/title override/i)).toHaveValue('Tight')
})
it('emits a placement-content patch when the title is edited', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ContentTab placement="hero" state={offerState} onChange={onChange} />)
await user.type(screen.getByLabelText(/title override/i), 'X')
const lastCall = onChange.mock.calls.at(-1)[0]
expect(lastCall.placements.hero.content).toHaveProperty('title')
})
it('renders Hero photo block only when placement is hero', () => {
const { rerender } = render(<ContentTab placement="hero" state={offerState} onChange={() => {}} />)
expect(screen.getByText(/hero photo/i)).toBeInTheDocument()
rerender(<ContentTab placement="inline" state={offerState} onChange={() => {}} />)
expect(screen.queryByText(/hero photo/i)).not.toBeInTheDocument()
})
it('renders Use Overview default link when overridden', () => {
render(<ContentTab placement="inline" state={offerState} onChange={() => {}} />)
expect(screen.getByText(/use overview default/i)).toBeInTheDocument()
})
it('clicking Use Overview default sets title back to null', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ContentTab placement="inline" state={offerState} onChange={onChange} />)
await user.click(screen.getByText(/use overview default/i))
const lastCall = onChange.mock.calls.at(-1)[0]
expect(lastCall.placements.inline.content.title).toBeNull()
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/perPlacement/__tests__/ContentTab.test.jsx Expected: FAIL.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/offerSections/perPlacement/ContentTab.jsx
import React from 'react'
import { CharCounter } from '../../components/CharCounter'
import { PLACEMENT_CHAR_LIMITS } from '../../constants/placements'
import HeroPhotoSection from '../../HeroPhotoSection'
function patchContent(state, placement, patch) {
return {
placements: {
...state.placements,
[placement]: {
...state.placements[placement],
content: { ...state.placements[placement].content, ...patch },
},
},
}
}
export function ContentTab({ placement, state, onChange }) {
const limits = PLACEMENT_CHAR_LIMITS[placement]
const content = state.placements[placement].content
const titleOverridden = content.title != null && content.title !== ''
const descOverridden = content.description != null && content.description !== ''
function setField(field, value) {
onChange(patchContent(state, placement, { [field]: value === '' ? null : value }))
}
return (
<div className="content-tab">
<div className="form-group">
<div className="form-group__heading">
<label htmlFor={`${placement}-title`} className="form-label">
Title override <span className="form-label__hint">ยท optional</span>
</label>
<CharCounter value={content.title ?? ''} limit={limits.title} />
</div>
<input
id={`${placement}-title`}
type="text"
className="form-input"
maxLength={limits.title}
placeholder={state.defaultTitle}
aria-label="Title override"
value={content.title ?? ''}
onChange={(e) => setField('title', e.target.value)}
/>
{titleOverridden && (
<button
type="button"
className="form-link"
onClick={() => onChange(patchContent(state, placement, { title: null }))}
>
โบ Use Overview default
</button>
)}
</div>
<div className="form-group">
<div className="form-group__heading">
<label htmlFor={`${placement}-description`} className="form-label">
Description override <span className="form-label__hint">ยท optional</span>
</label>
<CharCounter value={content.description ?? ''} limit={limits.description} />
</div>
<textarea
id={`${placement}-description`}
className="form-input"
maxLength={limits.description}
rows={3}
placeholder={`Inherits: ${state.defaultDescription}`}
aria-label="Description override"
value={content.description ?? ''}
onChange={(e) => setField('description', e.target.value)}
/>
{descOverridden && (
<button
type="button"
className="form-link"
onClick={() => onChange(patchContent(state, placement, { description: null }))}
>
โบ Use Overview default
</button>
)}
</div>
{placement === 'hero' && (
<div className="content-tab__hero-photo">
<div className="form-label">๐ท Hero Photo</div>
<HeroPhotoSection
heroPhotoUrl={content.heroPhotoUrl ?? ''}
heroLayout={content.heroLayout ?? 'full'}
photoMode={content.photoMode ?? null}
onChange={(p) => onChange(patchContent(state, 'hero', p))}
/>
</div>
)}
</div>
)
}Note:
HeroPhotoSection.jsxmay currently expect a different prop shape. If its existing API doesn't match{ heroPhotoUrl, heroLayout, photoMode, onChange }, adapt the import: read the existing file first and adjust the prop pass-through accordingly. Do not changeHeroPhotoSection.jsxโ adapt the call site.
- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/perPlacement/__tests__/ContentTab.test.jsx Expected: PASS. If the HeroPhotoSection import-time call fails the test, mock it at the test file's top: vi.mock('../../../HeroPhotoSection', () => ({ default: () => <div>Hero Photo</div> })).
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/offerSections/perPlacement/ContentTab.jsx \
apps/admin/src/merchant/offers/offerSections/perPlacement/__tests__/ContentTab.test.jsx
git commit -m "feat(offer-form): add ContentTab with inheritance and hero photo passthrough"Task 14: ConfigsTab (per-placement) โ
Files:
- Create:
apps/admin/src/merchant/offers/offerSections/perPlacement/ConfigsTab.jsx - Test:
apps/admin/src/merchant/offers/offerSections/perPlacement/__tests__/ConfigsTab.test.jsx
Renders an OverrideRow per offer-level toggle. Initially: Show as live event.
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/offerSections/perPlacement/__tests__/ConfigsTab.test.jsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ConfigsTab } from '../ConfigsTab'
const baseState = {
schedule: { isLiveEvent: true },
placements: {
hero: { content: { title: null, description: null }, configs: { isLiveEvent: 'inherit' } },
},
}
describe('ConfigsTab', () => {
it('renders the Show as live event row inheriting from Schedule', () => {
render(<ConfigsTab placement="hero" state={baseState} onChange={() => {}} />)
expect(screen.getByText(/Show as live event/i)).toBeInTheDocument()
expect(screen.getByText(/inheriting ยท on/i)).toBeInTheDocument()
})
it('emits a placement-config patch when overridden', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ConfigsTab placement="hero" state={baseState} onChange={onChange} />)
await user.click(screen.getByRole('button', { name: 'Off' }))
const lastCall = onChange.mock.calls.at(-1)[0]
expect(lastCall.placements.hero.configs.isLiveEvent).toBe(false)
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/perPlacement/__tests__/ConfigsTab.test.jsx Expected: FAIL.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/offerSections/perPlacement/ConfigsTab.jsx
import React from 'react'
import { OverrideRow } from '../../components/OverrideRow'
const CONFIG_ROWS = [
{
key: 'isLiveEvent',
icon: 'โก',
label: 'Show as live event',
sourceLabel: 'Schedule โ Live Event',
kind: 'boolean',
inheritedValuePath: ['schedule', 'isLiveEvent'],
},
]
function readPath(obj, path) {
return path.reduce((acc, key) => (acc == null ? acc : acc[key]), obj)
}
function patchConfig(state, placement, key, value) {
return {
placements: {
...state.placements,
[placement]: {
...state.placements[placement],
configs: { ...state.placements[placement].configs, [key]: value },
},
},
}
}
export function ConfigsTab({ placement, state, onChange }) {
return (
<div className="configs-tab">
<p className="configs-tab__hint">
Override offer-level toggles for this placement only. Defaults inherit from Schedule and other offer-level settings.
</p>
{CONFIG_ROWS.map((row) => {
const inheritedValue = readPath(state, row.inheritedValuePath)
const value = state.placements[placement].configs[row.key]
return (
<OverrideRow
key={row.key}
icon={row.icon}
label={row.label}
sourceLabel={row.sourceLabel}
inheritedValue={inheritedValue}
value={value}
kind={row.kind}
onChange={(next) => onChange(patchConfig(state, placement, row.key, next))}
/>
)
})}
</div>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/perPlacement/__tests__/ConfigsTab.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/offerSections/perPlacement/ConfigsTab.jsx \
apps/admin/src/merchant/offers/offerSections/perPlacement/__tests__/ConfigsTab.test.jsx
git commit -m "feat(offer-form): add ConfigsTab with Live Event override"Task 15: PerPlacementPage (header + sub-tab nav) โ
Files:
- Create:
apps/admin/src/merchant/offers/offerSections/PerPlacementPage.jsx - Test:
apps/admin/src/merchant/offers/offerSections/__tests__/PerPlacementPage.test.jsx
Renders the compact header (placement name + variant badges + close โ) and the sub-tab nav (Content, Configs, Design ยท soon). Owns local sub-tab state.
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/offerSections/__tests__/PerPlacementPage.test.jsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PerPlacementPage } from '../PerPlacementPage'
const stateWithHero = {
defaultTitle: 'T', defaultDescription: 'D',
schedule: { isLiveEvent: true },
placements: {
hero: { content: { title: null, description: null, heroPhotoUrl: null, heroLayout: 'full' }, configs: { isLiveEvent: 'inherit' } },
},
}
describe('PerPlacementPage', () => {
it('renders the compact header with placement label and live-event badge when inherited on', () => {
render(<PerPlacementPage placement="hero" state={stateWithHero} onChange={() => {}} onRemove={() => {}} />)
expect(screen.getByText('Hero Rail')).toBeInTheDocument()
expect(screen.getByText(/live event/i)).toBeInTheDocument()
})
it('renders three sub-tabs with Design disabled', () => {
render(<PerPlacementPage placement="hero" state={stateWithHero} onChange={() => {}} onRemove={() => {}} />)
expect(screen.getByRole('tab', { name: /Content/ })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /Configs/ })).toBeInTheDocument()
const designTab = screen.getByRole('tab', { name: /Design/ })
expect(designTab).toHaveAttribute('aria-disabled', 'true')
})
it('switches between Content and Configs tabs', async () => {
const user = userEvent.setup()
render(<PerPlacementPage placement="hero" state={stateWithHero} onChange={() => {}} onRemove={() => {}} />)
await user.click(screen.getByRole('tab', { name: /Configs/ }))
expect(screen.getByText(/Show as live event/i)).toBeInTheDocument()
})
it('does not switch to Design when clicked', async () => {
const user = userEvent.setup()
render(<PerPlacementPage placement="hero" state={stateWithHero} onChange={() => {}} onRemove={() => {}} />)
await user.click(screen.getByRole('tab', { name: /Design/ }))
// Content tab should still be active โ the title-override input still rendered
expect(screen.getByLabelText(/Title override/i)).toBeInTheDocument()
})
it('calls onRemove when โ is clicked', async () => {
const user = userEvent.setup()
const onRemove = vi.fn()
render(<PerPlacementPage placement="hero" state={stateWithHero} onChange={() => {}} onRemove={onRemove} />)
await user.click(screen.getByRole('button', { name: /remove/i }))
expect(onRemove).toHaveBeenCalledWith('hero')
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/PerPlacementPage.test.jsx Expected: FAIL.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/offerSections/PerPlacementPage.jsx
import React, { useState } from 'react'
import { ContentTab } from './perPlacement/ContentTab'
import { ConfigsTab } from './perPlacement/ConfigsTab'
import { PLACEMENT_LABELS } from '../constants/placements'
import { resolveConfig } from '../state/inheritance'
const TABS = [
{ key: 'content', label: 'Content' },
{ key: 'configs', label: 'Configs' },
{ key: 'design', label: 'Design', disabled: true, badge: 'soon' },
]
function VariantBadges({ state, placement }) {
const isLive = resolveConfig(state, placement, 'isLiveEvent')
return (
<span className="placement-header__badges">
{isLive && <span className="placement-header__badge">โก live event</span>}
</span>
)
}
export function PerPlacementPage({ placement, state, onChange, onRemove }) {
const [activeTab, setActiveTab] = useState('content')
return (
<div className="placement-page">
<header className="placement-header">
<div>
<div className="placement-header__eyebrow">PLACEMENTS</div>
<div className="placement-header__title">
<span>{PLACEMENT_LABELS[placement]}</span>
<VariantBadges state={state} placement={placement} />
</div>
</div>
<button
type="button"
className="placement-header__remove"
aria-label={`Remove ${PLACEMENT_LABELS[placement]}`}
onClick={() => onRemove(placement)}
>
โ
</button>
</header>
<div className="placement-tabs" role="tablist">
{TABS.map((tab) => {
const active = activeTab === tab.key
const onClick = tab.disabled ? undefined : () => setActiveTab(tab.key)
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={active}
aria-disabled={tab.disabled || undefined}
className={[
'placement-tabs__tab',
active ? 'placement-tabs__tab--active' : '',
tab.disabled ? 'placement-tabs__tab--disabled' : '',
].filter(Boolean).join(' ')}
onClick={onClick}
title={tab.disabled ? 'Coming soon โ custom look & feel for this placement' : undefined}
>
{tab.label}
{tab.badge && <span className="placement-tabs__badge">{tab.badge}</span>}
</button>
)
})}
</div>
<div className="placement-tabs__pane">
{activeTab === 'content' && <ContentTab placement={placement} state={state} onChange={onChange} />}
{activeTab === 'configs' && <ConfigsTab placement={placement} state={state} onChange={onChange} />}
</div>
</div>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/PerPlacementPage.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/offerSections/PerPlacementPage.jsx \
apps/admin/src/merchant/offers/offerSections/__tests__/PerPlacementPage.test.jsx
git commit -m "feat(offer-form): add PerPlacementPage with sub-tab nav"Task 16: usePreviewVariants hook โ
Files:
- Create:
apps/admin/src/merchant/offers/previewRail/usePreviewVariants.js - Test:
apps/admin/src/merchant/offers/previewRail/__tests__/usePreviewVariants.test.js
Pure function (no React hook surface other than memoization wrapper) that derives the list of variants for a given placement based on resolved offer state.
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/previewRail/__tests__/usePreviewVariants.test.js
import { describe, it, expect } from 'vitest'
import { computePreviewVariants } from '../usePreviewVariants'
const baseState = {
defaultTitle: 'Brunch',
defaultDescription: 'Default desc',
schedule: { isLiveEvent: false, liveEventDetails: null },
placements: {
hero: { content: { title: null, description: null, heroPhotoUrl: 'p.jpg', heroLayout: 'full' }, configs: { isLiveEvent: 'inherit' } },
},
}
describe('computePreviewVariants', () => {
it('returns only Standard when live event is off', () => {
const variants = computePreviewVariants(baseState, 'hero')
expect(variants.map((v) => v.id)).toEqual(['standard'])
})
it('returns Standard + Day-of + Live when offer-level live event is on and placement inherits', () => {
const state = { ...baseState, schedule: { isLiveEvent: true, liveEventDetails: { genre: 'edm' } } }
const variants = computePreviewVariants(state, 'hero')
expect(variants.map((v) => v.id)).toEqual(['standard', 'dayof', 'live'])
})
it('placement override of off removes day-of and live even if offer-level is on', () => {
const state = {
...baseState,
schedule: { isLiveEvent: true, liveEventDetails: { genre: 'edm' } },
placements: {
hero: { ...baseState.placements.hero, configs: { isLiveEvent: false } },
},
}
const variants = computePreviewVariants(state, 'hero')
expect(variants.map((v) => v.id)).toEqual(['standard'])
})
it('each variant carries a label and the placement key', () => {
const variants = computePreviewVariants(baseState, 'hero')
expect(variants[0]).toMatchObject({ id: 'standard', label: 'Standard', placement: 'hero' })
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/previewRail/__tests__/usePreviewVariants.test.js Expected: FAIL.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/previewRail/usePreviewVariants.js
import { useMemo } from 'react'
import { resolveConfig } from '../state/inheritance'
export function computePreviewVariants(state, placement) {
const variants = [{ id: 'standard', label: 'Standard', placement }]
const liveOn = resolveConfig(state, placement, 'isLiveEvent')
if (liveOn) {
variants.push({ id: 'dayof', label: 'Day-of', placement })
variants.push({ id: 'live', label: 'Live', placement })
}
return variants
}
export function usePreviewVariants(state, placement) {
return useMemo(() => computePreviewVariants(state, placement), [state, placement])
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/previewRail/__tests__/usePreviewVariants.test.js Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/previewRail/usePreviewVariants.js \
apps/admin/src/merchant/offers/previewRail/__tests__/usePreviewVariants.test.js
git commit -m "feat(offer-form): derive preview variants from offer state"Task 17: PreviewRail component โ
Files:
- Create:
apps/admin/src/merchant/offers/previewRail/PreviewRail.jsx - Test:
apps/admin/src/merchant/offers/previewRail/__tests__/PreviewRail.test.jsx
Renders the sticky pill nav, the scrollable variant cards, and uses IntersectionObserver to highlight the active pill while scrolling. Each variant card renders an <AdSlot> with an offer object built via normalizeFormToOffer (treating the placement-resolved content as the source).
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/previewRail/__tests__/PreviewRail.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PreviewRail } from '../PreviewRail'
vi.mock('../../../../components/offers/AdSlot', () => ({
default: ({ offer }) => <div data-testid="ad-slot">slot-{offer.id}-{offer.variantId}</div>,
}))
const stateLive = {
defaultTitle: 'T', defaultDescription: 'D', venueId: 'v_1',
schedule: { isLiveEvent: true, liveEventDetails: { genre: 'edm', startsAt: '', endsAt: '', liveHeadline: '', liveSubcopy: '' } },
placements: {
hero: { content: { title: null, description: null, heroPhotoUrl: 'p.jpg', heroLayout: 'full' }, configs: { isLiveEvent: 'inherit' } },
},
}
beforeEach(() => {
globalThis.IntersectionObserver = class {
observe() {}
disconnect() {}
}
})
describe('PreviewRail', () => {
it('renders one pill per variant', () => {
render(<PreviewRail state={stateLive} placement="hero" venue={{ id: 'v_1' }} />)
expect(screen.getByRole('button', { name: /Standard/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Day-of/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Live/ })).toBeInTheDocument()
})
it('renders one AdSlot per variant', () => {
render(<PreviewRail state={stateLive} placement="hero" venue={{ id: 'v_1' }} />)
expect(screen.getAllByTestId('ad-slot')).toHaveLength(3)
})
it('only renders the standard pill when live event is off', () => {
const off = { ...stateLive, schedule: { isLiveEvent: false, liveEventDetails: null } }
render(<PreviewRail state={off} placement="hero" venue={{ id: 'v_1' }} />)
expect(screen.queryByRole('button', { name: /Day-of/ })).not.toBeInTheDocument()
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/previewRail/__tests__/PreviewRail.test.jsx Expected: FAIL.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/previewRail/PreviewRail.jsx
import React, { useEffect, useRef, useState } from 'react'
import AdSlot from '../../../components/offers/AdSlot'
import { usePreviewVariants } from './usePreviewVariants'
import { resolveContent } from '../state/inheritance'
import { PLACEMENT_LABELS } from '../constants/placements'
function buildVariantOffer(state, placement, variant) {
const content = state.placements[placement].content
return {
id: `preview-${placement}-${variant.id}`,
variantId: variant.id,
locationId: state.venueId,
placement,
headline: resolveContent(state, placement, 'title') || '',
body: resolveContent(state, placement, 'description') || '',
incentive: '',
isPreview: true,
heroPhotoUrl: content.heroPhotoUrl ?? null,
heroLayout: content.heroLayout ?? null,
liveEvent: variant.id === 'standard' ? null : state.schedule.liveEventDetails,
badge: state.schedule.isLiveEvent && variant.id !== 'standard' ? 'live' : null,
}
}
export function PreviewRail({ state, placement, venue }) {
const variants = usePreviewVariants(state, placement)
const [activeId, setActiveId] = useState(variants[0]?.id)
const containerRef = useRef(null)
const variantRefs = useRef({})
useEffect(() => {
const container = containerRef.current
if (!container || !globalThis.IntersectionObserver) return
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]
if (visible) setActiveId(visible.target.dataset.variantId)
},
{ root: container, threshold: [0.4, 0.7] }
)
Object.values(variantRefs.current).forEach((el) => el && observer.observe(el))
return () => observer.disconnect()
}, [variants])
function scrollToVariant(id) {
setActiveId(id)
variantRefs.current[id]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return (
<aside className="preview-rail">
<div className="preview-rail__sticky">
<div className="preview-rail__heading">PREVIEW ยท {PLACEMENT_LABELS[placement].toUpperCase()}</div>
<div className="preview-rail__pills">
{variants.map((v) => (
<button
key={v.id}
type="button"
className={`preview-rail__pill${v.id === activeId ? ' preview-rail__pill--active' : ''}`}
onClick={() => scrollToVariant(v.id)}
>
{v.id === activeId ? 'โ ' : ''}{v.label}
</button>
))}
</div>
</div>
<div className="preview-rail__cards" ref={containerRef}>
{variants.map((v) => (
<div
key={v.id}
className={`preview-rail__card${v.id === activeId ? '' : ' preview-rail__card--dim'}`}
data-variant-id={v.id}
ref={(el) => { variantRefs.current[v.id] = el }}
>
<AdSlot offer={buildVariantOffer(state, placement, v)} venue={venue} isPreview />
</div>
))}
</div>
</aside>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/previewRail/__tests__/PreviewRail.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/previewRail/PreviewRail.jsx \
apps/admin/src/merchant/offers/previewRail/__tests__/PreviewRail.test.jsx
git commit -m "feat(offer-form): add PreviewRail with scroll-spy variant pills"Task 18: ReviewSection โ
Files:
- Create:
apps/admin/src/merchant/offers/offerSections/ReviewSection.jsx - Test:
apps/admin/src/merchant/offers/offerSections/__tests__/ReviewSection.test.jsx
Read-only summary of every section. Publish button is enabled only when validation.canPublish is true (the OfferForm container computes this from validateAll).
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/offerSections/__tests__/ReviewSection.test.jsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ReviewSection } from '../ReviewSection'
const completeState = {
venueId: 'v_1', defaultTitle: 'T', defaultDescription: 'D',
selectedPlacements: ['inline'],
placements: { inline: { content: { title: null, description: null }, configs: { isLiveEvent: 'inherit' } } },
targeting: { audience: 'nearby', geofenceRadius: 1500, geofenceUnit: 'm', perUserLimit: 1 },
schedule: { expiresAt: '2026-06-01T00:00:00.000Z', whileSuppliesLast: false, isLiveEvent: false, liveEventDetails: null },
budget: 50,
}
describe('ReviewSection', () => {
it('renders summary fields', () => {
render(<ReviewSection state={completeState} canPublish={true} onPublish={() => {}} onSaveDraft={() => {}} />)
expect(screen.getByText('T')).toBeInTheDocument()
expect(screen.getByText('Inline Card')).toBeInTheDocument()
})
it('disables Publish when canPublish is false', () => {
render(<ReviewSection state={completeState} canPublish={false} onPublish={() => {}} onSaveDraft={() => {}} />)
expect(screen.getByRole('button', { name: /publish/i })).toBeDisabled()
})
it('calls onPublish when Publish is clicked and enabled', async () => {
const user = userEvent.setup()
const onPublish = vi.fn()
render(<ReviewSection state={completeState} canPublish={true} onPublish={onPublish} onSaveDraft={() => {}} />)
await user.click(screen.getByRole('button', { name: /publish/i }))
expect(onPublish).toHaveBeenCalled()
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/ReviewSection.test.jsx Expected: FAIL.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/offerSections/ReviewSection.jsx
import React from 'react'
import { PLACEMENT_LABELS } from '../constants/placements'
function formatDate(iso) {
if (!iso) return 'โ'
return new Date(iso).toLocaleDateString()
}
export function ReviewSection({ state, canPublish, onSaveDraft, onPublish }) {
return (
<div className="offer-section">
<header className="offer-section__header">
<div className="offer-section__eyebrow">SECTION</div>
<h2 className="offer-section__title">Review & Publish</h2>
</header>
<dl className="review-summary">
<dt>Default title</dt><dd>{state.defaultTitle || 'โ'}</dd>
<dt>Default description</dt><dd>{state.defaultDescription || 'โ'}</dd>
<dt>Placements</dt>
<dd>
{state.selectedPlacements.length
? state.selectedPlacements.map((p) => PLACEMENT_LABELS[p]).join(', ')
: 'โ'}
</dd>
<dt>Audience</dt><dd>{state.targeting.audience || 'โ'}</dd>
<dt>Geofence</dt><dd>{state.targeting.geofenceRadius ? `${state.targeting.geofenceRadius} ${state.targeting.geofenceUnit}` : 'โ'}</dd>
<dt>Per-user limit</dt><dd>{state.targeting.perUserLimit}</dd>
<dt>Expires</dt><dd>{formatDate(state.schedule.expiresAt)}</dd>
<dt>Live event</dt><dd>{state.schedule.isLiveEvent ? 'On' : 'Off'}</dd>
<dt>Budget</dt><dd>${state.budget}</dd>
</dl>
<div className="review-actions">
<button type="button" className="btn btn--secondary" onClick={onSaveDraft}>Save Draft</button>
<button type="button" className="btn btn--primary" disabled={!canPublish} onClick={onPublish}>Publish</button>
</div>
</div>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/offerSections/__tests__/ReviewSection.test.jsx Expected: PASS.
- [ ] Step 5: Commit
git add apps/admin/src/merchant/offers/offerSections/ReviewSection.jsx \
apps/admin/src/merchant/offers/offerSections/__tests__/ReviewSection.test.jsx
git commit -m "feat(offer-form): add ReviewSection with publish gating"Task 19: offerNormalizer โ v2 conversion helpers โ
Files:
- Modify:
packages/shared/lib/offerNormalizer.js - Test:
packages/shared/lib/__tests__/offerNormalizer.v2.test.js(new file)
Add two helpers:
normalizeFormV2ToOfferDocs(state)returns an array of one persisted offer doc per selected placement, with the placement-resolved content baked in. The container uses this when calling the existingcreateMerchantOffer/updateMerchantOfferFirebase functions, which already accept one offer at a time per placement.normalizeFormV2ToPreviewOffer(state, placement, variantId)returns a single preview offer for the rail. (Uses existingnormalizeFormToOfferfor actual rendering โ this is just a convenience wrapper that resolves inheritance first.)
Note: The existing
normalizeFormToOffer(formData, options)is not removed โPreviewRailalready builds offers inline viabuildVariantOffer, so this task only adds the persistence helper.
- [ ] Step 1: Read the existing normalizer
Run: cat packages/shared/lib/offerNormalizer.js Note its exports and existing behavior. Confirm where to add new helpers without breaking the current export surface.
- [ ] Step 2: Write the failing test
// packages/shared/lib/__tests__/offerNormalizer.v2.test.js
import { describe, it, expect } from 'vitest'
import { normalizeFormV2ToOfferDocs } from '../offerNormalizer'
describe('normalizeFormV2ToOfferDocs', () => {
it('emits one doc per selected placement with resolved content', () => {
const state = {
id: 'offer_a',
venueId: 'v_1',
defaultTitle: 'Default title',
defaultDescription: 'Default desc',
selectedPlacements: ['hero', 'inline'],
placements: {
hero: { content: { title: null, description: null, heroPhotoUrl: 'p.jpg', heroLayout: 'full' }, configs: { isLiveEvent: 'inherit' } },
inline: { content: { title: 'Tighter copy', description: null }, configs: { isLiveEvent: false } },
},
targeting: { audience: 'nearby', geofenceRadius: 1500, geofenceUnit: 'm', perUserLimit: 1 },
schedule: { expiresAt: '2026-06-01T00:00:00.000Z', whileSuppliesLast: false, isLiveEvent: true, liveEventDetails: { genre: 'edm' } },
budget: 50,
}
const docs = normalizeFormV2ToOfferDocs(state)
expect(docs).toHaveLength(2)
const hero = docs.find((d) => d.placement === 'hero')
const inline = docs.find((d) => d.placement === 'inline')
expect(hero.title).toBe('Default title') // inherited
expect(hero.heroPhotoUrl).toBe('p.jpg')
expect(hero.liveEvent).toEqual({ genre: 'edm' }) // hero inherits live-event-on
expect(inline.title).toBe('Tighter copy') // overridden
expect(inline.liveEvent).toBeNull() // inline overrode live event off
})
})- [ ] Step 3: Run the test to verify it fails
Run: npm run test --workspace packages/shared -- offerNormalizer.v2 Expected: FAIL โ normalizeFormV2ToOfferDocs is not a function. (If packages/shared doesn't have its own test command, run from repo root with the appropriate workspace flag; check packages/shared/package.json for the test script.)
- [ ] Step 4: Write the implementation
Append to packages/shared/lib/offerNormalizer.js (do not modify existing exports):
// ----- v2 helpers -----
const CONTENT_DEFAULT_KEYS = { title: 'defaultTitle', description: 'defaultDescription' }
const CONFIG_OFFER_PATHS = { isLiveEvent: ['schedule', 'isLiveEvent'] }
function readPath(obj, path) {
return path.reduce((acc, key) => (acc == null ? acc : acc[key]), obj)
}
function resolveContentValue(state, placement, field) {
const value = state.placements?.[placement]?.content?.[field]
if (value != null && value !== '') return value
const key = CONTENT_DEFAULT_KEYS[field]
return key ? state[key] : undefined
}
function resolveConfigValue(state, placement, field) {
const value = state.placements?.[placement]?.configs?.[field]
if (value !== 'inherit' && value !== undefined) return value
const path = CONFIG_OFFER_PATHS[field]
return path ? readPath(state, path) : undefined
}
export function normalizeFormV2ToOfferDocs(state) {
return state.selectedPlacements.map((placement) => {
const content = state.placements[placement].content
const isLive = resolveConfigValue(state, placement, 'isLiveEvent')
return {
id: state.id,
placement,
venueId: state.venueId,
title: resolveContentValue(state, placement, 'title'),
description: resolveContentValue(state, placement, 'description'),
heroPhotoUrl: content.heroPhotoUrl ?? null,
heroLayout: content.heroLayout ?? null,
photoMode: content.photoMode ?? null,
targetAudience: state.targeting.audience,
radius: state.targeting.geofenceRadius || null,
per_user_limit: state.targeting.perUserLimit,
budget: state.budget,
expiresAt: state.schedule.expiresAt,
showDisclaimerWhileSuppliesLast: state.schedule.whileSuppliesLast,
liveEvent: isLive ? state.schedule.liveEventDetails : null,
}
})
}- [ ] Step 5: Run tests to verify they pass
Run: npm run test --workspace packages/shared -- offerNormalizer.v2 Expected: PASS.
- [ ] Step 6: Commit
git add packages/shared/lib/offerNormalizer.js \
packages/shared/lib/__tests__/offerNormalizer.v2.test.js
git commit -m "feat(offer-form): add v2-to-docs converter for per-placement persistence"Task 20: New OfferForm container โ wire it all together โ
Files:
- Modify:
apps/admin/src/merchant/offers/OfferForm.jsx(rewritten) - Test:
apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx
The container owns offer state, dispatches patches into it, computes validation, switches sections, and renders the PreviewRail only when the active section is a placement sub-page.
State management: a single useReducer with merge semantics โ each action is { type: 'patch', patch } where patch is a partial state object the section emits. Two structural actions: togglePlacement(placement, on) and removePlacement(placement) that update both selectedPlacements and placements atomically.
- [ ] Step 1: Write the failing test
// apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import OfferForm from '../OfferForm'
vi.mock('../../../firebase', () => ({
createMerchantOffer: vi.fn().mockResolvedValue({ id: 'new' }),
updateMerchantOffer: vi.fn().mockResolvedValue({ id: 'updated' }),
}))
vi.mock('../../components/offers/AdSlot', () => ({
default: () => <div data-testid="ad-slot" />,
}))
const venues = [{ id: 'v_1', name: 'Cafe' }]
beforeEach(() => {
globalThis.IntersectionObserver = class { observe(){} disconnect(){} }
})
describe('OfferForm container', () => {
it('starts on Overview section', () => {
render(<OfferForm merchantId="m_1" venues={venues} onSaved={() => {}} onCancel={() => {}} />)
expect(screen.getByRole('heading', { name: 'Overview' })).toBeInTheDocument()
})
it('navigates to Placements when sidebar entry is clicked', async () => {
const user = userEvent.setup()
render(<OfferForm merchantId="m_1" venues={venues} onSaved={() => {}} onCancel={() => {}} />)
await user.click(screen.getByRole('button', { name: /Placements/i }))
expect(screen.getByRole('heading', { name: 'Placements' })).toBeInTheDocument()
})
it('toggling a placement adds a sub-page entry to the sidebar', async () => {
const user = userEvent.setup()
render(<OfferForm merchantId="m_1" venues={venues} onSaved={() => {}} onCancel={() => {}} />)
await user.click(screen.getByRole('button', { name: /Placements/i }))
await user.click(screen.getByRole('checkbox', { name: /Hero Rail/ }))
// Hero Rail is now a sidebar item too
const heroSidebarBtn = screen.getAllByRole('button', { name: /Hero Rail/ }).find((b) => b.classList.contains('sidebar-nav__item--indent'))
expect(heroSidebarBtn).toBeDefined()
})
it('renders PreviewRail only on placement sub-pages', async () => {
const user = userEvent.setup()
render(<OfferForm merchantId="m_1" venues={venues} onSaved={() => {}} onCancel={() => {}} />)
expect(screen.queryByText(/PREVIEW ยท /)).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Placements/i }))
await user.click(screen.getByRole('checkbox', { name: /Hero Rail/ }))
const heroSidebarBtn = screen.getAllByRole('button', { name: /Hero Rail/ }).find((b) => b.classList.contains('sidebar-nav__item--indent'))
await user.click(heroSidebarBtn)
expect(screen.getByText(/PREVIEW ยท HERO RAIL/)).toBeInTheDocument()
})
})- [ ] Step 2: Run test to verify it fails
Run: npm run test --workspace apps/admin -- src/merchant/offers/__tests__/OfferForm.test.jsx Expected: FAIL โ current OfferForm.jsx has the old shape; tests will not match.
- [ ] Step 3: Write the implementation
// apps/admin/src/merchant/offers/OfferForm.jsx
import React, { useMemo, useReducer, useState } from 'react'
import { buildInitialFormState, emptyPlacementEntry } from './state/offerFormState'
import { validateAll } from './state/validators'
import { SidebarNav } from './offerSections/SidebarNav'
import { OverviewSection } from './offerSections/OverviewSection'
import { PlacementsSection } from './offerSections/PlacementsSection'
import { PerPlacementPage } from './offerSections/PerPlacementPage'
import { TargetingSection } from './offerSections/TargetingSection'
import { ScheduleSection } from './offerSections/ScheduleSection'
import { ReviewSection } from './offerSections/ReviewSection'
import { PreviewRail } from './previewRail/PreviewRail'
import { isValidPlacement } from './constants/placements'
import { normalizeFormV2ToOfferDocs } from '../../shared/lib/offerNormalizer'
import { createMerchantOffer, updateMerchantOffer } from '../../firebase'
function deepMerge(a, b) {
if (a == null || typeof a !== 'object' || Array.isArray(a)) return b
if (b == null || typeof b !== 'object' || Array.isArray(b)) return b
const out = { ...a }
for (const k of Object.keys(b)) out[k] = deepMerge(a[k], b[k])
return out
}
function reducer(state, action) {
switch (action.type) {
case 'patch':
return deepMerge(state, action.patch)
case 'togglePlacement': {
const { placement, on } = action
const selected = on
? Array.from(new Set([...state.selectedPlacements, placement]))
: state.selectedPlacements.filter((p) => p !== placement)
const placements = { ...state.placements }
if (on && !placements[placement]) placements[placement] = emptyPlacementEntry()
if (!on) delete placements[placement]
return { ...state, selectedPlacements: selected, placements }
}
default:
return state
}
}
export default function OfferForm({ merchantId, venues, offer, onSaved, onCancel }) {
const [state, dispatch] = useReducer(reducer, offer, buildInitialFormState)
const [activeSection, setActiveSection] = useState('overview')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const validation = useMemo(() => validateAll(state), [state])
const venue = useMemo(
() => venues.find((v) => v.id === state.venueId) ?? null,
[venues, state.venueId]
)
function handleChange(patch) {
dispatch({ type: 'patch', patch })
}
function handleTogglePlacement(placement, on) {
dispatch({ type: 'togglePlacement', placement, on })
}
async function persist(status) {
setSaving(true)
setError(null)
try {
const docs = normalizeFormV2ToOfferDocs(state).map((d) => ({ ...d, status }))
const fn = state.id ? updateMerchantOffer : createMerchantOffer
// NOTE: `createMerchantOffer`/`updateMerchantOffer` may take positional
// args in the existing codebase rather than the object shape used here.
// Before running this code, read `apps/admin/src/firebase.js` and
// confirm the call signature; adjust the line below to match without
// changing the imported function. Keep the loop โ one call per doc.
for (const doc of docs) {
await fn({ merchantId, offer: doc })
}
onSaved?.()
} catch (e) {
setError(e.message ?? 'Failed to save')
} finally {
setSaving(false)
}
}
const activePlacement = activeSection.startsWith('placements.')
? activeSection.split('.')[1]
: null
const showPreview = activePlacement && isValidPlacement(activePlacement)
return (
<div className="offer-form-v2">
<SidebarNav
validation={validation}
selectedPlacements={state.selectedPlacements}
activeSection={activeSection}
onSelect={setActiveSection}
/>
<main className="offer-form-v2__main">
{error && <div className="form-error">{error}</div>}
{activeSection === 'overview' && (
<OverviewSection state={state} venues={venues} onChange={handleChange} />
)}
{activeSection === 'placements' && (
<PlacementsSection
selectedPlacements={state.selectedPlacements}
onTogglePlacement={handleTogglePlacement}
/>
)}
{activePlacement && isValidPlacement(activePlacement) && state.placements[activePlacement] && (
<PerPlacementPage
placement={activePlacement}
state={state}
onChange={handleChange}
onRemove={(p) => {
handleTogglePlacement(p, false)
setActiveSection('placements')
}}
/>
)}
{activeSection === 'targeting' && (
<TargetingSection targeting={state.targeting} onChange={handleChange} />
)}
{activeSection === 'schedule' && (
<ScheduleSection schedule={state.schedule} onChange={handleChange} />
)}
{activeSection === 'review' && (
<ReviewSection
state={state}
canPublish={validation.canPublish && !saving}
onSaveDraft={() => persist('draft')}
onPublish={() => persist('active')}
/>
)}
<div className="offer-form-v2__footer">
<button type="button" className="btn btn--secondary" onClick={onCancel}>Cancel</button>
</div>
</main>
{showPreview && (
<PreviewRail state={state} placement={activePlacement} venue={venue} />
)}
</div>
)
}- [ ] Step 4: Run tests to verify they pass
Run: npm run test --workspace apps/admin -- src/merchant/offers/__tests__/OfferForm.test.jsx Expected: PASS โ all four tests green.
- [ ] Step 5: Run the full admin test suite to confirm no regressions
Run: npm run test --workspace apps/admin Expected: PASS. If existing tests for the old OfferForm exist, update or remove them as needed (none were found in exploration, but check apps/admin/src/merchant/offers/__tests__/ and apps/admin/src/merchant/tabs/__tests__/).
- [ ] Step 6: Commit
git add apps/admin/src/merchant/offers/OfferForm.jsx \
apps/admin/src/merchant/offers/__tests__/OfferForm.test.jsx
git commit -m "feat(offer-form): rewrite OfferForm as sectioned container"Task 21: CSS โ section, sidebar, sub-tabs, preview rail, override row โ
Files:
- Modify:
apps/admin/src/shared/styles/styles.css(append new section)
CSS uses tokens: --bg, --surface-dark, --surface, --surface-elevated, --accent-500, --accent-600, --border, --border-focus, --muted, --muted-dark-2, --text. No hex literals.
- [ ] Step 1: Read the bottom of the existing CSS file
Run: tail -n 50 apps/admin/src/shared/styles/styles.css Confirm where to append. Section comments use /* ===== Section ===== */ style.
- [ ] Step 2: Append the v2 styles
Append to apps/admin/src/shared/styles/styles.css:
/* ===== Offer Form v2 ===== */
.offer-form-v2 {
display: grid;
grid-template-columns: 220px 1fr 300px;
min-height: 100%;
background: var(--bg);
}
.offer-form-v2:not(:has(.preview-rail)) {
grid-template-columns: 220px 1fr;
}
.offer-form-v2__main {
background: var(--bg);
padding: 0 0 24px;
border-right: 1px solid var(--border);
overflow-y: auto;
}
.offer-form-v2__footer {
padding: 16px 24px 0;
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Sidebar */
.sidebar-nav {
background: var(--surface-dark);
padding: 16px 10px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.sidebar-nav__heading {
font-size: 10px;
color: var(--muted);
letter-spacing: 1px;
margin: 6px 4px 8px;
}
.sidebar-nav__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: transparent;
border: 0;
width: 100%;
text-align: left;
font: inherit;
color: var(--muted);
border-radius: 6px;
cursor: pointer;
margin-bottom: 2px;
}
.sidebar-nav__item:hover { background: var(--surface); color: var(--text); }
.sidebar-nav__item--active {
background: var(--surface-elevated);
color: var(--accent-500);
border-left: 2px solid var(--accent-500);
border-radius: 0 6px 6px 0;
}
.sidebar-nav__item--indent { padding-left: 26px; font-size: 12px; }
.sidebar-nav__item--complete .sidebar-nav__icon { color: #5cd388; }
.sidebar-nav__item--error .sidebar-nav__icon { color: #ef4444; }
.sidebar-nav__count {
margin-left: auto;
font-size: 10px;
color: var(--muted);
}
.sidebar-nav__legend {
margin-top: auto;
padding: 10px 4px 0;
border-top: 1px solid var(--border);
font-size: 10px;
color: var(--muted-dark-2);
}
/* Section */
.offer-section { padding: 18px 24px; }
.offer-section__eyebrow {
font-size: 10px;
color: var(--muted);
letter-spacing: 1px;
}
.offer-section__title {
font-size: 17px;
color: var(--text);
font-weight: 600;
margin: 2px 0 16px;
}
.offer-section__hint {
font-size: 11px;
color: var(--muted);
margin: 0 0 16px;
}
/* Form group */
.form-group { margin-bottom: 14px; }
.form-group__heading {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--muted);
margin-bottom: 4px;
}
.form-row { display: flex; gap: 8px; }
.form-input--narrow { width: 90px; }
.form-checkbox {
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
color: var(--text);
margin-bottom: 12px;
}
.form-link {
background: transparent;
border: 0;
color: var(--muted);
font-size: 10px;
margin-top: 3px;
padding: 0;
cursor: pointer;
}
/* CharCounter */
.char-counter--ok { color: #5cd388; }
.char-counter--warn { color: var(--accent-500); }
.char-counter--danger { color: #ef4444; }
/* Placements grid */
.placements-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.placements-card {
display: flex;
gap: 10px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.placements-card--selected {
background: var(--surface-elevated);
border-color: var(--accent-500);
color: var(--accent-500);
}
.placements-card__title { font-size: 12px; }
.placements-card__description { font-size: 11px; color: var(--muted); margin-top: 2px; }
/* Per-placement page */
.placement-page { }
.placement-header {
padding: 14px 24px 0;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.placement-header__eyebrow {
font-size: 10px;
color: var(--muted);
letter-spacing: 1px;
}
.placement-header__title {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
font-size: 17px;
color: var(--text);
font-weight: 600;
}
.placement-header__badges { display: inline-flex; gap: 6px; }
.placement-header__badge {
font-size: 10px;
color: var(--accent-500);
background: var(--surface-elevated);
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--border-focus);
font-weight: 400;
}
.placement-header__remove {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 4px 8px;
font-size: 11px;
border-radius: 4px;
cursor: pointer;
}
.placement-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-top: 14px;
padding: 0 24px;
}
.placement-tabs__tab {
background: transparent;
border: 0;
padding: 10px 16px;
color: var(--muted);
font-size: 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.placement-tabs__tab--active {
color: var(--accent-500);
border-bottom-color: var(--accent-500);
}
.placement-tabs__tab--disabled {
color: var(--muted-dark-2);
cursor: not-allowed;
display: inline-flex;
align-items: center;
gap: 6px;
}
.placement-tabs__badge {
font-size: 9px;
background: var(--surface);
color: var(--muted);
padding: 2px 7px;
border-radius: 8px;
border: 1px solid var(--border);
}
.placement-tabs__pane { padding: 20px 24px; }
/* OverrideRow */
.override-row {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
margin-bottom: 10px;
padding: 14px 16px;
display: flex;
gap: 14px;
align-items: center;
}
.override-row--overridden {
border-color: var(--border-focus);
box-shadow: inset 3px 0 0 var(--accent-500);
}
.override-row__main { flex: 1; }
.override-row__heading { display: flex; align-items: center; gap: 8px; }
.override-row__icon { margin-right: 4px; }
.override-row__label { color: var(--text); font-weight: 500; }
.override-row__badge {
font-size: 10px;
padding: 2px 7px;
border-radius: 8px;
border: 1px solid;
}
.override-row__badge--inherit {
color: #5cd388;
background: rgba(92, 211, 136, 0.1);
border-color: rgba(92, 211, 136, 0.3);
}
.override-row__badge--override {
color: var(--accent-500);
background: rgba(245, 158, 11, 0.1);
border-color: var(--border-focus);
}
.override-row__caption {
font-size: 11px;
color: var(--muted);
margin-top: 3px;
}
.override-row__revert {
background: transparent;
border: 0;
color: var(--muted);
text-decoration: underline;
cursor: pointer;
padding: 0;
}
/* SegmentedControl */
.segmented-control {
display: flex;
gap: 0;
background: var(--surface-dark);
border-radius: 8px;
padding: 3px;
border: 1px solid var(--border);
}
.segmented-control__option {
background: transparent;
border: 0;
padding: 5px 11px;
color: var(--muted);
font-size: 11px;
border-radius: 5px;
cursor: pointer;
}
.segmented-control__option.is-active {
background: var(--surface-elevated);
color: var(--text);
border: 1px solid var(--border-focus);
}
/* Preview rail */
.preview-rail {
background: var(--surface-dark);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.preview-rail__sticky {
position: sticky;
top: 0;
padding: 14px 16px 10px;
background: var(--surface-dark);
border-bottom: 1px solid var(--border);
z-index: 1;
}
.preview-rail__heading {
font-size: 10px;
color: var(--muted);
letter-spacing: 1px;
margin-bottom: 8px;
}
.preview-rail__pills { display: flex; gap: 5px; flex-wrap: wrap; }
.preview-rail__pill {
background: var(--surface);
border: 1px solid var(--border);
padding: 3px 9px;
border-radius: 10px;
color: var(--muted);
font-size: 10px;
cursor: pointer;
}
.preview-rail__pill--active {
background: var(--surface-elevated);
color: var(--accent-500);
border-color: var(--border-focus);
}
.preview-rail__cards {
padding: 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
.preview-rail__card { transition: opacity 200ms ease; }
.preview-rail__card--dim { opacity: 0.55; }
/* Schedule */
.schedule-live-event {
background: var(--surface);
padding: 14px;
border-radius: 10px;
border: 1px solid var(--border);
margin-top: 10px;
}
/* Configs tab */
.configs-tab__hint {
font-size: 11px;
color: var(--muted);
margin: 0 0 16px;
}
/* Review */
.review-summary {
display: grid;
grid-template-columns: max-content 1fr;
gap: 8px 14px;
font-size: 12px;
}
.review-summary dt { color: var(--muted); }
.review-summary dd { color: var(--text); margin: 0; }
.review-actions {
margin-top: 18px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* Responsive โ basic fallback. Below 1100px the sidebar and preview rail
collapse out; the form pane goes full-width. The spec calls for a top
dropdown nav and slide-over preview at narrow widths; that polish is
tracked as a follow-up plan and is intentionally out of scope here. */
@media (max-width: 1300px) {
.offer-form-v2 { grid-template-columns: 200px 1fr 280px; }
}
@media (max-width: 1100px) {
.offer-form-v2 {
grid-template-columns: 1fr;
}
.sidebar-nav { display: none; }
.preview-rail { display: none; }
}
/* ===== /Offer Form v2 ===== */- [ ] Step 3: Run the dev server and visually confirm
Run from a separate terminal: npm run dev --workspace apps/admin Open http://localhost:3001/merchant/<id>/offers and click "Create Offer". Verify:
- Sidebar renders with 5 sections
- Selecting placements adds nested children
- Click Hero Rail โ header with badge, sub-tabs, preview rail visible
- Configs tab shows the live-event override row with segmented control
- Color hierarchy reads correctly (sidebar/preview darker than form pane, cards pop above the form pane)
Spec note (docs/planning/specs/2026-05-07-create-offer-form-redesign-design.md, "Open decisions"): tune opacity: 0.55 on dimmed cards if it reads illegibly against real preview content.
- [ ] Step 4: Commit
git add apps/admin/src/shared/styles/styles.css
git commit -m "feat(offer-form): add v2 form styles using existing design tokens"Task 22: Cleanup โ delete deprecated files and confirm wiring โ
Files:
- Delete:
apps/admin/src/merchant/offers/HeroStateSwitcher.jsx - Delete:
apps/admin/src/merchant/offers/LiveEventSection.jsx - Verify:
apps/admin/src/merchant/tabs/Offers.jsx(no edits expected โ confirm import resolves)
HeroPhotoSection.jsx is not deleted; it is used by ContentTab.jsx.
- [ ] Step 1: Confirm no remaining references
Run:
grep -r "HeroStateSwitcher" apps/admin/src
grep -r "LiveEventSection" apps/admin/srcExpected: no matches in source files (only the file definitions themselves). If any remaining usage is found, fix the call site to use the new architecture.
- [ ] Step 2: Delete the files
Run:
git rm apps/admin/src/merchant/offers/HeroStateSwitcher.jsx
git rm apps/admin/src/merchant/offers/LiveEventSection.jsx- [ ] Step 3: Run the full admin test suite
Run: npm run test --workspace apps/admin Expected: PASS. No regressions.
- [ ] Step 4: Run the lint task
Run: npm run lint --workspace apps/admin Expected: no errors. Fix any warnings flagged by the linter on new files.
- [ ] Step 5: Run the validation orchestrator
Run: npm run validate Expected: PASS. This is the pre-PR gate per AGENTS.md rule 3.
- [ ] Step 6: Commit
git commit -m "refactor(offer-form): remove deprecated HeroStateSwitcher and LiveEventSection"End of plan โ
After Task 22, the new sectioned OfferForm is in place. The persisted offer document shape is additive: the v2 helper emits one doc per placement, so existing single-placement documents continue to load via buildInitialFormState's legacy migration path. No backend or web-app changes are required.
Known follow-ups (not in this plan) โ
These are explicit deferrals โ the spec mentions them, but they are scoped to a follow-up plan to keep this implementation focused:
- Narrow-viewport polish (<1100px). Sidebar should become a top dropdown; preview rail should become a slide-over toggled by a "Preview" button at the top of placement sub-pages. This plan ships the simpler responsive fallback (sidebar + preview hidden, form full-width).
- Future Configs rows. Theme override and A/B variant override are mentioned in the spec but only ship once the corresponding offer-level toggles exist. The
OverrideRowcomponent andConfigsTab.CONFIG_ROWSarray are designed so that adding a new row is a one-line addition to the array. - Design sub-tab. Visible-but-disabled per spec; the actual Design suite is a separate future project.