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
liveEventfield 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 atendsAt. - 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; returnsstandardfor 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:
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
liveEventis present:endsAt > startsAtandendsAt <= expiresAt. On create, also requireendsAt > now(no creating an event that has already ended). On update, nonow-relative check โ merchants need to be able to edit other offer fields after the event has started. heroPhotoUrlmust be a Firebase Storage URL undermerchants/{merchantId}/....useGradientFallback: trueoverrides any inherited venue photo and forces the gradient. Whenfalse, 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 ?? nullnull โ 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:
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'
}| State | Trigger | Visual |
|---|---|---|
standard | No liveEvent, or now < dayStart, or now >= endsAt | B2 Corner Tag chip + Storefront layout + resolved photo / gradient |
preroll | dayStart <= now < startsAt | Genre neon skin + countdown badge ("Yael Trio ยท in 2h 15m") + event copy |
live | startsAt <= now < endsAt | Genre 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. Readsoffer,venue,now. Resolves state viagetHeroState. 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โ wrapsStorefrontShellwith the parameterized neon-stack border + box-shadow stack + animation class. Used byprerollandliveonly.CountdownBadgeโprerollonly. ComputesformatCountdown(startsAt - now)with one-minute resolution, re-rendered every 30s via a parentuseIntervalso we don't waste renders on idle cards.LiveBadgeโliveonly. 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 โ
{
// ...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,
liveEventisnulland 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 ifvenue.heroPhotoUrlis set; disabled if not)Upload custom for this offerUse gradient (no photo)
- When
Upload customselected: drag-drop zone with client-side crop to 16:9, max 1MB after compression. Uses Firebase Storage upload via existingfirebase/storageintegration; on success, setsform.heroPhotoUrlto the resulting download URL. - When
Use gradientselected:useGradientFallback: trueis 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()(andliveEvent: 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:
// 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, exactstartsAt, mid-event, exactendsAt, afterendsAt. Co-located withHeroOfferCard.resolvedPhotoUrlhelper โ fallback chain: offer photo > venue photo > gradient;useGradientFallback: trueshort-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
liveEventaccepted; invalid genres rejected;endsAt <= startsAtrejected;endsAt > expiresAtrejected. heroPhotoUrlaccepted only with themerchants/{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.jsxgains new stories:WithVenuePhoto,Preroll,Live(usingjazzas 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
livestate is computed client-side fromDate.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-derivednowlater. - 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-motionand on aIntersectionObserverexit. - 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.