Skip to content

Themed Hero Rails โ€” Design โ€‹

Date: 2026-05-03 Scope: packages/ui/offers/, apps/admin/src/merchant/offers/, services/api/merchants/, venue + offer Firestore documents Context: Lands the themed-rail design system from the Ad Placement Design handoff bundle (Claude Design export at /tmp/lantern_design/ad-placement-design/) into the production hero rail. Adds merchant-controlled live-event genre theming and per-offer hero photos to the Create Offer flow, while leaving auto-applied contextual themes (Burning Hot, Limited Time) and occasion palettes (Pride, BCA, Trans, Juneteenth) for v1.5.

Goal โ€‹

After this change:

  • The hero-rail card has three render states: standard, day-of pre-roll, and live. Transitions are time-driven from a single liveEvent field on the offer.
  • Merchants can attach a live event to an offer (genre + start/end window + optional headline override). The genre neon skin from the design bundle activates from midnight on event-day, shows a countdown until startsAt, switches to a "LIVE" presentation during the event, and turns off at endsAt.
  • Merchants can upload a hero photo per offer, with a venue-level default that all offers inherit unless overridden. No photo โ†’ warm radial-gradient fallback (already in the design as StoreHeader).
  • The Create Offer form gains two collapsible sections (Live event, Hero photo) and a state switcher above the hero placement preview so merchants see Standard / Day-of / Live before publishing.
  • The B2 "Corner Tag" sponsored chip becomes the canonical sponsored treatment for hero placements across all three states.

Out of scope for v1, captured as parking-lot:

  • Burning Hot Now and Limited Time auto-skins (depend on lit-rate analytics + countdown plumbing not yet wired into the feed)
  • Occasion palettes (Pride, BCA, Trans, Juneteenth)
  • Live-event rotation precedence (recorded as a TODO in feed selection logic; not implemented)
  • Post-event "afterglow" presentation (placeholder branch in getHeroState; returns standard for now)
  • Per-merchant logo slot for the V4 thumbnail layout (v1 ships Storefront only)

Why now โ€‹

The merchant-portal Create Offer form (apps/admin/src/merchant/offers/OfferForm.jsx) is live and the underlying offers API (services/api/merchants/src/routes/offers.js) is the canonical write path for offers. The design exploration in /tmp/lantern_design/ad-placement-design/ landed on a parameterized neon-stack card system with a clear separation between auto-derived and merchant-controlled themes. Bringing the merchant-controlled axes (genre + photo) into Create Offer now unblocks merchants from setting up themed live-event promos and gives the consumer hero rail in packages/ui/offers/OfferCards.jsx a real test surface for the renderer split that the auto-themes will plug into in v1.5.

Architecture โ€‹

1. Data model โ€‹

Offer schema (Zod, services/api/merchants/src/routes/offers.js) โ€‹

Extend CreateOfferSchema and UpdateOfferSchema with:

js
liveEvent: z.object({
  genre: z.enum(['disco', 'edm', 'rock', 'jazz', 'hiphop']),
  startsAt: z.string().datetime(),
  endsAt: z.string().datetime(),
  liveHeadline: z.string().max(200).optional(),
  liveSubcopy: z.string().max(200).optional(),
}).optional(),
heroPhotoUrl: z.string().url().optional(),
useGradientFallback: z.boolean().default(false),

Validation rules:

  • If liveEvent is present: endsAt > startsAt and endsAt <= expiresAt. On create, also require endsAt > now (no creating an event that has already ended). On update, no now-relative check โ€” merchants need to be able to edit other offer fields after the event has started.
  • heroPhotoUrl must be a Firebase Storage URL under merchants/{merchantId}/....
  • useGradientFallback: true overrides any inherited venue photo and forces the gradient. When false, photo resolution follows the fallback chain below.

The Firestore offers/{offerId} document mirrors the Zod shape.

Venue schema โ€‹

Add heroPhotoUrl: string? to the venue document. A small upload control is added to the Venue edit page in the same implementation pass โ€” same crop/compression rules as the per-offer uploader, just persisted to the venue record. All offers for that venue inherit this photo unless they override.

Photo resolution โ€‹

resolvedPhotoUrl =
  offer.useGradientFallback ? null
  : offer.heroPhotoUrl ?? venue.heroPhotoUrl ?? null

null โ†’ renderer shows the radial-gradient StoreHeader from the design bundle.

Storage paths (Firebase Storage) โ€‹

  • Per-offer: merchants/{merchantId}/offers/{offerId}/hero.{jpg|png|webp}
  • Per-venue default: merchants/{merchantId}/venues/{venueId}/hero.{jpg|png|webp}

Storage rules already gate merchants/{merchantId}/** to admins and the merchant owner; no new rules needed.

2. Render states (single renderer, three branches) โ€‹

New helper co-located with HeroOfferCard:

js
export function getHeroState(offer, now = Date.now()) {
  const evt = offer.liveEvent
  if (!evt) return 'standard'
  const start = +new Date(evt.startsAt)
  const end = +new Date(evt.endsAt)
  if (now >= end) return 'standard'  // post-event; afterglow is v1.5
  if (now >= start) return 'live'
  const dayStart = new Date(start); dayStart.setHours(0, 0, 0, 0)
  if (now >= +dayStart) return 'preroll'
  return 'standard'
}
StateTriggerVisual
standardNo liveEvent, or now < dayStart, or now >= endsAtB2 Corner Tag chip + Storefront layout + resolved photo / gradient
prerolldayStart <= now < startsAtGenre neon skin + countdown badge ("Yael Trio ยท in 2h 15m") + event copy
livestartsAt <= now < endsAtGenre neon skin + pulsing "LIVE" badge + event copy

Genre presets are ported verbatim from HappeningGenres in /tmp/lantern_design/ad-placement-design/project/ad-variations.jsx (lines ~498โ€“599): disco / edm / rock / jazz / hiphop. Each preset supplies tube border color, interior radial gradient, layered box-shadow stack, animation class name + keyframe, and accent color for the badge dot.

Animation keyframes (ad-neon-disco, ad-neon-edm, ad-neon-rock, ad-neon-jazz, ad-neon-hiphop) ported from the design bundle's CSS into packages/ui/offers/cards.css.

3. Component refactor (packages/ui/offers/) โ€‹

Today HeroOfferCard in packages/ui/offers/OfferCards.jsx is a single monolithic component. Split into:

  • HeroOfferCard โ€” orchestrator. Reads offer, venue, now. Resolves state via getHeroState. Resolves photo via the fallback chain. Composes the right children for the state.
  • StorefrontShell โ€” always-on chrome: B2 Corner Tag chip, photo or gradient header, lit-count chip in the header. Used by all three states.
  • GenreNeonWrap โ€” wraps StorefrontShell with the parameterized neon-stack border + box-shadow stack + animation class. Used by preroll and live only.
  • CountdownBadge โ€” preroll only. Computes formatCountdown(startsAt - now) with one-minute resolution, re-rendered every 30s via a parent useInterval so we don't waste renders on idle cards.
  • LiveBadge โ€” live only. Pulsing red dot + "LIVE" text.

B2 Corner Tag chip is implemented inside StorefrontShell as a static element โ€” not a merchant control, not a state-dependent variant. Same chip in all three states.

Public API: <HeroOfferCard offer={offer} venue={venue} now={Date.now()} onSelect={...} onTrack={...} />. Apps pass now so previews can mock it.

4. Create Offer form changes (apps/admin/src/merchant/offers/OfferForm.jsx) โ€‹

Default form state โ€‹

js
{
  // ...existing fields
  liveEvent: null,           // { genre, startsAt, endsAt, liveHeadline, liveSubcopy }
  heroPhotoMode: 'venue',    // 'venue' | 'custom' | 'gradient'
  heroPhotoUrl: '',          // populated when mode === 'custom'
}

Live event section (collapsible, collapsed by default) โ€‹

Header: "Live event (optional)". Body:

  • Toggle: "Tie this offer to a live event" โ€” when off, liveEvent is null and the genre fields are hidden.
  • Genre select: Disco / EDM / Rock / Jazz / Hip-hop (uses StyledSelect)
  • Starts at: <input type="datetime-local">
  • Ends at: <input type="datetime-local">
  • Live headline override (optional, max 200 chars)
  • Live subcopy override (optional, max 200 chars)
  • Helper text below the section: "Live events get rotation precedence (coming soon)."

Validation in handleSubmit:

  • If toggle is on: genre, startsAt, endsAt are required; endsAt must be after startsAt and not after expiresAt.
  • If endsAt > expiresAt: inline error "Event must end before the offer expires."

Hero photo section (collapsible) โ€‹

Header: "Hero photo". Default-expanded if no venue photo is set; default-collapsed otherwise.

  • Radio group, three options:
    • Use venue default (selected by default if venue.heroPhotoUrl is set; disabled if not)
    • Upload custom for this offer
    • Use gradient (no photo)
  • When Upload custom selected: drag-drop zone with client-side crop to 16:9, max 1MB after compression. Uses Firebase Storage upload via existing firebase/storage integration; on success, sets form.heroPhotoUrl to the resulting download URL.
  • When Use gradient selected: useGradientFallback: true is sent in the payload. Overrides venue default for this offer specifically.

Image processing: use the browser-native <canvas> API for crop + JPEG re-encode at quality 0.85 before upload. No new dependencies.

Multi-state preview (right column) โ€‹

The existing right pane in OfferForm.jsx (lines 257โ€“289) already stacks all four placement previews via <AdSlot offer={...} placement={p} />. Augment the hero placement preview only with a small segmented control immediately above it:

[ Standard | Day-of | Live ]

Implementation: store heroPreviewState in OfferForm local state. Pass it down to <AdSlot> as a new optional mockNow prop. AdSlot for placement: 'hero' computes a synthetic now based on heroPreviewState:

  • Standard โ†’ Date.now() (and liveEvent: undefined)
  • Day-of โ†’ liveEvent.startsAt - 2h (a synthesized clock value that lands inside the preroll window for the configured event)
  • Live โ†’ liveEvent.startsAt + 30min

If the merchant hasn't filled in liveEvent, Day-of and Live segments are disabled with a tooltip "Set up a live event to preview". Switching the radio also drives the click-to-select placement behavior already in the form (clicking the hero card still selects placement: 'hero').

Submit payload โ€‹

handleSubmit builds the payload from form state, including liveEvent (or omitting it if the toggle is off), heroPhotoUrl, and useGradientFallback derived from heroPhotoMode === 'gradient'.

5. Backend wiring (services/api/merchants/) โ€‹

  • Schema additions in routes/offers.js (above)
  • POST and PUT handlers pass through new fields unchanged after validation
  • Firestore persistence is automatic (the route writes the validated body)
  • No new endpoints needed; the upload itself happens client-side directly to Firebase Storage, so the API only stores the resulting URL

6. Web app consumption (apps/web/) โ€‹

The web app's HeroOfferCard import in apps/web/src/components/dashboard/VenueView.jsx and similar consumers picks up the new behavior automatically once they pass venue alongside offer. Existing callers that don't pass venue get standard-state rendering only (no photo fallback chain), which is the safe default.

Update getHeroOffer in apps/web/src/lib/offerService.js to attach the resolved venue object when picking the hero offer. Add a TODO comment where rotation precedence will eventually consider liveEvent:

js
// TODO(v1.5): live events get rotation precedence โ€” sort offers by
// (hasActiveLiveEvent desc, priority desc) before picking the hero.

7. Tests โ€‹

Unit tests โ€‹

  • getHeroState โ€” boundary tests for: no event, before day-of, dayStart inclusive, exact startsAt, mid-event, exact endsAt, after endsAt. Co-located with HeroOfferCard.
  • resolvedPhotoUrl helper โ€” fallback chain: offer photo > venue photo > gradient; useGradientFallback: true short-circuits regardless of venue.
  • Genre preset registry โ€” every genre has all required keys (border, interior, shadow, animClass, accent, accentDot, copy/sub defaults).

Schema tests (services/api/merchants/test/) โ€‹

  • Valid liveEvent accepted; invalid genres rejected; endsAt <= startsAt rejected; endsAt > expiresAt rejected.
  • heroPhotoUrl accepted only with the merchants/{merchantId}/... path prefix.

Integration tests (apps/admin/) โ€‹

  • Form: live-event toggle reveals/hides genre fields; preview state segments are disabled when no event is configured; switching segments re-renders the hero preview.
  • Form: photo upload completes and produces a valid Firebase Storage URL in the submit payload; gradient radio sets useGradientFallback: true.

Storybook โ€‹

  • HeroOfferCard.stories.jsx gains new stories: WithVenuePhoto, Preroll, Live (using jazz as the canonical genre for the default story), plus one per remaining genre as visual-comparison stories (LiveDisco, LiveEdm, LiveRock, LiveHiphop).

Coverage threshold โ€‹

Existing 75% threshold applies. New code paths in HeroOfferCard, getHeroState, and the form sections must hit it.

Risks & open questions โ€‹

  • Clock skew between client and server: the live state is computed client-side from Date.now(), which means a merchant's clock being off by 10+ minutes shows them an inaccurate preview. Acceptable for v1 โ€” the production render also uses client-side time, so a user's view is consistent with what they'd actually see. If this becomes a complaint we can switch to server-derived now later.
  • Photo upload size: 1MB after compression is a guess. If it turns out to be too small for high-res storefront imagery, we revisit the cap. The renderer can handle larger, but bandwidth on hero rail is precious.
  • Animation cost on idle dashboard: five genre keyframe animations on a card that's always visible. Need to validate on a low-end Android in QA before shipping. Mitigation if needed: pause the keyframe via prefers-reduced-motion and on a IntersectionObserver exit.
  • State switcher UX with no event configured: the disabled segments approach is functional but slightly opaque. If users find it confusing, we can swap to "always show all three, with a banner above Day-of/Live: 'Configure a live event to enable this preview.'"
  • Venue photo upload UI is referenced but its design isn't part of this spec โ€” it's a small addition to the existing venue edit page (one upload control with the same crop/compression rules). Captured here so the dependency is explicit; will be done in the same implementation pass.

Built with VitePress