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 โ
| Path | Responsibility |
|---|---|
packages/ui/offers/getHeroState.js | Pure helper: (offer, now) โ 'standard' | 'preroll' | 'live' |
packages/ui/offers/getHeroState.test.js | Boundary tests for the state machine |
packages/ui/offers/resolvePhotoUrl.js | Photo fallback chain: offer โ venue โ null (gradient) |
packages/ui/offers/resolvePhotoUrl.test.js | Fallback tests |
packages/ui/offers/genrePresets.js | Registry: disco/edm/rock/jazz/hiphop config (border, interior, shadow, animClass, accent) |
packages/ui/offers/genrePresets.test.js | Registry shape + completeness tests |
packages/ui/offers/StorefrontShell.jsx | B2 Corner Tag chip + photo/gradient header (always-on chrome) |
packages/ui/offers/GenreNeonWrap.jsx | Wraps 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.jsx | Pulsing "LIVE" indicator |
packages/ui/offers/formatCountdown.js | Pure helper: (msRemaining) โ 'in 2h 15m' | 'starting now' |
packages/ui/offers/formatCountdown.test.js | Format tests |
packages/ui/offers/HeroOfferCard.stories.jsx | New stories: WithVenuePhoto, Preroll, LiveJazz, LiveDisco, LiveEdm, LiveRock, LiveHiphop |
apps/admin/src/merchant/offers/LiveEventSection.jsx | Collapsible live-event subform |
apps/admin/src/merchant/offers/HeroPhotoSection.jsx | Radio + uploader for offer photo |
apps/admin/src/merchant/offers/HeroStateSwitcher.jsx | Standard / Day-of / Live segmented control |
apps/admin/src/shared/lib/cropAndCompressImage.js | Client-side canvas-based 16:9 crop + JPEG compression |
apps/admin/src/shared/lib/cropAndCompressImage.test.js | Pure helper tests with synthetic ImageData |
apps/admin/src/merchant/tabs/VenueHeroPhotoUploader.jsx | Inline uploader for the Venues tab cards |
services/api/merchants/src/routes/__tests__/offers.themedRails.test.js | Schema validation tests for new fields |
Modified files โ
| Path | Change |
|---|---|
packages/ui/offers/OfferCards.jsx | Replace inline HeroOfferCard body with composition of new subcomponents |
packages/ui/offers/index.js | Export new public surfaces (getHeroState, genrePresets) |
packages/ui/offers/cards.css | Add 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.js | Extend CreateOfferSchema and UpdateOfferSchema with new fields |
packages/shared/lib/offerNormalizer.js | Pass liveEvent and heroPhotoUrl through normalization |
packages/shared/__tests__/lib/offerNormalizer.test.js | Cover new fields |
apps/admin/src/merchant/offers/OfferForm.jsx | Wire LiveEventSection, HeroPhotoSection, HeroStateSwitcher; submit new fields |
apps/admin/src/components/offers/AdSlot.jsx | Accept mockNow prop, pass through |
apps/admin/src/merchant/tabs/Venues.jsx | Render <VenueHeroPhotoUploader> inside each venue card |
apps/admin/src/firebase.js | Export a setVenueHeroPhotoUrl(venueId, url) helper |
apps/web/src/components/dashboard/VenueView.jsx | Pass venue to <HeroOfferCard> |
apps/web/src/lib/offerService.js | Add 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.jsTest:
packages/ui/offers/getHeroState.test.js[ ] Step 1: Write the failing test
// 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
// 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
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.jsTest:
packages/ui/offers/resolvePhotoUrl.test.js[ ] Step 1: Write the failing test
// 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
// 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
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.jsTest:
packages/ui/offers/formatCountdown.test.js[ ] Step 1: Write the failing test
// 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
// 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
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
// 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
// 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
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:
/* โโ 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
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
// 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
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
// 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
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.jsxCreate:
packages/ui/offers/LiveBadge.jsx[ ] Step 1: Implementations
// 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>
)
}// 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
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
HeroOfferCardexport
In packages/ui/offers/OfferCards.jsx, replace the existing HeroOfferCard definition with:
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.jsexports
Replace packages/ui/offers/index.js contents with:
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
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.jsCreate:
services/api/merchants/src/routes/__tests__/offers.themedRails.test.js[ ] Step 1: Write the failing schema test
// 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:
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
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.jsModify:
packages/shared/__tests__/lib/offerNormalizer.test.js[ ] Step 1: Add failing test cases
Append to packages/shared/__tests__/lib/offerNormalizer.test.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:
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:
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
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
// 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
// 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
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
// 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):
.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
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
// 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
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
// 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:
.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
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
defaultFormand imports
Add at the top:
import LiveEventSection from './LiveEventSection'
import HeroPhotoSection from './HeroPhotoSection'
import HeroStateSwitcher from './HeroStateSwitcher'Replace the existing defaultForm function with:
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
heroPreviewStatelocal state and validation gating
Inside the OfferForm component (after const [error, setError] = useState(null)), add:
const [heroPreviewState, setHeroPreviewState] = useState('standard')Replace the existing publish-button disabled check to also gate on a valid live event when one is set:
disabled={
saving ||
!form.title ||
!form.venueId ||
!form.placement ||
!form.targetAudience ||
!form.expiresAt ||
(form.liveEvent && !isLiveEventValid(form))
}Add this helper above defaultForm:
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
handleSubmitpayload
In handleSubmit, replace the payload construction with:
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:
<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:
{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):
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
AdSlotto forwardmockNow
In apps/admin/src/components/offers/AdSlot.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:
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
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:
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
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
// 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
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:
import VenueHeroPhotoUploader from './VenueHeroPhotoUploader'Locate the loop that renders one card per venue. After whatever metadata it shows today (name, address, etc.), add:
<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
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.jsxModify:
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:
{venueOffer && (
<div className="mb-6">
<HeroOfferCard
offer={venueOffer}
asDiv
hideVenueLine
badgeLabel={venueOffer.isFakeOffer ? 'Test offer ๐งช' : 'Special offer'}
/>
</div>
)}with:
{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:
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
liveEventand the current time is within the day-of preroll window, the card is genre-themed and shows a countdown.If
nowis betweenstartsAtandendsAt, the card shows the LIVE badge.[ ] Step 4: Commit
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:
// 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.Prerollshows the countdown.WithVenuePhotoshows the photo behind the chip.[ ] Step 3: Commit
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:
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:
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 section | Tasks |
|---|---|
| 1. Data model โ offer schema | Task 10 |
| 1. Data model โ venue schema | Tasks 17, 18, 19 |
| 1. Data model โ photo resolution chain | Task 2 |
| 1. Data model โ storage paths | Tasks 14, 18 |
2. Render states (getHeroState) | Task 1 |
| 2. Visual treatments per state | Tasks 5, 6, 7, 8, 9 |
| 3. Component refactor | Tasks 6, 7, 8, 9 |
| 4. Form โ Live event section | Task 13, integration in 16 |
| 4. Form โ Hero photo section | Task 14, integration in 16 |
| 4. Form โ submit payload | Task 16 (Step 4) |
| 5. Multi-state preview | Tasks 15, 16 (Steps 6โ7) |
| 6. B2 chip system-level | Task 6 (StorefrontShell) |
| 7. Out of scope (parking lot) | Task 20 (Step 2) โ TODO comment in offerService for rotation precedence |
| 8. Tests | Tasks 1, 2, 3, 4, 10, 11, 12, 21 |