Skip to content

Offers Flush Layout & Tab Router โ€” Design โ€‹

Date: 2026-05-07 Scope: apps/admin/src/merchant/tabs/Offers.jsx, apps/admin/src/merchant/offers/OfferForm.jsx, apps/admin/src/merchant/offers/offerSections/SidebarNav.jsx, plus the offer-form-specific styles in apps/admin/src/shared/styles/styles.css. Context: Visual refinement of the Create/Edit Offer experience. Builds on the architecture from 2026-05-07-create-offer-form-redesign-design.md (sectioned form, per-placement sub-pages, preview rail). This spec addresses the surrounding chrome and field treatment, not the form's information architecture.

Goal โ€‹

Replace the current "floating card on a black canvas" rendering of the Create Offer form with a flush layout that runs against the merchant shell on all sides, and add a routed tab strip at the offers area level so View Offers and Create Offer are first-class siblings rather than mode-switches buried inside a state machine.

After this change, the offers area looks and behaves like one cohesive page rather than a sequence of disconnected modes.

Why now โ€‹

The form currently renders inside a card with rounded corners floating in dark space. Combined with the merchant shell's left nav (also a card-like surface) and the page topbar (another bounded surface), the user reads three nested containers before reaching any input. The interior of the form then adds a fourth nested surface โ€” the section sidebar โ€” and a fifth โ€” the per-section content card โ€” leading to the "bureaucratic" reading the user flagged.

Flattening this in a single coherent pass โ€” and introducing the tab router that View Offers and Create Offer have always implicitly needed โ€” prevents the redesign from getting reverted again (commits 6011f447 โ†’ c2a9a324 show this is the third iteration on the offer form's chrome).

Hard constraints (do NOT touch) โ€‹

These zones were marked off-limits by the user and are explicitly out of scope. Any change that visually alters them must stop and confirm.

  • Merchant shell left nav โ€” apps/admin/src/merchant/MerchantShell.jsx and its associated CSS classes. The Lantern Merchant brand mark, all top-level nav items (Overview, Offers, Venues, Notes, Photos, Address, Profile), and the VIEWING / Back to Admin / Sign out footer keep their current rendering.
  • Existing page topbar โ€” the strip rendered by PageHeader inside Offers.jsx for each view ("CREATE OFFER / Define a new promotional offer" + top-right Cancel button). Text content, typography, colors, spacing, and the Cancel button styling all stay as-is. We may add new chrome below the topbar; we do not modify the topbar itself.

Architecture โ€‹

View hierarchy after this change โ€‹

MerchantShell (untouched)
โ””โ”€ Offers tab content (apps/admin/src/merchant/tabs/Offers.jsx)
   โ”œโ”€ PageHeader topbar (untouched)               โ† LOCKED
   โ”œโ”€ Routed tab strip [View Offers | Create Offer]   โ† NEW
   โ””โ”€ Tab content
      โ”œโ”€ View Offers   โ†’ OffersList / OfferDetail (existing components)
      โ””โ”€ Create Offer  โ†’ OfferForm (chrome flushed; details below)

The routed tab strip โ€‹

A new horizontal tab strip sits directly beneath the existing PageHeader, full-width of the right pane.

  • Two tabs: View Offers and Create Offer
  • Active tab is marked by an orange (--accent-500) 2px underline at the strip's bottom edge, sharing the strip's bottom border so the active tab visually punctures it
  • Inactive tabs use --muted-dark-2; active tab uses --text
  • 12px vertical padding, 24px gap between tabs, 24px left padding to align with the topbar's content edge

Routing model. The tab strip is backed by React Router nested routes (the merchant shell already uses React Router; MerchantShell.jsx mounts <Route path="offers/*" element={<Offers />} />). Inside Offers.jsx we declare nested routes:

  • /merchant/:merchantId/offers (index) โ†’ View Offers tab active, renders OffersList
  • /merchant/:merchantId/offers/create โ†’ Create Offer tab active, renders OfferForm
  • /merchant/:merchantId/offers/:offerId โ†’ View Offers tab active (deep state), renders OfferDetail
  • /merchant/:merchantId/offers/:offerId/edit โ†’ View Offers tab active (deep state), renders OfferForm in edit mode

The tab strip stays on View Offers when detail/edit deep states are active, because edits are conceptually a sub-flow of viewing.

The existing view state machine in Offers.jsx ('list' | 'create' | 'detail' | 'edit') is replaced by route-driven rendering: each route renders the appropriate subcomponent directly. selectedOffer state is removed in favor of useParams().offerId + an in-component lookup or a passed offer prop hydrated from the route's loader. Navigation (Cancel, Back, onSaved) uses useNavigate().

Cancel buttons. The top-right Cancel in the existing PageHeader continues to send the user back to the previous tab/view. The redundant footer Cancel inside OfferForm is removed. (Cancel state semantics: from create โ†’ return to list. From edit โ†’ return to detail.)

Flush form layout โ€‹

The Create Offer tab content abandons all card chrome:

  • The form root (.offer-form-v2) drops its rounded card surface, surrounding margin, and box border. It fills the work area edge to edge: flush against the merchant shell on the left, flush with the bottom of the tab strip on top, and flush with the viewport bottom.
  • The section sidebar (SidebarNav) drops its card border on three sides. It keeps only a hairline 1px solid var(--border-soft) (rgba(255,255,255,0.05)) on its right edge as a divider. No top border, no bottom border, no left border. Background stays transparent so it inherits --bg.
  • The section content area drops its card surface. Padding becomes 28px 32px 28px 28px; max-width on individual fields is 640px to keep line lengths readable while letting the canvas breathe.

The current sidebar has bureaucratic noise that gets removed:

  • The SECTIONS uppercase header at the top is deleted. The section list speaks for itself.
  • The โœ“ done ยท โ— editing ยท โ—‹ empty ยท ! error legend at the bottom is deleted. Color and shape carry the meaning (gray dot = empty, orange dot = active, red dot with halo = error, filled gray = done). For the very small accessibility cost, dots get an aria-label describing their state.
  • Section item rows are reduced to: 6px status dot + label, 12px gap, 14px row gap. No row backgrounds, no left-border accent stripe.
  • Active item gets --text color and a slightly larger dot (7px) with a 3px rgba(245,158,11,0.15) halo. No background fill, no left-border stripe โ€” color promotion alone marks active.
  • Error items use the same dot+halo treatment in --error (#ef4444) and label color shifts to --error. A small count chip (e.g. 1) appears right-aligned when sub-pages have errors.
  • Sub-items (per-placement entries under Placements) indent 18px and drop one tier in font size (12px โ†’ already accounted for in the existing nested behavior).

Content-area cleanup โ€‹

  • The per-section content area also drops bureaucratic noise:
    • The SECTION uppercase eyebrow above each section title is removed.
    • The section title (Overview, Schedule, etc.) is promoted to 22px / 600 / letter-spacing: -0.01em โ€” it now anchors the page on its own.
    • Helper text under the section title sits at 12.5px / --muted / max-width 540px.
  • The redundant footer Cancel button inside OfferForm is removed (top-right Cancel in PageHeader is the single Cancel).

Field treatment โ€‹

Inputs lose their card-like surface treatment in favor of baseline underlines:

StateTreatment
Defaultbackground: transparent, no border except border-bottom: 1px solid var(--border), padding: 6px 0 8px 0, text color --muted-dark-2
Focusedborder-bottom-color: var(--accent-500), text color --text. No box shadow, no surrounding ring.
Errorborder-bottom-color: var(--error), error message in --error below the field
Disabledborder-bottom-color: var(--border-soft), text --muted-dark-2

Field labels move above the input, uppercase, 10.5px, --muted, letter-spacing: 0.08em, font-weight: 600. Character counters sit at the same baseline as the label, right-aligned, 10px tabular-numerics in --muted-dark-2. The counter shifts to --accent-500 at 75% and --error at 95%+ of the limit (matches the existing threshold pattern from the predecessor spec).

The Venue selector retains its dropdown behavior; visually it follows the same baseline-underline pattern with a โ–พ caret right-aligned.

The Description textarea becomes a baseline-underline block: border: none, border-bottom: 1px solid var(--border), padding: 6px 0, height 56โ€“80px depending on content density. Resize handle hidden.

Component changes โ€‹

FileChange
apps/admin/src/merchant/tabs/Offers.jsxAdd tab strip render between PageHeader and the view-switched content. Add hash sync (read on mount + hashchange, write on tab click). Keep existing view state machine.
apps/admin/src/merchant/offers/OfferForm.jsxRemove footer Cancel button block (currently inside .offer-form-v2__content). No other JSX changes.
apps/admin/src/merchant/offers/offerSections/SidebarNav.jsxRemove the "SECTIONS" header element and the legend element at the bottom. Keep the section list. Adjust section item markup to drop the left-border accent stripe (move to dot-only active styling).
apps/admin/src/shared/styles/styles.cssUpdate .offer-form-v2, .offer-form-v2__main, .offer-form-v2__content, .offer-sidebar, .offer-sidebar__item, .offer-sidebar__icon, plus add new .offer-tabs / .offer-tabs__tab styles. Update field/label/counter classes used inside section components to the baseline-underline treatment.

The per-section components (OverviewSection.jsx, ScheduleSection.jsx, PlacementsSection.jsx, TargetingSection.jsx, ReviewSection.jsx) and the per-placement sub-tabs do not need JSX changes โ€” only their inputs/labels pick up the new styles by class. Verify by inspection during implementation; if any section uses inline-styled inputs that bypass the shared classes, port them.

Color tokens used โ€‹

All from the existing apps/admin/src/shared/styles/styles.css token set. No new tokens introduced.

TokenWhere
--bg (#000)Form root, sidebar background, work area
--border (rgba(255,255,255,0.08))Tab strip bottom border, default field underline
--border-soft (new alias for rgba(255,255,255,0.05) if not already present; otherwise reuse --border)Sidebar right divider
--text (#e6eef8)Active tab, active sidebar item, focused-field text, content title
--muted (#9aa6b2)Field labels, helper text, sidebar default item
--muted-dark-2 (#52585f)Inactive tab, sidebar inactive dot, default field text, character counters
--accent-500 (#f59e0b)Active tab underline, active sidebar dot+halo, focused field underline, counter at 75%+
--error (#ef4444)Error sidebar dot+halo, error field underline, error counter

If --border-soft does not already exist in the stylesheet, add it as a new variable (--border-soft: rgba(255,255,255,0.05);) โ€” that is the only new token. Confirmed during implementation.

Responsive behavior โ€‹

  • โ‰ฅ1100px: full layout as described
  • <1100px (matches existing .offer-form-grid collapse threshold): sidebar collapses to a top dropdown above the section content (existing behavior โ€” preserved). The tab strip stays horizontal; on very narrow viewports it can scroll horizontally rather than wrap, since it only ever has 2 tabs in the current scope.

Accessibility โ€‹

  • Tab strip uses role="tablist" with each tab as role="tab" and aria-selected. Tab content area gets role="tabpanel". Pattern matches the existing .sub-tabs usage in OffersList.jsx.
  • Section sidebar items keep their button semantics. Status dots get aria-label describing the state ("complete", "active", "empty", "error") so the legend's removal does not lose the signal for screen readers.
  • Field labels are always visually present (no placeholder-as-label), even in the new baseline-underline treatment.

Out of scope โ€‹

  • Anything in the merchant shell (left nav, top brand area, viewing/admin footer)
  • The existing PageHeader topbar text, color, or button treatment
  • The right-side preview rail's internal styling (separate spec)
  • The information architecture of the form (sections, sub-pages, configs tab) โ€” defined in the predecessor spec
  • The View Offers tab's own internal styling (we wire the route, but do not redesign OffersList.jsx here)
  • Any data shape, API, or normalization changes

Open decisions deferred to implementation โ€‹

  • Whether --border-soft already exists. If yes, reuse; if no, add it.
  • Whether OfferDetail needs to load the offer document itself when arriving via a deep link (currently it receives offer as a prop because the parent had it in state). Default: OfferDetail accepts offerId and loads the document if no offer prop is provided, so deep links work.
  • Whether the section-item active state needs any affordance beyond color + dot halo (e.g., does it need a left-edge bar after all for scannability with longer section lists). Default: no bar โ€” color alone. Revisit if usability testing in implementation reveals scanning issues.

Built with VitePress