Skip to content

Create Offer Form Redesign โ€” Design โ€‹

Date: 2026-05-07 Scope: apps/admin/src/merchant/offers/ (primary), packages/shared/lib/offerNormalizer.js (touch), apps/admin/src/shared/styles/styles.css (new section styles) Context: Refactor of the existing OfferForm.jsx from a single-page form to a sectioned form with sidebar navigation, sub-tabs per placement, and a contextual scroll-spy preview rail. Built to support multi-select placements with per-placement content overrides and per-placement overrides of offer-level toggles.

Goal โ€‹

Replace the current single-page OfferForm with a section-based form that:

  • Splits configuration into 5 named sidebar sections (Overview, Placements, Targeting, Schedule, Review), navigable freely.
  • Lets merchants select multiple placements at once. Each selected placement appears as a nested child under "Placements" in the sidebar, with its own sub-page.
  • On each placement sub-page, presents internal sub-tabs (Content, Configs, future Design) for content overrides and per-placement overrides of offer-level toggles.
  • Replaces the right-side "all 4 placement variants stacked" preview with a contextual preview rail that only renders on placement sub-pages, shows variants for that single placement, scrolls vertically, and uses sticky scroll-spy pill navigation.
  • Reserves architectural real estate for two future features: a per-placement Design suite, and additional offer-level toggles (Theme, A/B variants).

Why now โ€‹

The current form has accumulated structural debt:

  • Hero-only features (LiveEventSection, hero photo upload) are conditionally rendered inside a flat form, so the form's shape silently shifts based on placement choice.
  • Single-placement model can't extend to multi-select without further conditional sprawl.
  • The right-rail preview shows all 4 placement variants stacked, most of which are irrelevant to the merchant at any given moment.
  • "Live event" is structurally tied to Hero placement, but the user has confirmed it should be an offer-level concept that placements can opt out of locally.

Doing this redesign before adding more features (Theme support, A/B variants, per-placement design suite) prevents the conditional sprawl from compounding.

Architecture โ€‹

Section structure โ€‹

The form is broken into 5 top-level sections, rendered as a left-sidebar navigation. Sections are freely navigable โ€” clicking any item activates that section regardless of completion state. Each item shows a status indicator:

  • โœ“ complete ยท all required fields filled and valid
  • โ— editing ยท current section
  • โ—‹ empty ยท no content yet
  • ! error ยท validation failed

The Publish button (in the existing top header) only enables from the Review section once all required fields across all sections validate.

SectionContents
OverviewVenue selector, default Title, default Description (with character counters). These are the offer-level defaults inherited by all placements.
PlacementsMulti-select grid of placement options (Hero Rail, Inline Card, Chat Pill, Feed). Selected placements appear as nested sub-items under this section in the sidebar.
โ†’ per-selected placementEach selected placement is a sub-page accessed via the indented sidebar entry. Content described in Per-placement sub-page.
TargetingTarget audience, geofence radius (with unit toggle), per-user limit.
ScheduleExpires (date), "While supplies last" disclaimer, Live Event toggle (offer-level โ€” moved out of Hero placement), live event details when toggle is on.
ReviewRead-only summary of all sections, validation state, Publish button.

Per-placement sub-page โ€‹

Each selected placement gets its own sub-page reached by clicking its indented entry in the sidebar. The sub-page has a compact header (placement name + variant badges inline + close โœ• icon) and an internal sub-nav with three tabs:

TabContents
ContentTitle override (optional, with char counter and limit per placement type), Description override (optional, with char counter), placement-specific media (e.g., Hero photo + layout selector). Empty fields inherit from Overview defaults; placeholder shows the inherited value in italic. A "โ†บ Use Overview default" link clears any override.
ConfigsPer-placement overrides of offer-level toggles. See Configs tab interaction.
DesignReserved for the future per-placement design suite. Rendered as a disabled tab with a small soon badge (e.g., Design ยท soon). Clicking is a no-op (or shows a brief tooltip: "Coming soon โ€” custom look & feel for this placement"). The tab is visible so merchants know the capability is on the roadmap; it does not block any flow.

Configs tab interaction โ€‹

The Configs tab is a vertical stack of override rows, one per offer-level toggle. Each row has the same anatomy:

  • Left column: label (icon + name), an inheritance status badge (inheriting ยท on in green when not overridden, overridden in amber when overridden), and a one-line caption explaining the source ("Source: Schedule โ†’ Live Event = on") and a โ†บ revert link when overridden.
  • Right column: a segmented control that drives the override.
    • Boolean toggles use a 3-state segmented control: Inherit | On | Off. Inherit is the default and reads through to the offer-level value.
    • Enumerated toggles (e.g., Theme) use a 2-state segmented control: Inherit | Override. Selecting Override reveals an inline picker (chip row for Theme, dropdown for any future enum).

When a row is in override mode, it gets a subtle accent treatment: a 3px left edge in --accent-500 and an --border-focus border. This makes it easy to scan a long list of toggles and immediately see which placement-specific decisions have been made.

The preview rail reacts live to Configs changes โ€” pills appear/disappear and active variant cards re-render as overrides toggle. Example: toggling Show as live event to Off for Hero Rail removes the Day-of and Live pills from the Hero Rail preview rail, leaving only Standard.

Initial rows (this implementation):

  • โšก Show as live event โ€” overrides offer-level Live Event toggle from Schedule, for this placement only.

Reserved row pattern (future):

  • ๐ŸŽจ Theme โ€” once theme support ships at the offer level
  • ๐Ÿ…ฐ A/B variant โ€” once A/B variants ship at the offer level

These future rows are not rendered today; the row component is generic enough that adding one is registering its descriptor (label, icon, source path, control type) in a config array.

The compact header shows variant badges next to the placement name (e.g., โšก live event) so the merchant always sees what offer-level toggles are influencing this placement, even from the Content tab.

Per-placement character limits โ€‹

Title and description fields enforce character limits per placement type. Counters appear in the field's top-right corner and shift color at thresholds (green default โ†’ amber at 75% โ†’ red at 95%+). Limits live as constants near placement metadata for easy tuning.

PlacementTitle limitDescription limit
Hero Rail4080
Inline Card3060
Chat Pill2040
Feed3060

The Overview section's default Title and Description fields use the most permissive limit (40/80) since they may be inherited by Hero โ€” but show a soft warning if the value exceeds a stricter placement's limit while that placement is selected.

Contextual preview rail โ€‹

The right-side preview panel renders only on placement sub-pages. Other sections (Overview, Placements, Targeting, Schedule, Review) are full-width form. The rail has two layers:

  1. Sticky pill nav at top. One pill per relevant variant. Pills are dynamic โ€” only render when the variant exists given current toggles:
    • Always: Standard
    • When offer-level Live Event is on AND this placement hasn't overridden it off: Day-of, Live
    • When a Theme is set (future): Theme A (and per-theme pills as theme suite ships)
    • When A/B variants are configured (future): Variant A, Variant B
  2. Vertically scrollable variant cards. All relevant variants render stacked. Inactive variants are dimmed (e.g. opacity: 0.55); the active variant (the one currently in view) renders at full opacity. No outline or highlight ring is drawn on the active card โ€” each placement's native styling is meant to be the focal point.

Pills act as a scroll-spy: clicking a pill smooth-scrolls to the corresponding variant; scrolling the rail highlights the matching pill via IntersectionObserver. This is a pattern, not a behavior to invent โ€” the active pill matches whichever variant has the largest visible area.

The rail width is fixed (~280px); below ~1100px viewport, the rail collapses into a button at the top of the form area that expands the preview as a slide-over (matching existing collapse behavior from the current form's responsive grid).

Inheritance & override model โ€‹

A single principle drives the form: defaults flow downward from Overview/Schedule, placements override locally.

  • Content defaults (title, description) live in Overview. Each placement's Content tab can override them.
  • Toggle defaults live at the offer level. Live Event is in Schedule. Future toggles (Theme, A/B variants) will land in whichever section fits semantically (Overview vs Schedule) โ€” that's a per-toggle decision when the feature is added. Each placement's Configs tab can override any of them.
  • An override field that is empty or set to "inherit" reads from the default. Setting any value creates an override. A "โ†บ Use default" affordance reverts an override.

State shape (rough sketch):

js
{
  // offer-level
  venueId, defaultTitle, defaultDescription,
  selectedPlacements: ['hero', 'inline'],
  targeting: { audience, geofenceRadius, geofenceUnit, perUserLimit },
  schedule: { expiresAt, whileSuppliesLast, isLiveEvent, liveEventDetails },

  // per-placement, keyed by placement type
  placements: {
    hero: {
      content: { title: null, description: null, photo: { ... }, layout: 'full' },
      configs: { isLiveEvent: 'inherit' /* or true / false */, theme: 'inherit' },
      // design: future
    },
    inline: {
      content: { title: null, description: 'Tighter copy for in-feed' },
      configs: { isLiveEvent: 'inherit', theme: 'inherit' },
    },
  },
}

null content values mean "inherit Overview default." 'inherit' config values mean "inherit Schedule offer-level toggle." Any other value is an explicit override.

Validation & sidebar status โ€‹

Each section computes its own validation state from the offer state object. The sidebar reads these states to render status icons. Required fields per section:

  • Overview: venue, defaultTitle, defaultDescription
  • Placements: at least 1 selected placement; each selected placement has its own validation rolled up to its sub-item (Hero requires a photo, others may have no required fields beyond inherited content)
  • Targeting: audience
  • Schedule: expiresAt; if isLiveEvent is on, liveEventDetails are required
  • Review: read-only โ€” enables Publish when all other sections are โœ“

Sub-items (per-placement sub-pages) are validated independently and roll up: if any sub-page has an error, the parent "Placements" section shows !.

Component architecture โ€‹

New components, all under apps/admin/src/merchant/offers/:

  • OfferForm.jsx โ€” top-level container, owns offer state, renders sidebar + active section.
  • offerSections/SidebarNav.jsx โ€” sidebar with status-aware section list and dynamic placement children.
  • offerSections/OverviewSection.jsx
  • offerSections/PlacementsSection.jsx โ€” multi-select grid + (when no placement selected) instructions.
  • offerSections/PerPlacementPage.jsx โ€” wrapper that renders the compact header + sub-tab nav + active sub-tab pane.
  • offerSections/perPlacement/ContentTab.jsx
  • offerSections/perPlacement/ConfigsTab.jsx
  • offerSections/TargetingSection.jsx
  • offerSections/ScheduleSection.jsx โ€” owns the offer-level Live Event toggle (moved here from Hero).
  • offerSections/ReviewSection.jsx
  • previewRail/PreviewRail.jsx โ€” sticky pill nav + scrollable variant container.
  • previewRail/usePreviewVariants.js โ€” hook that derives the variant list for a given placement from current state (which pills/cards to render).

Existing components reused:

  • AdSlot.jsx (renders individual placement variants โ€” already exists, accept variant props)
  • LiveEventSection.jsx โ€” repurposed/moved into ScheduleSection.jsx (offer-level) and a smaller per-placement override row in ConfigsTab.jsx
  • HeroStateSwitcher.jsx โ€” replaced by the scroll-spy pill nav; can be deleted

The existing OfferForm.jsx (single-page) is replaced wholesale. The container in Offers.jsx (state machine for list/detail/create/edit) does not change.

Data shape changes โ€‹

The persisted offer document needs new fields to support per-placement overrides. Migration is additive โ€” existing offers continue to work because selectedPlacements defaults to [currentPlacement] and placements defaults to one entry built from the current top-level fields.

offerNormalizer.js is extended to accept the new shape and produce one normalized offer per (offerId, placement) pair for the consumer-facing display layer (which already addresses placement variants individually).

Styling: color tokens โ€‹

All backgrounds use the existing CSS custom properties defined in apps/admin/src/shared/styles/styles.css. No new tokens are introduced; the new components must use these vars (not hex literals) so the form participates in any future theming.

TokenValueUsed for
--bg#000000Page background; form-pane background (the center column on placement sub-pages)
--surface-dark#131316Sidebar background; preview-rail background; segmented-control track; inactive variant pill background
--surface#18181bForm cards, inputs, textareas; per-row Configs panels; overall card surfaces inside the form
--surface-elevated#1e1e1eActive/selected states โ€” current sidebar item, active segmented-control option, active pill in preview rail, hovered rows
--borderrgba(255,255,255,0.08)Default border for inputs, panels, dividers
--border-focusrgba(245, 158, 11, 0.5)Focus and active-state borders (orange accent)
--text / --text-primary#e6eef8Primary text
--muted#9aa6b2Secondary text, captions, inactive labels
--muted-dark-2#52585fTertiary / disabled / hint text

The orange accent uses the existing --accent-500 (#f59e0b) token for primary affordances: active sidebar item left border, active sub-tab underline, active pill border, override-mode row accent stripe. Hover/pressed states use --accent-600 (#d97706).

The hierarchy intuition: --bg is the deepest layer, --surface-dark recedes from form content (sidebar, preview rail are container chrome), --surface is the working surface for inputs/cards, --surface-elevated lifts only the actively-selected element.

Responsive behavior โ€‹

  • โ‰ฅ1300px: full three-column layout (sidebar + form + preview rail on placement sub-pages)
  • 1100โ€“1300px: sidebar narrows; preview rail still visible on placement sub-pages
  • <1100px: sidebar collapses to a top dropdown; preview rail becomes a slide-over toggled by a "Preview" button at the top of placement sub-pages

This matches the existing .offer-form-grid collapse behavior at 1100px.

Out of scope โ€‹

  • The Design suite itself (the future Design sub-tab content). This spec only reserves the architectural slot.
  • Theme support and A/B variants. Mentioned only to confirm the variant rendering and override model can accommodate them.
  • Backend/API changes beyond the additive offer document fields described above. CRUD endpoints don't change shape.
  • The web app's consumption of offers (apps/web/). Display-side changes are independently scoped.
  • Any change to the merchant tab routing or MerchantShell.

Open decisions deferred to implementation โ€‹

  • Exact opacity for inactive preview variants โ€” pick a value that reads clearly without making cards illegible (initial guess: 0.55, tune in implementation against real preview content).
  • Char-counter color thresholds โ€” initial 75% / 95% guess; tune visually.
  • Whether Targeting and Schedule should swap order. Current order matches the user's mental flow ("who, then when") but the existing form has them adjacent at the bottom; verify with the merchant UX once implemented.

Built with VitePress