Skip to content

Themed Hero Rails 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: Add merchant-controlled live-event genre theming and hero photos to the Create Offer flow, backed by a renderer-side state machine (standard / day-of pre-roll / live) that ports the parameterized neon-stack design from /tmp/lantern_design/ad-placement-design/.

Architecture: Split the monolithic HeroOfferCard in @lantern/ui into a state-driven orchestrator + composable subcomponents (StorefrontShell, GenreNeonWrap, CountdownBadge, LiveBadge). Extend the Zod schema in services/api/merchants with liveEvent, heroPhotoUrl, and useGradientFallback. Augment OfferForm.jsx with a Live Event section, a Hero Photo section, and a Standard/Day-of/Live state switcher above the hero preview. Add an inline hero-photo uploader to the merchant Venues tab.

Tech Stack: React 18 (Vite), Tailwind v4, Zod, Express on Cloud Run (merchants-api), Firebase Firestore + Storage, Vitest + Testing Library, Storybook, lucide-react.

Spec: docs/superpowers/specs/2026-05-03-themed-hero-rails-design.md


File Structure โ€‹

Created files โ€‹

PathResponsibility
packages/ui/offers/getHeroState.jsPure helper: (offer, now) โ†’ 'standard' | 'preroll' | 'live'
packages/ui/offers/getHeroState.test.jsBoundary tests for the state machine
packages/ui/offers/resolvePhotoUrl.jsPhoto fallback chain: offer โ†’ venue โ†’ null (gradient)
packages/ui/offers/resolvePhotoUrl.test.jsFallback tests
packages/ui/offers/genrePresets.jsRegistry: disco/edm/rock/jazz/hiphop config (border, interior, shadow, animClass, accent)
packages/ui/offers/genrePresets.test.jsRegistry shape + completeness tests
packages/ui/offers/StorefrontShell.jsxB2 Corner Tag chip + photo/gradient header (always-on chrome)
packages/ui/offers/GenreNeonWrap.jsxWraps shell with parameterized neon-stack border + box-shadow
packages/ui/offers/CountdownBadge.jsx"in 2h 15m" text with useInterval(30s) re-render
packages/ui/offers/LiveBadge.jsxPulsing "LIVE" indicator
packages/ui/offers/formatCountdown.jsPure helper: (msRemaining) โ†’ 'in 2h 15m' | 'starting now'
packages/ui/offers/formatCountdown.test.jsFormat tests
packages/ui/offers/HeroOfferCard.stories.jsxNew stories: WithVenuePhoto, Preroll, LiveJazz, LiveDisco, LiveEdm, LiveRock, LiveHiphop
apps/admin/src/merchant/offers/LiveEventSection.jsxCollapsible live-event subform
apps/admin/src/merchant/offers/HeroPhotoSection.jsxRadio + uploader for offer photo
apps/admin/src/merchant/offers/HeroStateSwitcher.jsxStandard / Day-of / Live segmented control
apps/admin/src/shared/lib/cropAndCompressImage.jsClient-side canvas-based 16:9 crop + JPEG compression
apps/admin/src/shared/lib/cropAndCompressImage.test.jsPure helper tests with synthetic ImageData
apps/admin/src/merchant/tabs/VenueHeroPhotoUploader.jsxInline uploader for the Venues tab cards
services/api/merchants/src/routes/__tests__/offers.themedRails.test.jsSchema validation tests for new fields

Modified files โ€‹

PathChange
packages/ui/offers/OfferCards.jsxReplace inline HeroOfferCard body with composition of new subcomponents
packages/ui/offers/index.jsExport new public surfaces (getHeroState, genrePresets)
packages/ui/offers/cards.cssAdd genre keyframes (ad-neon-disco, ad-neon-edm, ad-neon-rock, ad-neon-jazz, ad-neon-hiphop) + ad-live-pulse + ad-flame
services/api/merchants/src/routes/offers.jsExtend CreateOfferSchema and UpdateOfferSchema with new fields
packages/shared/lib/offerNormalizer.jsPass liveEvent and heroPhotoUrl through normalization
packages/shared/__tests__/lib/offerNormalizer.test.jsCover new fields
apps/admin/src/merchant/offers/OfferForm.jsxWire LiveEventSection, HeroPhotoSection, HeroStateSwitcher; submit new fields
apps/admin/src/components/offers/AdSlot.jsxAccept mockNow prop, pass through
apps/admin/src/merchant/tabs/Venues.jsxRender <VenueHeroPhotoUploader> inside each venue card
apps/admin/src/firebase.jsExport a setVenueHeroPhotoUrl(venueId, url) helper
apps/web/src/components/dashboard/VenueView.jsxPass venue to <HeroOfferCard>
apps/web/src/lib/offerService.jsAdd TODO comment about live-event rotation precedence

Phase 1 โ€” Renderer foundation (pure helpers + presets) โ€‹

This phase produces a fully unit-tested foundation. No UI consumers change yet; the existing HeroOfferCard keeps working.

Task 1: getHeroState helper โ€‹

Files:

  • Create: packages/ui/offers/getHeroState.js

  • Test: packages/ui/offers/getHeroState.test.js

  • [ ] Step 1: Write the failing test

js
// packages/ui/offers/getHeroState.test.js
import { describe, it, expect } from 'vitest'
import { getHeroState } from './getHeroState.js'

const baseOffer = {
  liveEvent: {
    genre: 'jazz',
    startsAt: '2026-06-01T21:00:00.000Z',
    endsAt: '2026-06-01T23:00:00.000Z',
  },
}

const ms = (iso) => new Date(iso).getTime()

describe('getHeroState', () => {
  it('returns standard when offer has no liveEvent', () => {
    expect(getHeroState({}, ms('2026-06-01T21:30:00.000Z'))).toBe('standard')
  })

  it('returns standard before midnight on event day', () => {
    expect(getHeroState(baseOffer, ms('2026-05-31T23:59:59.000Z'))).toBe('standard')
  })

  it('returns preroll at midnight on event day', () => {
    expect(getHeroState(baseOffer, ms('2026-06-01T00:00:00.000Z'))).toBe('preroll')
  })

  it('returns preroll one minute before startsAt', () => {
    expect(getHeroState(baseOffer, ms('2026-06-01T20:59:00.000Z'))).toBe('preroll')
  })

  it('returns live exactly at startsAt', () => {
    expect(getHeroState(baseOffer, ms('2026-06-01T21:00:00.000Z'))).toBe('live')
  })

  it('returns live mid-event', () => {
    expect(getHeroState(baseOffer, ms('2026-06-01T22:00:00.000Z'))).toBe('live')
  })

  it('returns standard exactly at endsAt', () => {
    expect(getHeroState(baseOffer, ms('2026-06-01T23:00:00.000Z'))).toBe('standard')
  })

  it('returns standard after endsAt', () => {
    expect(getHeroState(baseOffer, ms('2026-06-02T01:00:00.000Z'))).toBe('standard')
  })

  it('uses local-day boundary for dayStart, not UTC', () => {
    // startsAt 21:00 UTC = 17:00 EDT; local "day-of" should still be 2026-06-01 EDT
    // The helper relies on Date#setHours(0,0,0,0) which respects local TZ โ€” assert via behavior
    const evt = ms('2026-06-01T20:59:59.000Z')
    expect(getHeroState(baseOffer, evt)).toBe('preroll')
  })
})
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run packages/ui/offers/getHeroState.test.js Expected: FAIL โ€” getHeroState is not defined or module not found.

  • [ ] Step 3: Write the implementation
js
// packages/ui/offers/getHeroState.js
/**
 * Derives which visual state a hero offer card should render.
 *
 *   standard โ€” no event, before event-day, or after endsAt
 *   preroll  โ€” local midnight on event day โ†’ startsAt
 *   live     โ€” startsAt โ†’ endsAt
 *
 * @param {object} offer  Offer record. Reads `offer.liveEvent.{startsAt,endsAt}` if present.
 * @param {number} now    Epoch ms. Pass Date.now() in production; pass a synthetic ms in previews.
 * @returns {'standard'|'preroll'|'live'}
 */
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'
  if (now >= start) return 'live'
  const dayStart = new Date(start)
  dayStart.setHours(0, 0, 0, 0)
  if (now >= +dayStart) return 'preroll'
  return 'standard'
}
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run packages/ui/offers/getHeroState.test.js Expected: PASS โ€” 9 tests passing.

  • [ ] Step 5: Commit
bash
git add packages/ui/offers/getHeroState.js packages/ui/offers/getHeroState.test.js
git commit -m "feat(ui/offers): add getHeroState state-machine helper"

Task 2: resolvePhotoUrl helper โ€‹

Files:

  • Create: packages/ui/offers/resolvePhotoUrl.js

  • Test: packages/ui/offers/resolvePhotoUrl.test.js

  • [ ] Step 1: Write the failing test

js
// packages/ui/offers/resolvePhotoUrl.test.js
import { describe, it, expect } from 'vitest'
import { resolvePhotoUrl } from './resolvePhotoUrl.js'

describe('resolvePhotoUrl', () => {
  it('returns offer photo when set', () => {
    expect(resolvePhotoUrl(
      { heroPhotoUrl: 'https://x/o.jpg' },
      { heroPhotoUrl: 'https://x/v.jpg' },
    )).toBe('https://x/o.jpg')
  })

  it('falls back to venue photo when offer is unset', () => {
    expect(resolvePhotoUrl({}, { heroPhotoUrl: 'https://x/v.jpg' })).toBe('https://x/v.jpg')
  })

  it('returns null when neither set', () => {
    expect(resolvePhotoUrl({}, {})).toBe(null)
  })

  it('returns null when venue is undefined', () => {
    expect(resolvePhotoUrl({}, undefined)).toBe(null)
  })

  it('forces null when useGradientFallback is true, even with offer photo', () => {
    expect(resolvePhotoUrl(
      { heroPhotoUrl: 'https://x/o.jpg', useGradientFallback: true },
      { heroPhotoUrl: 'https://x/v.jpg' },
    )).toBe(null)
  })

  it('forces null when useGradientFallback is true with venue photo only', () => {
    expect(resolvePhotoUrl(
      { useGradientFallback: true },
      { heroPhotoUrl: 'https://x/v.jpg' },
    )).toBe(null)
  })
})
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run packages/ui/offers/resolvePhotoUrl.test.js Expected: FAIL โ€” resolvePhotoUrl is not defined.

  • [ ] Step 3: Write the implementation
js
// packages/ui/offers/resolvePhotoUrl.js
/**
 * Resolves the hero photo for an offer using the documented fallback chain:
 *   1. offer.heroPhotoUrl (per-offer override)
 *   2. venue.heroPhotoUrl (per-venue default)
 *   3. null (renderer falls back to radial gradient)
 *
 * `offer.useGradientFallback === true` short-circuits the chain to null,
 * so a merchant can opt out of the venue default for one specific offer.
 *
 * @returns {string|null}
 */
export function resolvePhotoUrl(offer, venue) {
  if (offer?.useGradientFallback) return null
  return offer?.heroPhotoUrl ?? venue?.heroPhotoUrl ?? null
}
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run packages/ui/offers/resolvePhotoUrl.test.js Expected: PASS โ€” 6 tests passing.

  • [ ] Step 5: Commit
bash
git add packages/ui/offers/resolvePhotoUrl.js packages/ui/offers/resolvePhotoUrl.test.js
git commit -m "feat(ui/offers): add resolvePhotoUrl fallback helper"

Task 3: formatCountdown helper โ€‹

Files:

  • Create: packages/ui/offers/formatCountdown.js

  • Test: packages/ui/offers/formatCountdown.test.js

  • [ ] Step 1: Write the failing test

js
// packages/ui/offers/formatCountdown.test.js
import { describe, it, expect } from 'vitest'
import { formatCountdown } from './formatCountdown.js'

describe('formatCountdown', () => {
  it('formats hours and minutes', () => {
    expect(formatCountdown(2 * 3600_000 + 15 * 60_000)).toBe('in 2h 15m')
  })

  it('formats minutes only when under an hour', () => {
    expect(formatCountdown(45 * 60_000)).toBe('in 45m')
  })

  it('formats hours only when minutes are zero', () => {
    expect(formatCountdown(3 * 3600_000)).toBe('in 3h')
  })

  it('returns "starting now" within the final 60 seconds', () => {
    expect(formatCountdown(45_000)).toBe('starting now')
  })

  it('returns "starting now" at zero or negative', () => {
    expect(formatCountdown(0)).toBe('starting now')
    expect(formatCountdown(-1000)).toBe('starting now')
  })

  it('clamps to integer minutes (no fractional)', () => {
    expect(formatCountdown(90_000)).toBe('in 1m') // 1.5min โ†’ 1m floor
  })
})
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run packages/ui/offers/formatCountdown.test.js Expected: FAIL.

  • [ ] Step 3: Implementation
js
// packages/ui/offers/formatCountdown.js
/**
 * Renders a "in 2h 15m" countdown string for the day-of preroll badge.
 * Returns "starting now" inside the final minute or after startsAt.
 */
export function formatCountdown(msRemaining) {
  if (msRemaining < 60_000) return 'starting now'
  const totalMin = Math.floor(msRemaining / 60_000)
  const h = Math.floor(totalMin / 60)
  const m = totalMin % 60
  if (h === 0) return `in ${m}m`
  if (m === 0) return `in ${h}h`
  return `in ${h}h ${m}m`
}
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run packages/ui/offers/formatCountdown.test.js Expected: PASS โ€” 6 tests.

  • [ ] Step 5: Commit
bash
git add packages/ui/offers/formatCountdown.js packages/ui/offers/formatCountdown.test.js
git commit -m "feat(ui/offers): add formatCountdown helper"

Task 4: Genre presets registry โ€‹

Files:

  • Create: packages/ui/offers/genrePresets.js
  • Test: packages/ui/offers/genrePresets.test.js

The presets are ported from /tmp/lantern_design/ad-placement-design/project/ad-variations.jsx lines ~498โ€“599. Each preset is consumed by GenreNeonWrap.jsx (Task 7) โ€” keys must match exactly.

  • [ ] Step 1: Write the failing test
js
// packages/ui/offers/genrePresets.test.js
import { describe, it, expect } from 'vitest'
import { GENRE_PRESETS, GENRE_KEYS } from './genrePresets.js'

const REQUIRED_KEYS = [
  'label',          // human label e.g. "Disco ยท Live"
  'border',         // CSS color string for borderColor
  'interior',       // CSS background string (gradients)
  'shadow',         // CSS box-shadow string
  'animClass',      // CSS class name for the keyframe
  'accent',         // primary accent color (hex)
  'accentDot',      // secondary accent for the badge dot
  'venueColor',     // map-pin / venue accent
]

describe('GENRE_PRESETS', () => {
  it('exports the five expected genres', () => {
    expect(GENRE_KEYS).toEqual(['disco', 'edm', 'rock', 'jazz', 'hiphop'])
  })

  it.each(['disco', 'edm', 'rock', 'jazz', 'hiphop'])('preset %s has all required keys', (key) => {
    const preset = GENRE_PRESETS[key]
    expect(preset).toBeDefined()
    for (const k of REQUIRED_KEYS) {
      expect(preset[k], `${key}.${k}`).toBeTruthy()
    }
  })

  it('animClass values are unique', () => {
    const classes = GENRE_KEYS.map((k) => GENRE_PRESETS[k].animClass)
    expect(new Set(classes).size).toBe(classes.length)
  })

  it('animClass values match the ad-neon-{genre} convention', () => {
    for (const k of GENRE_KEYS) {
      expect(GENRE_PRESETS[k].animClass).toBe(`ad-neon-${k}`)
    }
  })
})
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run packages/ui/offers/genrePresets.test.js Expected: FAIL.

  • [ ] Step 3: Write the implementation
js
// packages/ui/offers/genrePresets.js
/**
 * Genre neon presets โ€” ported from the design handoff in
 * /tmp/lantern_design/ad-placement-design/project/ad-variations.jsx
 * (HappeningGenres object, lines ~498โ€“599).
 *
 * Each preset is consumed by GenreNeonWrap to build the parameterized
 * neon-stack box-shadow that wraps StorefrontShell during preroll/live.
 *
 * Animation classes (`ad-neon-{genre}`) must be defined in cards.css.
 */
export const GENRE_PRESETS = {
  disco: {
    label: 'Disco ยท Live',
    border: 'rgba(217,70,239,0.50)',
    interior:
      'radial-gradient(ellipse 130% 80% at 50% 110%, rgba(217,70,239,0.32) 0%, rgba(34,211,238,0.18) 40%, rgba(24,24,27,0.55) 75%), ' +
      'linear-gradient(140deg, rgba(34,211,238,0.12) 0%, rgba(217,70,239,0.08) 55%, rgba(24,24,27,0.5) 100%)',
    shadow:
      'inset 0 0 10px rgba(34,211,238,0.40), ' +
      '0 0 0 1.5px rgba(34,211,238,0.85), ' +
      '0 0 12px rgba(34,211,238,0.70), ' +
      '0 0 32px rgba(217,70,239,0.40), ' +
      '0 0 64px rgba(250,204,21,0.25)',
    animClass: 'ad-neon-disco',
    accent: '#22d3ee',
    accentDot: '#d946ef',
    venueColor: '#67e8f9',
  },
  edm: {
    label: 'EDM ยท Live',
    border: 'rgba(139,92,246,0.65)',
    interior:
      'radial-gradient(ellipse 130% 90% at 50% 110%, rgba(139,92,246,0.40) 0%, rgba(34,211,238,0.18) 35%, rgba(24,24,27,0.6) 75%), ' +
      'linear-gradient(140deg, rgba(139,92,246,0.16) 0%, rgba(34,211,238,0.10) 55%, rgba(24,24,27,0.5) 100%)',
    shadow:
      'inset 0 0 12px rgba(139,92,246,0.50), ' +
      '0 0 0 1.5px rgba(139,92,246,1), ' +
      '0 0 16px rgba(139,92,246,0.85), ' +
      '0 0 40px rgba(34,211,238,0.55), ' +
      '0 0 80px rgba(217,70,239,0.40)',
    animClass: 'ad-neon-edm',
    accent: '#8b5cf6',
    accentDot: '#22d3ee',
    venueColor: '#c4b5fd',
  },
  rock: {
    label: 'Rock ยท Live',
    border: 'rgba(220,38,38,0.65)',
    interior:
      'radial-gradient(ellipse 130% 90% at 50% 110%, rgba(220,38,38,0.45) 0%, rgba(251,113,133,0.20) 40%, rgba(24,24,27,0.6) 75%), ' +
      'linear-gradient(140deg, rgba(220,38,38,0.18) 0%, rgba(127,29,29,0.12) 55%, rgba(24,24,27,0.5) 100%)',
    shadow:
      'inset 0 0 10px rgba(220,38,38,0.40), ' +
      '0 0 0 1.5px rgba(220,38,38,0.90), ' +
      '0 0 14px rgba(220,38,38,0.70), ' +
      '0 0 36px rgba(251,113,133,0.40), ' +
      '0 0 64px rgba(127,29,29,0.30)',
    animClass: 'ad-neon-rock',
    accent: '#dc2626',
    accentDot: '#f87171',
    venueColor: '#fca5a5',
  },
  jazz: {
    label: 'Jazz ยท Live',
    border: 'rgba(103,232,249,0.40)',
    interior:
      'radial-gradient(ellipse 110% 70% at 50% 110%, rgba(103,232,249,0.22) 0%, rgba(91,33,182,0.16) 40%, rgba(24,24,27,0.6) 75%), ' +
      'linear-gradient(140deg, rgba(103,232,249,0.10) 0%, rgba(91,33,182,0.10) 55%, rgba(24,24,27,0.55) 100%)',
    shadow:
      'inset 0 0 8px rgba(103,232,249,0.30), ' +
      '0 0 0 1px rgba(103,232,249,0.60), ' +
      '0 0 8px rgba(103,232,249,0.40), ' +
      '0 0 24px rgba(91,33,182,0.30), ' +
      '0 0 48px rgba(15,23,42,0.20)',
    animClass: 'ad-neon-jazz',
    accent: '#67e8f9',
    accentDot: '#a78bfa',
    venueColor: '#bae6fd',
  },
  hiphop: {
    label: 'Hip-hop / R&B ยท Live',
    border: 'rgba(217,70,239,0.55)',
    interior:
      'radial-gradient(ellipse 130% 85% at 50% 110%, rgba(217,70,239,0.32) 0%, rgba(139,92,246,0.20) 40%, rgba(24,24,27,0.6) 75%), ' +
      'linear-gradient(140deg, rgba(217,70,239,0.14) 0%, rgba(59,130,246,0.10) 55%, rgba(24,24,27,0.5) 100%)',
    shadow:
      'inset 0 0 12px rgba(217,70,239,0.45), ' +
      '0 0 0 1.5px rgba(217,70,239,0.85), ' +
      '0 0 14px rgba(217,70,239,0.70), ' +
      '0 0 36px rgba(139,92,246,0.45), ' +
      '0 0 72px rgba(59,130,246,0.30)',
    animClass: 'ad-neon-hiphop',
    accent: '#d946ef',
    accentDot: '#8b5cf6',
    venueColor: '#f5d0fe',
  },
}

export const GENRE_KEYS = Object.keys(GENRE_PRESETS)
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run packages/ui/offers/genrePresets.test.js Expected: PASS โ€” 8 tests (5 parameterized + 3).

  • [ ] Step 5: Commit
bash
git add packages/ui/offers/genrePresets.js packages/ui/offers/genrePresets.test.js
git commit -m "feat(ui/offers): add genre presets registry (disco/edm/rock/jazz/hiphop)"

Task 5: CSS keyframes โ€‹

Files:

  • Modify: packages/ui/offers/cards.css

  • [ ] Step 1: Append keyframes

Append to the end of packages/ui/offers/cards.css:

css
/* โ”€โ”€ Genre neon animations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
   Each genre rotates its own box-shadow stack to keep the tube color
   consistent across all five layers. Pacing is tuned to genre energy
   (jazz slow / EDM strobe / rock heavy pulse).
   โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */

@keyframes ad-neon-disco-kf {
  0%, 100% { box-shadow:
    inset 0 0 10px rgba(34,211,238,0.40),
    0 0 0 1.5px rgba(34,211,238,0.85),
    0 0 12px rgba(34,211,238,0.70),
    0 0 32px rgba(217,70,239,0.40),
    0 0 64px rgba(250,204,21,0.25); }
  33% { box-shadow:
    inset 0 0 10px rgba(217,70,239,0.40),
    0 0 0 1.5px rgba(217,70,239,0.85),
    0 0 12px rgba(217,70,239,0.70),
    0 0 32px rgba(250,204,21,0.40),
    0 0 64px rgba(34,211,238,0.25); }
  66% { box-shadow:
    inset 0 0 10px rgba(250,204,21,0.40),
    0 0 0 1.5px rgba(250,204,21,0.85),
    0 0 12px rgba(250,204,21,0.70),
    0 0 32px rgba(34,211,238,0.40),
    0 0 64px rgba(217,70,239,0.25); }
}
.ad-neon-disco { animation: ad-neon-disco-kf 6s ease-in-out infinite; }

@keyframes ad-neon-edm-kf {
  0%, 100% { filter: brightness(1); }
  50%      { filter: brightness(1.4); }
}
.ad-neon-edm { animation: ad-neon-edm-kf 0.9s ease-in-out infinite; }

@keyframes ad-neon-rock-kf {
  0%, 100% { box-shadow:
    inset 0 0 10px rgba(220,38,38,0.40),
    0 0 0 1.5px rgba(220,38,38,0.90),
    0 0 14px rgba(220,38,38,0.70),
    0 0 36px rgba(251,113,133,0.40),
    0 0 64px rgba(127,29,29,0.30); }
  50% { box-shadow:
    inset 0 0 14px rgba(220,38,38,0.55),
    0 0 0 2px rgba(220,38,38,1),
    0 0 18px rgba(220,38,38,0.90),
    0 0 48px rgba(251,113,133,0.55),
    0 0 80px rgba(127,29,29,0.45); }
}
.ad-neon-rock { animation: ad-neon-rock-kf 1.8s ease-in-out infinite; }

@keyframes ad-neon-jazz-kf {
  0%, 100% { filter: brightness(1); }
  50%      { filter: brightness(1.08); }
}
.ad-neon-jazz { animation: ad-neon-jazz-kf 5s ease-in-out infinite; }

@keyframes ad-neon-hiphop-kf {
  0%, 100% { box-shadow:
    inset 0 0 12px rgba(217,70,239,0.45),
    0 0 0 1.5px rgba(217,70,239,0.85),
    0 0 14px rgba(217,70,239,0.70),
    0 0 36px rgba(139,92,246,0.45),
    0 0 72px rgba(59,130,246,0.30); }
  50% { box-shadow:
    inset 0 0 12px rgba(139,92,246,0.45),
    0 0 0 1.5px rgba(139,92,246,0.85),
    0 0 14px rgba(139,92,246,0.70),
    0 0 36px rgba(59,130,246,0.45),
    0 0 72px rgba(217,70,239,0.30); }
}
.ad-neon-hiphop { animation: ad-neon-hiphop-kf 4.5s ease-in-out infinite; }

/* Pulsing red dot for the LIVE badge */
@keyframes ad-live-pulse-kf {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.4; }
}
.ad-live-pulse { animation: ad-live-pulse-kf 1.2s ease-in-out infinite; }

/* Reduced motion โ€” kill the genre keyframes for accessibility */
@media (prefers-reduced-motion: reduce) {
  .ad-neon-disco, .ad-neon-edm, .ad-neon-rock, .ad-neon-jazz, .ad-neon-hiphop,
  .ad-live-pulse { animation: none !important; }
}
  • [ ] Step 2: Verify the package still builds

Run: npm run -w packages/ui build 2>&1 | tail -10 (if the package has a build script โ€” otherwise skip)

If packages/ui has no build script, run: node -e "require('fs').readFileSync('packages/ui/offers/cards.css','utf8')" to confirm the file parses.

  • [ ] Step 3: Commit
bash
git add packages/ui/offers/cards.css
git commit -m "feat(ui/offers): add genre neon keyframes + live-pulse"

Task 6: StorefrontShell (B2 chip + photo/gradient header) โ€‹

Files:

  • Create: packages/ui/offers/StorefrontShell.jsx

This is the always-on chrome. Lifted from VStoreB2 in the design handoff (ad-variations.jsx lines ~856โ€“874, plus StoreHeader/StoreBody lines ~808โ€“830).

  • [ ] Step 1: Implementation
jsx
// packages/ui/offers/StorefrontShell.jsx
import React from 'react'
import { Megaphone, MapPin } from 'lucide-react'
import { FlamePulse } from './OfferCards.jsx'

/**
 * StorefrontShell โ€” full-bleed top image header + B2 Corner Tag chip + body.
 * The always-on chrome shared by all hero render states.
 *
 * Renders:
 *   - Photo from `photoUrl` if provided, otherwise the design's warm radial
 *     gradient fallback (matches `StoreHeader` in the handoff bundle).
 *   - B2 Corner Tag chip (solid amber tab in top-left rounded corner).
 *   - Optional "lit" pill in top-right when lanternCount > 0.
 *   - Body: headline + venue row (or `headlineOverride`/`subOverride` for events).
 */
export function StorefrontShell({
  photoUrl,
  headline,
  subline,
  venueName,
  distanceLabel,
  lanternCount,
  badge,            // optional ReactNode rendered top-right (replaces lit pill when present)
  hideVenueLine,    // suppress the venue/distance row (e.g. when caller already shows it)
  children,         // optional extra content above the body
}) {
  return (
    <div className="rounded-2xl border border-white/5 bg-zinc-900/70 backdrop-blur-md overflow-hidden relative">
      <div
        className="relative h-[110px] overflow-hidden"
        style={
          photoUrl
            ? { backgroundImage: `url(${photoUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }
            : {
                background:
                  'radial-gradient(circle at 30% 40%, rgba(251,146,60,0.55), rgba(124,45,18,0.4) 50%, #18181b 90%)',
              }
        }
      >
        {!photoUrl && (
          <div
            className="absolute inset-0"
            style={{
              background:
                'radial-gradient(circle at 75% 70%, rgba(255,200,100,0.3), transparent 55%)',
            }}
          />
        )}
        <div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-zinc-900 to-transparent" />

        {/* B2 Corner Tag โ€” solid amber, top-left */}
        <div className="absolute top-0 left-0 inline-flex items-center gap-1.5 h-7 pl-3 pr-3 rounded-br-xl bg-amber-500 text-black">
          <Megaphone size={11} aria-hidden />
          <span className="text-[10px] font-bold uppercase tracking-[0.14em] leading-none">
            Sponsored
          </span>
        </div>

        {/* Top-right slot: custom badge (countdown / live) or lit pill */}
        {badge ? (
          <div className="absolute top-3 right-3">{badge}</div>
        ) : lanternCount > 0 ? (
          <div className="absolute top-3 right-3 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-black/70 backdrop-blur-md border border-amber-500/40">
            <span className="text-amber-400 leading-none">
              <FlamePulse size={11} opacity={0.95} />
            </span>
            <span className="text-[10px] font-bold text-amber-300 leading-none translate-y-[0.5px]">
              {lanternCount} LIT
            </span>
          </div>
        ) : null}
      </div>

      <div className="p-4 pt-2">
        <h3 className="text-white text-[22px] font-bold leading-[1.15] tracking-tight">
          {headline}
        </h3>
        {subline && <p className="text-white/70 text-sm mt-1">{subline}</p>}
        {!hideVenueLine && venueName && (
          <div className="flex items-center gap-1.5 mt-1.5 text-[13px]">
            <MapPin size={12} className="text-zinc-400" aria-hidden />
            <span className="text-white/85 font-medium">{venueName}</span>
            {distanceLabel && <span className="text-zinc-500">ยท {distanceLabel}</span>}
          </div>
        )}
        {children}
      </div>
    </div>
  )
}
  • [ ] Step 2: Commit
bash
git add packages/ui/offers/StorefrontShell.jsx
git commit -m "feat(ui/offers): add StorefrontShell (B2 chip + photo/gradient header)"

Task 7: GenreNeonWrap โ€‹

Files:

  • Create: packages/ui/offers/GenreNeonWrap.jsx

  • [ ] Step 1: Implementation

jsx
// packages/ui/offers/GenreNeonWrap.jsx
import React from 'react'
import { GENRE_PRESETS } from './genrePresets.js'

/**
 * Wraps `children` (a StorefrontShell) with the parameterized neon-stack
 * border + box-shadow for a given genre, plus the genre's keyframe class.
 *
 * Static box-shadow is rendered inline so the card has the right glow
 * even before the keyframe rotates layers (or when prefers-reduced-motion
 * is on and the animation is disabled in CSS).
 */
export function GenreNeonWrap({ genre, children }) {
  const preset = GENRE_PRESETS[genre]
  if (!preset) return children
  return (
    <div
      className={preset.animClass}
      style={{
        borderRadius: 16,
        boxShadow: preset.shadow,
        outline: `1.5px solid ${preset.border}`,
        outlineOffset: -1.5,
      }}
    >
      {children}
    </div>
  )
}
  • [ ] Step 2: Commit
bash
git add packages/ui/offers/GenreNeonWrap.jsx
git commit -m "feat(ui/offers): add GenreNeonWrap component"

Task 8: CountdownBadge and LiveBadge โ€‹

Files:

  • Create: packages/ui/offers/CountdownBadge.jsx

  • Create: packages/ui/offers/LiveBadge.jsx

  • [ ] Step 1: Implementations

jsx
// packages/ui/offers/CountdownBadge.jsx
import React, { useEffect, useState } from 'react'
import { Clock } from 'lucide-react'
import { formatCountdown } from './formatCountdown.js'

/**
 * Re-renders every 30s (cheap; only mounted while a card is in preroll).
 * Pass `now` to override the clock for previews.
 */
export function CountdownBadge({ startsAt, now, accent }) {
  const [tick, setTick] = useState(0)
  useEffect(() => {
    if (now != null) return undefined
    const id = setInterval(() => setTick((t) => t + 1), 30_000)
    return () => clearInterval(id)
  }, [now])

  const current = now ?? Date.now()
  const remaining = +new Date(startsAt) - current
  // Reference `tick` so React keeps this re-rendering when the timer fires
  void tick

  return (
    <div
      className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-black/70 backdrop-blur-md border"
      style={{ borderColor: accent || 'rgba(255,255,255,0.3)' }}
    >
      <Clock size={11} style={{ color: accent || '#fff' }} aria-hidden />
      <span
        className="text-[10px] font-bold leading-none translate-y-[0.5px]"
        style={{ color: accent || '#fff' }}
      >
        {formatCountdown(remaining).toUpperCase()}
      </span>
    </div>
  )
}
jsx
// packages/ui/offers/LiveBadge.jsx
import React from 'react'

/**
 * Pulsing red dot + "LIVE" label. Use during the active event window.
 */
export function LiveBadge() {
  return (
    <div className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-black/80 backdrop-blur-md border border-red-500/60">
      <span
        aria-hidden
        className="ad-live-pulse inline-block w-2 h-2 rounded-full bg-red-500"
        style={{ boxShadow: '0 0 8px rgba(239,68,68,0.9)' }}
      />
      <span className="text-[10px] font-bold uppercase tracking-[0.14em] text-red-100 leading-none translate-y-[0.5px]">
        Live
      </span>
    </div>
  )
}
  • [ ] Step 2: Commit
bash
git add packages/ui/offers/CountdownBadge.jsx packages/ui/offers/LiveBadge.jsx
git commit -m "feat(ui/offers): add CountdownBadge and LiveBadge"

Task 9: Refactor HeroOfferCard to use the new components โ€‹

Files:

  • Modify: packages/ui/offers/OfferCards.jsx
  • Modify: packages/ui/offers/index.js

Replace the existing HeroOfferCard body. Keep OfferPill, ChatOfferPill, FeedOfferCard, and formatLanternCount unchanged. Keep FlamePulse exported (imported by StorefrontShell).

  • [ ] Step 1: Read current HeroOfferCard

Open packages/ui/offers/OfferCards.jsx. Locate the HeroOfferCard export (currently around line 149+). Confirm it renders amber/gradient header + offer text directly inline.

  • [ ] Step 2: Replace HeroOfferCard export

In packages/ui/offers/OfferCards.jsx, replace the existing HeroOfferCard definition with:

jsx
import { StorefrontShell } from './StorefrontShell.jsx'
import { GenreNeonWrap } from './GenreNeonWrap.jsx'
import { CountdownBadge } from './CountdownBadge.jsx'
import { LiveBadge } from './LiveBadge.jsx'
import { getHeroState } from './getHeroState.js'
import { resolvePhotoUrl } from './resolvePhotoUrl.js'
import { GENRE_PRESETS } from './genrePresets.js'

export const HeroOfferCard = ({ offer, venue, now, hideVenueLine, onSelect, onTrack }) => {
  const state = getHeroState(offer, now)
  const photoUrl = resolvePhotoUrl(offer, venue ?? offer.venue)
  const venueName = (venue ?? offer.venue)?.name || 'Unknown venue'
  const distanceLabel = (venue ?? offer.venue)?.distance || null

  // Standard render โ€” no genre wrap, no countdown/live badge.
  if (state === 'standard') {
    return (
      <button
        type="button"
        onClick={() => { onTrack?.(offer); onSelect?.(offer.venue) }}
        className="w-full text-left"
      >
        <StorefrontShell
          photoUrl={photoUrl}
          headline={offer.headline || offer.title || ''}
          subline={offer.body || offer.description || null}
          venueName={venueName}
          distanceLabel={distanceLabel}
          lanternCount={offer.lanternCount || 0}
          hideVenueLine={hideVenueLine}
        />
      </button>
    )
  }

  // preroll | live โ€” wrap with genre neon
  const genre = offer.liveEvent?.genre
  const preset = GENRE_PRESETS[genre]
  const liveHeadline = offer.liveEvent?.liveHeadline || offer.headline || offer.title
  const liveSubcopy = offer.liveEvent?.liveSubcopy || offer.body || offer.description

  const badge =
    state === 'live' ? (
      <LiveBadge />
    ) : (
      <CountdownBadge
        startsAt={offer.liveEvent.startsAt}
        now={now}
        accent={preset?.accent}
      />
    )

  return (
    <button
      type="button"
      onClick={() => { onTrack?.(offer); onSelect?.(offer.venue) }}
      className="w-full text-left"
    >
      <GenreNeonWrap genre={genre}>
        <StorefrontShell
          photoUrl={photoUrl}
          headline={liveHeadline}
          subline={liveSubcopy}
          venueName={venueName}
          distanceLabel={distanceLabel}
          lanternCount={offer.lanternCount || 0}
          badge={badge}
          hideVenueLine={hideVenueLine}
        />
      </GenreNeonWrap>
    </button>
  )
}
  • [ ] Step 3: Update index.js exports

Replace packages/ui/offers/index.js contents with:

js
export {
  HeroOfferCard,
  OfferPill,
  ChatOfferPill,
  FeedOfferCard,
  FlamePulse,
  formatLanternCount,
  isRedundantIncentive,
} from './OfferCards.jsx'

export { default as AdSlot } from './AdSlot.jsx'

export { getHeroState } from './getHeroState.js'
export { resolvePhotoUrl } from './resolvePhotoUrl.js'
export { GENRE_PRESETS, GENRE_KEYS } from './genrePresets.js'
export { formatCountdown } from './formatCountdown.js'
  • [ ] Step 4: Run the full UI offers test suite

Run: npx vitest run packages/ui/offers Expected: All previously-passing tests still pass; new tests from Tasks 1โ€“4 pass.

  • [ ] Step 5: Commit
bash
git add packages/ui/offers/OfferCards.jsx packages/ui/offers/index.js
git commit -m "refactor(ui/offers): split HeroOfferCard into state-driven composition"

Phase 2 โ€” Backend schema โ€‹

Task 10: Extend Zod schemas in merchants-api โ€‹

Files:

  • Modify: services/api/merchants/src/routes/offers.js

  • Create: services/api/merchants/src/routes/__tests__/offers.themedRails.test.js

  • [ ] Step 1: Write the failing schema test

js
// services/api/merchants/src/routes/__tests__/offers.themedRails.test.js
import { describe, it, expect } from 'vitest'
import { CreateOfferSchema, UpdateOfferSchema } from '../offers.js'

const baseOffer = {
  venueId: 'v1',
  title: 'Live jazz Friday',
  description: 'Yael Trio โ€” no cover',
  placement: 'hero',
  targetAudience: 'nearby',
  radius: 500,
  budget: 50,
  expiresAt: '2026-12-01T00:00:00.000Z',
}

const validLiveEvent = {
  genre: 'jazz',
  startsAt: '2026-06-01T21:00:00.000Z',
  endsAt: '2026-06-01T23:00:00.000Z',
}

describe('CreateOfferSchema โ€” themed-rails fields', () => {
  it('accepts an offer without liveEvent or photo', () => {
    expect(() => CreateOfferSchema.parse(baseOffer)).not.toThrow()
  })

  it('accepts a valid liveEvent', () => {
    expect(() => CreateOfferSchema.parse({ ...baseOffer, liveEvent: validLiveEvent })).not.toThrow()
  })

  it('rejects unknown genre', () => {
    expect(() =>
      CreateOfferSchema.parse({ ...baseOffer, liveEvent: { ...validLiveEvent, genre: 'country' } }),
    ).toThrow()
  })

  it('rejects endsAt <= startsAt', () => {
    expect(() =>
      CreateOfferSchema.parse({
        ...baseOffer,
        liveEvent: { ...validLiveEvent, endsAt: validLiveEvent.startsAt },
      }),
    ).toThrow(/endsAt must be after startsAt/)
  })

  it('rejects endsAt > expiresAt', () => {
    expect(() =>
      CreateOfferSchema.parse({
        ...baseOffer,
        expiresAt: '2026-06-01T22:00:00.000Z',
        liveEvent: validLiveEvent,
      }),
    ).toThrow(/endsAt must be on or before expiresAt/)
  })

  it('accepts heroPhotoUrl', () => {
    expect(() =>
      CreateOfferSchema.parse({
        ...baseOffer,
        heroPhotoUrl: 'https://firebasestorage.googleapis.com/v0/b/x/o/merchants%2Fm1%2Foffers%2Fo1%2Fhero.jpg',
      }),
    ).not.toThrow()
  })

  it('rejects non-https heroPhotoUrl', () => {
    expect(() =>
      CreateOfferSchema.parse({ ...baseOffer, heroPhotoUrl: 'ftp://x/y.jpg' }),
    ).toThrow()
  })

  it('useGradientFallback defaults to false', () => {
    const parsed = CreateOfferSchema.parse(baseOffer)
    expect(parsed.useGradientFallback).toBe(false)
  })
})

describe('UpdateOfferSchema โ€” themed-rails fields', () => {
  it('accepts a partial update with only liveEvent', () => {
    expect(() => UpdateOfferSchema.parse({ liveEvent: validLiveEvent })).not.toThrow()
  })

  it('accepts clearing liveEvent via null', () => {
    expect(() => UpdateOfferSchema.parse({ liveEvent: null })).not.toThrow()
  })
})
  • [ ] Step 2: Run test to verify it fails

Run: cd services/api/merchants && npx vitest run src/routes/__tests__/offers.themedRails.test.js Expected: FAIL โ€” schemas missing the new fields.

  • [ ] Step 3: Read the existing schema block

Open services/api/merchants/src/routes/offers.js. Locate CreateOfferSchema (line 24+) and any UpdateOfferSchema. If UpdateOfferSchema doesn't exist, scan the PUT handler around line 171 to see how partial updates are validated โ€” it likely uses CreateOfferSchema.partial() inline.

  • [ ] Step 4: Modify schemas

In services/api/merchants/src/routes/offers.js, replace the schema definitions with:

js
const LiveEventSchema = 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(),
}).refine(
  (v) => +new Date(v.endsAt) > +new Date(v.startsAt),
  { message: 'endsAt must be after startsAt', path: ['endsAt'] },
)

export const CreateOfferSchema = z.object({
  venueId: z.string().min(1),
  title: z.string().min(1).max(200),
  description: z.string().min(1).max(2000),
  placement: z.enum(['hero', 'inline', 'chat', 'feed']),
  targetAudience: z.enum(['nearby', 'lantern', 'frequent', 'new']),
  radius: z.number().int().min(10).max(2500),
  per_user_limit: z.number().int().min(1).default(1),
  budget: z.number().min(1),
  expiresAt: z.string().datetime(),
  showDisclaimerWhileSuppliesLast: z.boolean().default(false),
  status: z.enum(['draft', 'active']).default('draft'),
  liveEvent: LiveEventSchema.optional(),
  heroPhotoUrl: z.string().url().refine((u) => u.startsWith('https://'), {
    message: 'heroPhotoUrl must be HTTPS',
  }).optional(),
  useGradientFallback: z.boolean().default(false),
}).refine(
  (v) => !v.liveEvent || +new Date(v.liveEvent.endsAt) <= +new Date(v.expiresAt),
  { message: 'endsAt must be on or before expiresAt', path: ['liveEvent', 'endsAt'] },
)

export const UpdateOfferSchema = CreateOfferSchema._def.schema.partial().extend({
  // Allow explicit null to clear the live event on update.
  liveEvent: LiveEventSchema.nullable().optional(),
})

(Note: CreateOfferSchema is wrapped in .refine() โ€” to call .partial() we reach for the underlying object via ._def.schema. If the existing PUT handler is calling .partial() directly on CreateOfferSchema, update it to import UpdateOfferSchema instead.)

In the PUT handler (around line 171), change const body = CreateOfferSchema.partial().parse(req.body) (or equivalent) to const body = UpdateOfferSchema.parse(req.body).

  • [ ] Step 5: Run test to verify it passes

Run: cd services/api/merchants && npx vitest run src/routes/__tests__/offers.themedRails.test.js Expected: PASS.

  • [ ] Step 6: Run full merchants-api test suite to confirm no regression

Run: cd services/api/merchants && npx vitest run Expected: all green.

  • [ ] Step 7: Commit
bash
git add services/api/merchants/src/routes/offers.js services/api/merchants/src/routes/__tests__/offers.themedRails.test.js
git commit -m "feat(merchants-api): add liveEvent + heroPhotoUrl to offer schema"

Task 11: Wire normalizer to pass new fields through โ€‹

Files:

  • Modify: packages/shared/lib/offerNormalizer.js

  • Modify: packages/shared/__tests__/lib/offerNormalizer.test.js

  • [ ] Step 1: Add failing test cases

Append to packages/shared/__tests__/lib/offerNormalizer.test.js:

js
import { describe, it, expect } from 'vitest'
import { normalizeFormToOffer } from '../../lib/offerNormalizer.js'

describe('normalizeFormToOffer โ€” themed rails', () => {
  it('passes liveEvent through', () => {
    const evt = { genre: 'jazz', startsAt: '2026-06-01T21:00:00.000Z', endsAt: '2026-06-01T23:00:00.000Z' }
    const result = normalizeFormToOffer({ title: 'x', liveEvent: evt })
    expect(result.liveEvent).toEqual(evt)
  })

  it('passes heroPhotoUrl through', () => {
    const result = normalizeFormToOffer({ title: 'x', heroPhotoUrl: 'https://x/y.jpg' })
    expect(result.heroPhotoUrl).toBe('https://x/y.jpg')
  })

  it('passes useGradientFallback through', () => {
    const result = normalizeFormToOffer({ title: 'x', useGradientFallback: true })
    expect(result.useGradientFallback).toBe(true)
  })

  it('omits liveEvent when not set', () => {
    const result = normalizeFormToOffer({ title: 'x' })
    expect('liveEvent' in result).toBe(false)
  })
})
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run packages/shared/__tests__/lib/offerNormalizer.test.js Expected: FAIL.

  • [ ] Step 3: Modify normalizeFormToOffer

In packages/shared/lib/offerNormalizer.js, update the return object inside normalizeFormToOffer:

js
const out = {
  id: options.id || `preview_${Date.now()}`,
  locationId: options.venueId || null,
  headline: title || 'Your offer title',
  body: formData.description || 'Your offer description will appear here.',
  incentive,
  priority: 0,
  placement: formData.placement || 'hero',
  startAt: new Date().toISOString(),
  endAt: new Date(Date.now() + 30 * 86400000).toISOString(),
  badge: options.badge || 'Preview',
  lanternCount: options.lanternCount || 0,
  showDisclaimerWhileSuppliesLast: formData.showDisclaimerWhileSuppliesLast || false,
  isPreview: true,
  venue: options.venue || { name: 'Your Venue', distance: 'nearby', category: 'cafe' },
  useGradientFallback: !!formData.useGradientFallback,
}
if (formData.liveEvent) out.liveEvent = formData.liveEvent
if (formData.heroPhotoUrl) out.heroPhotoUrl = formData.heroPhotoUrl
return out

(Replace the entire return { ... } block with the above.)

  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run packages/shared/__tests__/lib/offerNormalizer.test.js Expected: PASS.

  • [ ] Step 5: Sync the admin and web copies of offerNormalizer.js

Two app-local copies exist (admin: apps/admin/src/shared/lib/offerNormalizer.js, web: apps/web/src/lib/offerNormalizer.js). They are duplicates of the shared one. Apply the same changes to both files. Run:

bash
cp packages/shared/lib/offerNormalizer.js apps/admin/src/shared/lib/offerNormalizer.js
cp packages/shared/lib/offerNormalizer.js apps/web/src/lib/offerNormalizer.js

(If the cp produces a diff that breaks any consumer, prefer manual diff-and-merge, but in this case the files are pure forks with no app-specific edits.)

  • [ ] Step 6: Commit
bash
git add packages/shared/lib/offerNormalizer.js packages/shared/__tests__/lib/offerNormalizer.test.js apps/admin/src/shared/lib/offerNormalizer.js apps/web/src/lib/offerNormalizer.js
git commit -m "feat(shared): pass liveEvent and heroPhotoUrl through offer normalizer"

Phase 3 โ€” Create Offer form changes โ€‹

Task 12: Image crop+compress helper โ€‹

Files:

  • Create: apps/admin/src/shared/lib/cropAndCompressImage.js
  • Create: apps/admin/src/shared/lib/cropAndCompressImage.test.js

This helper takes a File (image), center-crops to 16:9, scales to a max width, and re-encodes JPEG at the configured quality. Pure browser API โ€” no new deps.

  • [ ] Step 1: Write the test
js
// apps/admin/src/shared/lib/cropAndCompressImage.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { cropAndCompressImage } from './cropAndCompressImage.js'

// Mock document.createElement('img') and 'canvas' so we can assert on dimensions.
beforeEach(() => {
  globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock')
  globalThis.URL.revokeObjectURL = vi.fn()
})

function makeImageMock(width, height) {
  const img = {
    width, height,
    onload: null, onerror: null,
    set src(_v) { setTimeout(() => this.onload && this.onload(), 0) },
  }
  return img
}

function makeCanvasMock(captureRef) {
  const ctx = { drawImage: vi.fn() }
  return {
    width: 0, height: 0,
    getContext: () => ctx,
    toBlob: (cb) => cb(new Blob(['fake'], { type: 'image/jpeg' })),
    _ctx: ctx,
    _capture: (w, h) => { captureRef.w = w; captureRef.h = h },
  }
}

describe('cropAndCompressImage', () => {
  it('center-crops a 1200x1200 image to 16:9', async () => {
    const captured = {}
    vi.spyOn(document, 'createElement').mockImplementation((tag) => {
      if (tag === 'img') return makeImageMock(1200, 1200)
      if (tag === 'canvas') {
        const c = makeCanvasMock(captured)
        Object.defineProperty(c, 'width', { set(v) { captured.w = v }, get() { return captured.w } })
        Object.defineProperty(c, 'height', { set(v) { captured.h = v }, get() { return captured.h } })
        return c
      }
      return {}
    })
    const blob = await cropAndCompressImage(new File(['x'], 'x.jpg', { type: 'image/jpeg' }))
    expect(blob.type).toBe('image/jpeg')
    // 16:9 from 1200ร—1200 source = 1200ร—675 output (width preserved, height clipped)
    expect(captured.w).toBe(1200)
    expect(captured.h).toBe(675)
  })
})
  • [ ] Step 2: Run test (expected to fail)

Run: cd apps/admin && npx vitest run src/shared/lib/cropAndCompressImage.test.js Expected: FAIL โ€” module not found.

  • [ ] Step 3: Implementation
js
// apps/admin/src/shared/lib/cropAndCompressImage.js
/**
 * Loads `file` (image), center-crops to 16:9, optionally caps width,
 * and re-encodes JPEG at `quality`. Returns a Blob.
 *
 * Used by the OfferForm hero-photo uploader and the venue hero-photo uploader.
 */
export async function cropAndCompressImage(file, { maxWidth = 1280, quality = 0.85 } = {}) {
  const url = URL.createObjectURL(file)
  try {
    const img = await loadImage(url)
    const targetAR = 16 / 9
    const srcAR = img.width / img.height
    let sx, sy, sw, sh
    if (srcAR > targetAR) {
      // Source is wider than 16:9 โ€” crop sides
      sh = img.height
      sw = Math.round(sh * targetAR)
      sx = Math.round((img.width - sw) / 2)
      sy = 0
    } else {
      // Source is taller โ€” crop top/bottom
      sw = img.width
      sh = Math.round(sw / targetAR)
      sx = 0
      sy = Math.round((img.height - sh) / 2)
    }
    const outW = Math.min(maxWidth, sw)
    const outH = Math.round(outW / targetAR)
    const canvas = document.createElement('canvas')
    canvas.width = outW
    canvas.height = outH
    const ctx = canvas.getContext('2d')
    ctx.drawImage(img, sx, sy, sw, sh, 0, 0, outW, outH)
    return await new Promise((resolve, reject) => {
      canvas.toBlob(
        (b) => (b ? resolve(b) : reject(new Error('toBlob produced null'))),
        'image/jpeg',
        quality,
      )
    })
  } finally {
    URL.revokeObjectURL(url)
  }
}

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const img = document.createElement('img')
    img.onload = () => resolve(img)
    img.onerror = (e) => reject(e)
    img.src = src
  })
}
  • [ ] Step 4: Run test to verify it passes

Run: cd apps/admin && npx vitest run src/shared/lib/cropAndCompressImage.test.js Expected: PASS.

  • [ ] Step 5: Commit
bash
git add apps/admin/src/shared/lib/cropAndCompressImage.js apps/admin/src/shared/lib/cropAndCompressImage.test.js
git commit -m "feat(admin): add cropAndCompressImage helper (16:9 + JPEG)"

Task 13: LiveEventSection component โ€‹

Files:

  • Create: apps/admin/src/merchant/offers/LiveEventSection.jsx

  • [ ] Step 1: Implementation

jsx
// apps/admin/src/merchant/offers/LiveEventSection.jsx
import React, { useState } from 'react'
import StyledSelect from '../../shared/components/StyledSelect'

const GENRE_OPTIONS = [
  { value: 'disco', label: 'Disco' },
  { value: 'edm', label: 'EDM' },
  { value: 'rock', label: 'Rock' },
  { value: 'jazz', label: 'Jazz' },
  { value: 'hiphop', label: 'Hip-hop / R&B' },
]

/**
 * Collapsible "Live event" subform. Owns the toggle that flips
 * `value` between null (no event) and a populated event object.
 */
export default function LiveEventSection({ value, onChange }) {
  const [open, setOpen] = useState(!!value)
  const enabled = !!value

  function update(patch) {
    onChange({ ...(value || {}), ...patch })
  }

  function toggle() {
    if (enabled) {
      onChange(null)
    } else {
      onChange({
        genre: 'jazz',
        startsAt: '',
        endsAt: '',
        liveHeadline: '',
        liveSubcopy: '',
      })
      setOpen(true)
    }
  }

  return (
    <div className="form-card" style={{ marginTop: 'var(--space-3)' }}>
      <button
        type="button"
        className="offer-form-section-header"
        onClick={() => setOpen((o) => !o)}
        aria-expanded={open}
      >
        <span>Live event {enabled && <em>(on)</em>}</span>
        <span aria-hidden>{open ? 'โ–พ' : 'โ–ธ'}</span>
      </button>
      {open && (
        <div className="offer-form-section-body" style={{ paddingTop: 'var(--space-2)' }}>
          <div className="form-group" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
            <input id="le-toggle" type="checkbox" checked={enabled} onChange={toggle} />
            <label htmlFor="le-toggle" style={{ fontSize: '0.875rem' }}>
              Tie this offer to a live event
            </label>
          </div>

          {enabled && (
            <>
              <div className="form-group">
                <label className="form-label">Genre</label>
                <StyledSelect
                  options={GENRE_OPTIONS}
                  value={GENRE_OPTIONS.find((o) => o.value === value.genre) || null}
                  onChange={(s) => update({ genre: s?.value })}
                />
              </div>
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
                <div className="form-group">
                  <label className="form-label">Starts at</label>
                  <input
                    className="form-input"
                    type="datetime-local"
                    value={value.startsAt ? value.startsAt.slice(0, 16) : ''}
                    onChange={(e) => update({ startsAt: new Date(e.target.value).toISOString() })}
                  />
                </div>
                <div className="form-group">
                  <label className="form-label">Ends at</label>
                  <input
                    className="form-input"
                    type="datetime-local"
                    value={value.endsAt ? value.endsAt.slice(0, 16) : ''}
                    onChange={(e) => update({ endsAt: new Date(e.target.value).toISOString() })}
                  />
                </div>
              </div>
              <div className="form-group">
                <label className="form-label">Live headline (optional)</label>
                <input
                  className="form-input"
                  maxLength={200}
                  placeholder="Yael Trio, 9pm"
                  value={value.liveHeadline || ''}
                  onChange={(e) => update({ liveHeadline: e.target.value })}
                />
              </div>
              <div className="form-group">
                <label className="form-label">Live subcopy (optional)</label>
                <input
                  className="form-input"
                  maxLength={200}
                  placeholder="No cover ยท two sets"
                  value={value.liveSubcopy || ''}
                  onChange={(e) => update({ liveSubcopy: e.target.value })}
                />
              </div>
              <p className="text-muted" style={{ fontSize: '0.75rem', marginTop: 'var(--space-2)' }}>
                Live events get rotation precedence (coming soon).
              </p>
            </>
          )}
        </div>
      )}
    </div>
  )
}
  • [ ] Step 2: Add minimal styles for the section header

In whichever stylesheet OfferForm.jsx already uses (search for offer-form-section-header first; if absent, append to the same file as offer-form-grid):

css
.offer-form-section-header {
  display: flex;
  width: 100%;
  align-items: center;
  justify-content: space-between;
  background: transparent;
  border: 0;
  padding: var(--space-2) 0;
  font-weight: 600;
  font-size: 0.875rem;
  cursor: pointer;
}
.offer-form-section-header em {
  font-style: normal;
  color: var(--accent);
  margin-left: 0.5rem;
  font-size: 0.75rem;
}
  • [ ] Step 3: Commit
bash
git add apps/admin/src/merchant/offers/LiveEventSection.jsx
# Plus the CSS file you edited (whichever held offer-form-grid):
git add apps/admin/src/shared/styles/styles.css
git commit -m "feat(admin/offers): add LiveEventSection collapsible subform"

Task 14: HeroPhotoSection component โ€‹

Files:

  • Create: apps/admin/src/merchant/offers/HeroPhotoSection.jsx

  • [ ] Step 1: Implementation

jsx
// apps/admin/src/merchant/offers/HeroPhotoSection.jsx
import React, { useState } from 'react'
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage'
import { storage } from '../../firebase'
import { cropAndCompressImage } from '../../shared/lib/cropAndCompressImage'

/**
 * Three-mode picker for the hero photo:
 *   - 'venue'    โ†’ no per-offer URL; renderer falls back to venue default
 *   - 'custom'   โ†’ upload a new image; URL stored on the offer
 *   - 'gradient' โ†’ setUseGradientFallback(true); ignore venue default
 *
 * Calls onChange with { mode, heroPhotoUrl, useGradientFallback }.
 */
export default function HeroPhotoSection({
  merchantId,
  offerId,                  // string | undefined; undefined for new offers
  venueHeroPhotoUrl,        // string | null
  value,                    // { mode, heroPhotoUrl, useGradientFallback }
  onChange,
}) {
  const [open, setOpen] = useState(true)
  const [uploading, setUploading] = useState(false)
  const [error, setError] = useState(null)

  const venueAvailable = !!venueHeroPhotoUrl
  const mode = value.mode

  function setMode(nextMode) {
    if (nextMode === 'venue') {
      onChange({ mode: 'venue', heroPhotoUrl: '', useGradientFallback: false })
    } else if (nextMode === 'gradient') {
      onChange({ mode: 'gradient', heroPhotoUrl: '', useGradientFallback: true })
    } else {
      onChange({ mode: 'custom', heroPhotoUrl: value.heroPhotoUrl || '', useGradientFallback: false })
    }
  }

  async function handleFile(file) {
    setError(null)
    setUploading(true)
    try {
      const blob = await cropAndCompressImage(file)
      const safeOfferId = offerId || `draft_${Date.now()}`
      const path = `merchants/${merchantId}/offers/${safeOfferId}/hero.jpg`
      const sref = ref(storage, path)
      await uploadBytes(sref, blob, { contentType: 'image/jpeg' })
      const url = await getDownloadURL(sref)
      onChange({ mode: 'custom', heroPhotoUrl: url, useGradientFallback: false })
    } catch (e) {
      setError(e.message || 'Upload failed')
    } finally {
      setUploading(false)
    }
  }

  return (
    <div className="form-card" style={{ marginTop: 'var(--space-3)' }}>
      <button
        type="button"
        className="offer-form-section-header"
        onClick={() => setOpen((o) => !o)}
        aria-expanded={open}
      >
        <span>Hero photo</span>
        <span aria-hidden>{open ? 'โ–พ' : 'โ–ธ'}</span>
      </button>
      {open && (
        <div className="offer-form-section-body" style={{ paddingTop: 'var(--space-2)' }}>
          <div className="form-group" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
            <label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
              <input
                type="radio"
                name="hero-photo-mode"
                checked={mode === 'venue'}
                disabled={!venueAvailable}
                onChange={() => setMode('venue')}
              />
              <span>
                Use venue default {!venueAvailable && <em style={{ color: 'var(--text-muted)' }}>(no venue photo set)</em>}
              </span>
            </label>
            <label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
              <input
                type="radio"
                name="hero-photo-mode"
                checked={mode === 'custom'}
                onChange={() => setMode('custom')}
              />
              <span>Upload custom for this offer</span>
            </label>
            <label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
              <input
                type="radio"
                name="hero-photo-mode"
                checked={mode === 'gradient'}
                onChange={() => setMode('gradient')}
              />
              <span>Use gradient (no photo)</span>
            </label>
          </div>

          {mode === 'custom' && (
            <div className="form-group" style={{ marginTop: 'var(--space-3)' }}>
              <input
                type="file"
                accept="image/jpeg,image/png,image/webp"
                onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
                disabled={uploading}
              />
              {uploading && <p className="text-muted">Uploadingโ€ฆ</p>}
              {error && <p className="form-error">{error}</p>}
              {value.heroPhotoUrl && (
                <img
                  src={value.heroPhotoUrl}
                  alt="Hero preview"
                  style={{ marginTop: 'var(--space-2)', maxWidth: 240, borderRadius: 8 }}
                />
              )}
            </div>
          )}
        </div>
      )}
    </div>
  )
}
  • [ ] Step 2: Commit
bash
git add apps/admin/src/merchant/offers/HeroPhotoSection.jsx
git commit -m "feat(admin/offers): add HeroPhotoSection (radio + uploader)"

Task 15: HeroStateSwitcher component โ€‹

Files:

  • Create: apps/admin/src/merchant/offers/HeroStateSwitcher.jsx

  • [ ] Step 1: Implementation

jsx
// apps/admin/src/merchant/offers/HeroStateSwitcher.jsx
import React from 'react'

const SEGMENTS = [
  { value: 'standard', label: 'Standard' },
  { value: 'preroll',  label: 'Day-of'   },
  { value: 'live',     label: 'Live'     },
]

/**
 * Three-segment control above the hero placement preview. When liveEvent
 * isn't configured, day-of/live segments are disabled.
 */
export default function HeroStateSwitcher({ value, onChange, liveEventConfigured }) {
  return (
    <div className="hero-state-switcher" role="tablist">
      {SEGMENTS.map((s) => {
        const disabled = s.value !== 'standard' && !liveEventConfigured
        return (
          <button
            key={s.value}
            type="button"
            role="tab"
            aria-selected={value === s.value}
            disabled={disabled}
            title={disabled ? 'Set up a live event to preview this state' : undefined}
            className={`hero-state-switcher__segment${value === s.value ? ' is-active' : ''}`}
            onClick={() => onChange(s.value)}
          >
            {s.label}
          </button>
        )
      })}
    </div>
  )
}

Add styles in the same stylesheet as the offer-form-grid:

css
.hero-state-switcher {
  display: flex;
  gap: 4px;
  padding: 4px;
  background: rgba(0,0,0,0.2);
  border-radius: 999px;
  margin-bottom: var(--space-2);
}
.hero-state-switcher__segment {
  flex: 1;
  background: transparent;
  border: 0;
  padding: 6px 10px;
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--text-muted);
  cursor: pointer;
}
.hero-state-switcher__segment.is-active {
  background: var(--accent);
  color: var(--bg-primary);
}
.hero-state-switcher__segment:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
  • [ ] Step 2: Commit
bash
git add apps/admin/src/merchant/offers/HeroStateSwitcher.jsx apps/admin/src/shared/styles/styles.css
git commit -m "feat(admin/offers): add HeroStateSwitcher segmented control"

Task 16: Wire new sections into OfferForm.jsx โ€‹

Files:

  • Modify: apps/admin/src/merchant/offers/OfferForm.jsx
  • Modify: apps/admin/src/components/offers/AdSlot.jsx

This is a larger edit. Read the existing file end-to-end first so you understand the shape before replacing pieces.

  • [ ] Step 1: Read OfferForm.jsx

Open apps/admin/src/merchant/offers/OfferForm.jsx. Note the current shape: defaultForm initializer, update handler, handleSubmit payload, and the right-pane preview that maps over ALL_PLACEMENTS.

  • [ ] Step 2: Update defaultForm and imports

Add at the top:

jsx
import LiveEventSection from './LiveEventSection'
import HeroPhotoSection from './HeroPhotoSection'
import HeroStateSwitcher from './HeroStateSwitcher'

Replace the existing defaultForm function with:

jsx
function defaultForm(offer) {
  const photoMode = !offer
    ? 'venue'
    : offer.useGradientFallback
      ? 'gradient'
      : offer.heroPhotoUrl
        ? 'custom'
        : 'venue'
  return {
    venueId: offer?.venueId || '',
    title: offer?.title || '',
    description: offer?.description || '',
    placement: offer?.placement || '',
    targetAudience: offer?.targetAudience || '',
    radius: offer?.radius ?? '',
    per_user_limit: offer?.per_user_limit ?? 1,
    budget: offer?.budget ?? 50,
    expiresAt: offer?.expiresAt ? offer.expiresAt.split('T')[0] : '',
    showDisclaimerWhileSuppliesLast: offer?.showDisclaimerWhileSuppliesLast || false,
    liveEvent: offer?.liveEvent || null,
    heroPhotoUrl: offer?.heroPhotoUrl || '',
    useGradientFallback: offer?.useGradientFallback || false,
    photoMode,
  }
}
  • [ ] Step 3: Add heroPreviewState local state and validation gating

Inside the OfferForm component (after const [error, setError] = useState(null)), add:

jsx
const [heroPreviewState, setHeroPreviewState] = useState('standard')

Replace the existing publish-button disabled check to also gate on a valid live event when one is set:

jsx
disabled={
  saving ||
  !form.title ||
  !form.venueId ||
  !form.placement ||
  !form.targetAudience ||
  !form.expiresAt ||
  (form.liveEvent && !isLiveEventValid(form))
}

Add this helper above defaultForm:

jsx
function isLiveEventValid(form) {
  const evt = form.liveEvent
  if (!evt) return true
  if (!evt.genre || !evt.startsAt || !evt.endsAt) return false
  const start = +new Date(evt.startsAt)
  const end = +new Date(evt.endsAt)
  if (!(end > start)) return false
  if (form.expiresAt) {
    const exp = +new Date(form.expiresAt)
    if (end > exp) return false
  }
  return true
}
  • [ ] Step 4: Update handleSubmit payload

In handleSubmit, replace the payload construction with:

jsx
const payload = {
  ...form,
  radius: Number(form.radius),
  per_user_limit: Number(form.per_user_limit),
  budget: Number(form.budget),
  expiresAt: new Date(form.expiresAt).toISOString(),
  status: submitStatus,
}
// Strip form-only fields and apply liveEvent semantics
delete payload.photoMode
if (!payload.liveEvent) delete payload.liveEvent
if (!payload.heroPhotoUrl) delete payload.heroPhotoUrl
  • [ ] Step 5: Render the new sections

Inside the left form column (after the existing fields, before </div> that closes .offer-form-card), add:

jsx
<LiveEventSection
  value={form.liveEvent}
  onChange={(evt) => setForm((f) => ({ ...f, liveEvent: evt }))}
/>

<HeroPhotoSection
  merchantId={merchantId}
  offerId={offer?.id}
  venueHeroPhotoUrl={selectedVenue?.heroPhotoUrl || null}
  value={{
    mode: form.photoMode,
    heroPhotoUrl: form.heroPhotoUrl,
    useGradientFallback: form.useGradientFallback,
  }}
  onChange={(v) =>
    setForm((f) => ({
      ...f,
      photoMode: v.mode,
      heroPhotoUrl: v.heroPhotoUrl,
      useGradientFallback: v.useGradientFallback,
    }))
  }
/>
  • [ ] Step 6: Add the state switcher above the hero preview

In the right preview pane, where ALL_PLACEMENTS.map renders the cards, change the hero placement render to include a switcher above it. Replace the inline <AdSlot offer={...} placement={placement} for the hero case with:

jsx
{ALL_PLACEMENTS.map((placement) => {
  const isSelected = form.placement === placement
  return (
    <button
      key={placement}
      type="button"
      onClick={() => setForm((f) => ({ ...f, placement }))}
      className={['offer-form-preview__slot', isSelected && 'is-selected']
        .filter(Boolean).join(' ')}
      aria-pressed={isSelected}
      aria-label={`Select ${PLACEMENT_LABELS[placement]}`}
    >
      {placement === 'hero' && (
        <HeroStateSwitcher
          value={heroPreviewState}
          onChange={setHeroPreviewState}
          liveEventConfigured={isLiveEventValid(form)}
        />
      )}
      <AdSlot
        offer={{ ...previewOffer, placement }}
        isPreview={false}
        mockNow={placement === 'hero' ? mockNowFor(heroPreviewState, form.liveEvent) : undefined}
      />
    </button>
  )
})}

Add the mockNowFor helper (above defaultForm):

jsx
function mockNowFor(state, evt) {
  if (!evt) return undefined
  const start = +new Date(evt.startsAt)
  if (state === 'preroll') return start - 2 * 3600_000
  if (state === 'live') return start + 30 * 60_000
  return undefined
}
  • [ ] Step 7: Update AdSlot to forward mockNow

In apps/admin/src/components/offers/AdSlot.jsx:

jsx
import React from 'react'
import { AdSlot as BaseAdSlot } from '@lantern/ui/offers'

export default function AdSlot({ offer, isPreview = true, mockNow }) {
  return <BaseAdSlot offer={offer} isPreview={isPreview} mockNow={mockNow} />
}

In packages/ui/offers/AdSlot.jsx, replace the entire file with:

jsx
import React from 'react'
import { HeroOfferCard, OfferPill, ChatOfferPill, FeedOfferCard } from './OfferCards.jsx'

const placementRenderers = {
  hero: ({ offer, onSelect, onTrack, mockNow, venue }) => (
    <HeroOfferCard offer={offer} venue={venue} now={mockNow} onSelect={onSelect} onTrack={onTrack} />
  ),
  inline: ({ offer }) => <OfferPill offer={offer} />,
  chat: ({ offer }) => <ChatOfferPill offer={offer} />,
  feed: ({ offer, onSelect, onTrack }) => (
    <FeedOfferCard offer={offer} onSelect={onSelect} onTrack={onTrack} />
  ),
}

const placementLabels = {
  hero: 'Hero Rail',
  inline: 'Inline Card',
  chat: 'Chat Pill',
  feed: 'Feed Card',
}

/**
 * Placement-aware ad renderer. Routes to the correct visual based on
 * `offer.placement`. Supports a preview mode with dashed border and label.
 *
 * Tracking is opt-in via the `onTrack` prop (suppressed when `isPreview`).
 * `mockNow` is forwarded to the hero renderer so the admin preview can
 * synthesize standard / preroll / live states.
 */
export default function AdSlot({ offer, venue, onSelect, onTrack, isPreview = false, mockNow }) {
  const placement = offer?.placement || 'hero'
  const Renderer = placementRenderers[placement]
  if (!Renderer) return null

  const effectiveTrack = isPreview ? undefined : onTrack
  const effectiveSelect = isPreview ? undefined : onSelect

  if (isPreview) {
    return (
      <div className="relative border border-dashed border-white/15 rounded-2xl p-3 space-y-2">
        <span className="inline-flex px-2 py-0.5 rounded-full text-[10px] font-medium bg-white/5 border border-white/10 text-zinc-400">
          {placementLabels[placement] || placement}
        </span>
        <Renderer offer={offer} venue={venue} mockNow={mockNow} onSelect={effectiveSelect} onTrack={effectiveTrack} />
      </div>
    )
  }

  return <Renderer offer={offer} venue={venue} mockNow={mockNow} onSelect={effectiveSelect} onTrack={effectiveTrack} />
}
  • [ ] Step 8: Manual smoke test

Run the admin app and create a new offer. Confirm:

  • Live event toggle reveals genre + datetime fields.
  • Setting endsAt > expiresAt shows the publish button as disabled.
  • Hero photo radio: "Use venue default" disabled when no venue photo; "Upload custom" enables file input; uploading sets the preview thumbnail.
  • Above the hero preview card, the [Standard | Day-of | Live] switcher appears; Day-of and Live are disabled until a valid live event is set.
  • Switching segments with a valid event re-renders the preview with the genre skin / countdown / live badge.

Run: npm run dev:admin (or whatever the project's admin dev command is โ€” check package.json scripts).

  • [ ] Step 9: Commit
bash
git add apps/admin/src/merchant/offers/OfferForm.jsx apps/admin/src/components/offers/AdSlot.jsx packages/ui/offers/AdSlot.jsx
git commit -m "feat(admin/offers): wire live event + hero photo + state switcher into OfferForm"

Phase 4 โ€” Venue hero-photo uploader โ€‹

Task 17: Add setVenueHeroPhotoUrl API helper โ€‹

Files:

  • Modify: apps/admin/src/firebase.js

Venues live as Firestore documents at venues/{venueId} (or wherever the existing venue list reads from โ€” verify by grepping for getMerchantData/disassociateVenueFromMerchant). For v1 we write directly to the doc; if the venues service exposes a write endpoint, prefer that.

  • [ ] Step 1: Confirm venue document path

Run: grep -n "venues/\${\|venues', \|doc(db, 'venues'" apps/admin/src/firebase.js | head

Note the pattern (likely doc(db, 'venues', venueId)).

  • [ ] Step 2: Append helper

Append to apps/admin/src/firebase.js:

js
import { doc as firestoreDoc, updateDoc } from 'firebase/firestore'

/**
 * Set the venue's default hero photo URL. Used by the merchant Venues tab.
 * Pass null to clear.
 */
export async function setVenueHeroPhotoUrl(venueId, url) {
  const venueRef = firestoreDoc(db, 'venues', venueId)
  await updateDoc(venueRef, { heroPhotoUrl: url })
}

(Reuse the existing db import from firebase.js โ€” don't double-import.)

  • [ ] Step 3: Commit
bash
git add apps/admin/src/firebase.js
git commit -m "feat(admin): add setVenueHeroPhotoUrl helper"

Task 18: VenueHeroPhotoUploader component โ€‹

Files:

  • Create: apps/admin/src/merchant/tabs/VenueHeroPhotoUploader.jsx

  • [ ] Step 1: Implementation

jsx
// apps/admin/src/merchant/tabs/VenueHeroPhotoUploader.jsx
import React, { useState } from 'react'
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage'
import { storage, setVenueHeroPhotoUrl } from '../../firebase'
import { cropAndCompressImage } from '../../shared/lib/cropAndCompressImage'

/**
 * Inline uploader for the venue's default hero photo. Used by the
 * Venues tab. Self-manages upload state; calls `onChange(url)` on success.
 */
export default function VenueHeroPhotoUploader({ merchantId, venueId, currentUrl, onChange }) {
  const [uploading, setUploading] = useState(false)
  const [error, setError] = useState(null)

  async function handleFile(file) {
    setError(null)
    setUploading(true)
    try {
      const blob = await cropAndCompressImage(file)
      const path = `merchants/${merchantId}/venues/${venueId}/hero.jpg`
      const sref = ref(storage, path)
      await uploadBytes(sref, blob, { contentType: 'image/jpeg' })
      const url = await getDownloadURL(sref)
      await setVenueHeroPhotoUrl(venueId, url)
      onChange?.(url)
    } catch (e) {
      setError(e.message || 'Upload failed')
    } finally {
      setUploading(false)
    }
  }

  async function handleRemove() {
    setUploading(true)
    setError(null)
    try {
      await setVenueHeroPhotoUrl(venueId, null)
      onChange?.(null)
    } catch (e) {
      setError(e.message || 'Remove failed')
    } finally {
      setUploading(false)
    }
  }

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
      {currentUrl && (
        <img
          src={currentUrl}
          alt="Venue hero"
          style={{ width: 80, height: 45, objectFit: 'cover', borderRadius: 4 }}
        />
      )}
      <input
        type="file"
        accept="image/jpeg,image/png,image/webp"
        onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
        disabled={uploading}
      />
      {currentUrl && (
        <button type="button" className="btn btn-ghost btn-sm" onClick={handleRemove} disabled={uploading}>
          Remove
        </button>
      )}
      {uploading && <span className="text-muted">Uploadingโ€ฆ</span>}
      {error && <span className="form-error">{error}</span>}
    </div>
  )
}
  • [ ] Step 2: Commit
bash
git add apps/admin/src/merchant/tabs/VenueHeroPhotoUploader.jsx
git commit -m "feat(admin): add VenueHeroPhotoUploader component"

Task 19: Wire uploader into Venues tab โ€‹

Files:

  • Modify: apps/admin/src/merchant/tabs/Venues.jsx

  • [ ] Step 1: Import and render in each venue card

Open apps/admin/src/merchant/tabs/Venues.jsx. After the existing imports, add:

jsx
import VenueHeroPhotoUploader from './VenueHeroPhotoUploader'

Locate the loop that renders one card per venue. After whatever metadata it shows today (name, address, etc.), add:

jsx
<div style={{ marginTop: 'var(--space-2)' }}>
  <label className="form-label">Hero photo</label>
  <VenueHeroPhotoUploader
    merchantId={merchantId}
    venueId={venue.venueId || venue.id}
    currentUrl={venue.heroPhotoUrl || null}
    onChange={(url) =>
      setVenues((prev) =>
        prev.map((v) =>
          (v.venueId || v.id) === (venue.venueId || venue.id)
            ? { ...v, heroPhotoUrl: url }
            : v,
        ),
      )
    }
  />
</div>
  • [ ] Step 2: Manual smoke test

Open the merchant Venues tab. For one of the merchant's venues, upload an image. Confirm:

  • Thumbnail appears after upload.

  • Refreshing the page still shows the thumbnail (Firestore persisted).

  • "Remove" clears the photo.

  • Visiting the Create Offer form for that merchant, the "Use venue default" radio is no longer disabled, and the standard preview shows the photo.

  • [ ] Step 3: Commit

bash
git add apps/admin/src/merchant/tabs/Venues.jsx
git commit -m "feat(admin/venues): inline hero-photo uploader on venue cards"

Phase 5 โ€” Web app consumption โ€‹

Task 20: Pass venue to <HeroOfferCard> in the web app โ€‹

Files:

  • Modify: apps/web/src/components/dashboard/VenueView.jsx

  • Modify: apps/web/src/lib/offerService.js

  • [ ] Step 1: Reconcile legacy props in VenueView.jsx

The current call (around line 449โ€“457) passes three legacy props that don't exist on the refactored HeroOfferCard: asDiv, hideVenueLine, badgeLabel. Replace the entire block:

jsx
{venueOffer && (
  <div className="mb-6">
    <HeroOfferCard
      offer={venueOffer}
      asDiv
      hideVenueLine
      badgeLabel={venueOffer.isFakeOffer ? 'Test offer ๐Ÿงช' : 'Special offer'}
    />
  </div>
)}

with:

jsx
{venueOffer && (
  <div className="mb-6">
    <HeroOfferCard
      offer={venueOffer}
      venue={venue}
      now={Date.now()}
      hideVenueLine
    />
  </div>
)}

The badge override (Special offer / Test offer ๐Ÿงช) is dropped: the new B2 Corner Tag chip says "Sponsored" in all states. If the test-offer affordance must be preserved, add a follow-up TODO โ€” but for v1 the chip wins, since it's the system-level treatment we just locked in.

asDiv was used because the parent of this <div className="mb-6"> is itself clickable. Wrap the new <HeroOfferCard> in <div onClickCapture={(e) => e.stopPropagation()}>...</div> if the parent click handler conflicts with the card's button. Verify in the manual smoke test (Step 3).

  • [ ] Step 2: Add the rotation TODO comment

In apps/web/src/lib/offerService.js, find getHeroOffer (line ~90). Replace its body with:

js
export function getHeroOffer(offers = []) {
  // TODO(v1.5): live events get rotation precedence โ€” sort by
  // (hasActiveLiveEvent desc, priority desc) before picking the hero.
  const hero = offers.find((o) => o.placement === 'hero')
  return hero || null
}

(Keep the existing logic; just wrap with the comment. If the existing getHeroOffer already does priority sorting, preserve it โ€” only add the comment.)

  • [ ] Step 3: Manual smoke test

Run: npm run dev:web Open the dashboard with a venue that has a published hero offer. Confirm:

  • The hero card renders the new B2 Corner Tag chip.

  • If the offer has a liveEvent and the current time is within the day-of preroll window, the card is genre-themed and shows a countdown.

  • If now is between startsAt and endsAt, the card shows the LIVE badge.

  • [ ] Step 4: Commit

bash
git add apps/web/src/components/dashboard/VenueView.jsx apps/web/src/lib/offerService.js
git commit -m "feat(web): pass venue to HeroOfferCard + TODO for live-event precedence"

Phase 6 โ€” Storybook + final cleanup โ€‹

Task 21: Add Storybook stories for new states โ€‹

Files:

  • Modify: apps/web/src/screens/dashboard/HeroOfferCard.stories.jsx (existing)

  • [ ] Step 1: Add new stories

Replace or extend the existing stories file with:

jsx
// apps/web/src/screens/dashboard/HeroOfferCard.stories.jsx
import React from 'react'
import { HeroOfferCard } from '@lantern/ui/offers'

export default {
  title: 'Screens/Dashboard/HeroOfferCard',
  component: HeroOfferCard,
}

const baseOffer = {
  headline: '20% off tacos',
  body: 'Show this at the counter',
  lanternCount: 3,
  venue: { name: "Mauricio's Mexican Food", distance: '108 ft' },
  placement: 'hero',
}

const photoUrl = 'https://images.unsplash.com/photo-1565299624946-b28f40a0ca4b?w=800'

export const Standard = () => <HeroOfferCard offer={baseOffer} venue={baseOffer.venue} />

export const WithVenuePhoto = () => (
  <HeroOfferCard
    offer={baseOffer}
    venue={{ ...baseOffer.venue, heroPhotoUrl: photoUrl }}
  />
)

const liveEvent = (genre) => ({
  genre,
  startsAt: new Date(Date.now() - 30 * 60_000).toISOString(),
  endsAt: new Date(Date.now() + 90 * 60_000).toISOString(),
  liveHeadline: ({ disco: 'Mirror ball night, 9pm', edm: 'Sub Bay sound system, 11pm', rock: 'The Hammers โ€” live, 10pm', jazz: 'Yael Trio, 9pm', hiphop: 'DJ Mars, 10pm' })[genre],
  liveSubcopy: ({ disco: 'all-vinyl set', edm: '21+ ยท doors at 10', rock: '$10 cover ยท loud as hell', jazz: 'No cover ยท two sets', hiphop: 'Late-night menu ยท 21+' })[genre],
})

export const Preroll = () => (
  <HeroOfferCard
    offer={{
      ...baseOffer,
      liveEvent: {
        ...liveEvent('jazz'),
        startsAt: new Date(Date.now() + 2 * 3600_000).toISOString(),
        endsAt: new Date(Date.now() + 4 * 3600_000).toISOString(),
      },
    }}
    venue={baseOffer.venue}
  />
)

export const LiveJazz = () => (
  <HeroOfferCard offer={{ ...baseOffer, liveEvent: liveEvent('jazz') }} venue={baseOffer.venue} />
)
export const LiveDisco = () => (
  <HeroOfferCard offer={{ ...baseOffer, liveEvent: liveEvent('disco') }} venue={baseOffer.venue} />
)
export const LiveEdm = () => (
  <HeroOfferCard offer={{ ...baseOffer, liveEvent: liveEvent('edm') }} venue={baseOffer.venue} />
)
export const LiveRock = () => (
  <HeroOfferCard offer={{ ...baseOffer, liveEvent: liveEvent('rock') }} venue={baseOffer.venue} />
)
export const LiveHiphop = () => (
  <HeroOfferCard offer={{ ...baseOffer, liveEvent: liveEvent('hiphop') }} venue={baseOffer.venue} />
)
  • [ ] Step 2: Run Storybook

Run: npm run storybook (or whichever script the repo uses).

Confirm:

  • All seven stories render without errors.

  • Each Live* story shows the genre-specific neon glow + LIVE badge.

  • Preroll shows the countdown.

  • WithVenuePhoto shows the photo behind the chip.

  • [ ] Step 3: Commit

bash
git add apps/web/src/screens/dashboard/HeroOfferCard.stories.jsx
git commit -m "test(web): add Storybook stories for hero-card themed states"

Task 22: Update version stamp + run validation โ€‹

Files:

  • Modify: apps/web/public/version.json (already in git status; will be updated by build pipeline if not manually).

  • [ ] Step 1: Run the project validator

Run: npm run validate

Per AGENTS.md, this is the pre-commit gate. Expected: green across linting, typecheck, and tests for all touched workspaces. If any test fails, fix it before continuing.

  • [ ] Step 2: Run the affected test suites explicitly

Run:

bash
npx vitest run packages/ui/offers
npx vitest run packages/shared
cd services/api/merchants && npx vitest run && cd -
cd apps/admin && npx vitest run && cd -
cd apps/web && npx vitest run && cd -

Expected: all green.

  • [ ] Step 3: Commit any lint/typecheck fixups

If npm run validate produced auto-fixes (formatter), commit them:

bash
git add -p
git commit -m "chore: lint fixups for themed-rails feature"

Self-review checklist โ€‹

After each phase, the worker should pause and skim the spec to confirm coverage. The complete coverage map:

Spec sectionTasks
1. Data model โ€” offer schemaTask 10
1. Data model โ€” venue schemaTasks 17, 18, 19
1. Data model โ€” photo resolution chainTask 2
1. Data model โ€” storage pathsTasks 14, 18
2. Render states (getHeroState)Task 1
2. Visual treatments per stateTasks 5, 6, 7, 8, 9
3. Component refactorTasks 6, 7, 8, 9
4. Form โ€” Live event sectionTask 13, integration in 16
4. Form โ€” Hero photo sectionTask 14, integration in 16
4. Form โ€” submit payloadTask 16 (Step 4)
5. Multi-state previewTasks 15, 16 (Steps 6โ€“7)
6. B2 chip system-levelTask 6 (StorefrontShell)
7. Out of scope (parking lot)Task 20 (Step 2) โ€” TODO comment in offerService for rotation precedence
8. TestsTasks 1, 2, 3, 4, 10, 11, 12, 21

Built with VitePress