Skip to content

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:

FileResponsibility
apps/admin/src/merchant/offers/constants/placements.jsPlacement enum, labels, descriptions, char limits per placement
apps/admin/src/merchant/offers/state/offerFormState.jsInitial state factory + state-to-payload converter
apps/admin/src/merchant/offers/state/inheritance.jsPure helpers that resolve content and config values for a given placement, walking the inheritance chain
apps/admin/src/merchant/offers/state/validators.jsPer-section validators that return { status, errors }
apps/admin/src/merchant/offers/components/CharCounter.jsxColor-shifting character counter
apps/admin/src/merchant/offers/components/SegmentedControl.jsx2- or 3-state segmented control
apps/admin/src/merchant/offers/components/OverrideRow.jsxConfigs-tab row composing SegmentedControl + inheritance badge
apps/admin/src/merchant/offers/offerSections/SidebarNav.jsxSidebar with status icons + nested placement children
apps/admin/src/merchant/offers/offerSections/OverviewSection.jsxVenue + default title/description
apps/admin/src/merchant/offers/offerSections/PlacementsSection.jsxMulti-select grid
apps/admin/src/merchant/offers/offerSections/PerPlacementPage.jsxHeader + sub-tab nav + active sub-tab pane
apps/admin/src/merchant/offers/offerSections/perPlacement/ContentTab.jsxTitle/description overrides + Hero photo (when applicable)
apps/admin/src/merchant/offers/offerSections/perPlacement/ConfigsTab.jsxOverrideRow stack
apps/admin/src/merchant/offers/offerSections/TargetingSection.jsxAudience + geofence + per-user limit
apps/admin/src/merchant/offers/offerSections/ScheduleSection.jsxExpires + Live Event toggle (offer-level) + while-supplies-last
apps/admin/src/merchant/offers/offerSections/ReviewSection.jsxRead-only summary + Publish
apps/admin/src/merchant/offers/previewRail/usePreviewVariants.jsHook that derives variant list from offer state
apps/admin/src/merchant/offers/previewRail/PreviewRail.jsxSticky pill nav + scrollable variant container

Modified:

FileChange
apps/admin/src/merchant/offers/OfferForm.jsxReplaced wholesale: top-level container that owns state and switches between sections
apps/admin/src/merchant/tabs/Offers.jsxNo prop changes (already passes merchantId, venues, offer, onSaved, onCancel); confirm import resolves
apps/admin/src/shared/styles/styles.cssNew section: .offer-form-v2-* classes for sidebar/sub-tab/preview rail layout
packages/shared/lib/offerNormalizer.jsAdd normalizeOfferToFormV2() and normalizeFormV2ToOffer() for the new shape; keep existing normalizeFormToOffer() for the preview rail to consume

Deleted:

FileReason
apps/admin/src/merchant/offers/HeroStateSwitcher.jsxReplaced by scroll-spy pill nav inside PreviewRail
apps/admin/src/merchant/offers/LiveEventSection.jsxLive 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 for apps/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.js

  • Test: apps/admin/src/merchant/offers/constants/__tests__/placements.test.js

  • [ ] Step 1: Write the failing test

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

  • Test: apps/admin/src/merchant/offers/components/__tests__/CharCounter.test.jsx

  • [ ] Step 1: Write the failing test

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

  • Test: apps/admin/src/merchant/offers/components/__tests__/SegmentedControl.test.jsx

  • [ ] Step 1: Write the failing test

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

  • Test: apps/admin/src/merchant/offers/offerSections/__tests__/OverviewSection.test.jsx

  • [ ] Step 1: Write the failing test

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

  • Test: apps/admin/src/merchant/offers/offerSections/__tests__/TargetingSection.test.jsx

  • [ ] Step 1: Write the failing test

jsx
// 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
jsx
// 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
bash
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
jsx
// 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
jsx
// 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.jsx may 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 change HeroPhotoSection.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
bash
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
jsx
// 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
jsx
// 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
bash
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
jsx
// 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
jsx
// 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
bash
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
js
// 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
js
// 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
bash
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
jsx
// 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
jsx
// 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
bash
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
jsx
// 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
jsx
// 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 &amp; 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
bash
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 existing createMerchantOffer/updateMerchantOffer Firebase functions, which already accept one offer at a time per placement.
  • normalizeFormV2ToPreviewOffer(state, placement, variantId) returns a single preview offer for the rail. (Uses existing normalizeFormToOffer for actual rendering โ€” this is just a convenience wrapper that resolves inheritance first.)

Note: The existing normalizeFormToOffer(formData, options) is not removed โ€” PreviewRail already builds offers inline via buildVariantOffer, 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
js
// 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):

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

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

bash
grep -r "HeroStateSwitcher" apps/admin/src
grep -r "LiveEventSection" apps/admin/src

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

bash
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
bash
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 OverrideRow component and ConfigsTab.CONFIG_ROWS array 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.

Built with VitePress