diff --git a/docs/amazon-insert-video.md b/docs/amazon-insert-video.md index 49d4ff9..cdf973f 100644 --- a/docs/amazon-insert-video.md +++ b/docs/amazon-insert-video.md @@ -52,33 +52,36 @@ More generations → **more variety** in inserts, **not** denser inserts by defa ### Plain English -1. While someone browses a **product grid**, we sometimes drop a **fashion reel tile** into the stream — same size as a product card. -2. That tile is picked from the **full multi-generation pool** of category reels. -3. If they’re already in a room (e.g. Handbags), we **don’t** insert handbags reels — only **other** categories (cross-promo). -4. About **one reel every few products**, with a **cap** so the grid doesn’t turn into a video wall. -5. For a given list/filter, the mix is **stable for the UTC day** (not reshuffled every reload); **next day** it can reshuffle. -6. Tap → shop that category. Video is **muted**, loops, plays when on screen. +1. While someone browses a **product grid**, we drop **fashion reel tiles** into the stream — same size as a product card. +2. Pool = **category room reels** (all gens) **+ persona jet-set reels** (`REELS_JETSET`). +3. **Never repeat the same reel id on one page.** +4. Gaps between inserts are **random 2–6 products** (immersive / unpredictable). +5. If they’re already in a room (e.g. Handbags), we skip handbags reels — persona jet-set still allowed. +6. For a given list/filter, the mix is **stable for the UTC day**; **next day** it reshuffles. +7. Category reels → shop that room. **Jet-set persona reels → `/quiz` (vibe check).** +8. Video is **muted**, loops, plays when on screen. ### Where | Surface | Density | Cap | |---------|---------|-----| -| **Shop** grid | ~every **5** products | **6** inserts max | -| **Home** product rows | ~every **4** | **1–2** | -| **PDP** similar / also-like | ~every **3** | **2** | +| **Shop** grid | random **2–6** product gaps | **10** inserts max | +| **Home** product rows | random **2–6** | **2–4** | +| **PDP** similar / also-like | random **2–5** | **2** | | Lists with **< 3** products | — | **no** inserts | ### Technical | Piece | Detail | |-------|--------| -| Pool | `CATEGORY_REELS` in `src/data/reels.ts` (all gens) | -| Interleave | `interleaveReelInserts` → `src/lib/reelInserts.ts` | +| Pool | `CATEGORY_REELS` = gen1 ∪ gen2 ∪ `REELS_JETSET` | +| Interleave | `interleaveReelInserts` → `src/lib/reelInserts.ts` (`minGap`/`maxGap`) | | UI grid | `ProductGrid` + `ReelInsertCard` | -| Cross-promo | `excludeCategory` = current shop room / PDP category | +| Cross-promo | `excludeCategory` for room reels only | +| Persona link | `href: /quiz` on jet-set reels | | Seed | `listName + exclude + YYYY-MM-DD (UTC) + product ids` | | Click analytics | GA `reel_insert_click` | -| Full catalog | `/reels` (gen sections) | +| Full catalog | `/reels` (jet set + room sections) | ### Not a cron job @@ -94,7 +97,9 @@ Shuffle is **computed on the client** when the grid renders. Same seed → same | Gen 1 poster | `public/brand/videos/reels/posters/{category}.jpg` | | Gen 2 video | `public/brand/videos/reels/{category}-g2.mp4` | | Gen 2 poster | `public/brand/videos/reels/posters/{category}-g2.jpg` | -| R2 (optional) | `video/reels/{category}-g2.mp4` via mint — see [`imagine-r2-videos.md`](./imagine-r2-videos.md) | +| Jet-set video | `public/brand/videos/reels/jetset-{vibeId}.mp4` | +| Jet-set poster | `public/brand/videos/reels/posters/jetset-{vibeId}.jpg` | +| R2 (optional) | `video/…` via mint — see [`imagine-r2-videos.md`](./imagine-r2-videos.md) | ### Production pipeline diff --git a/public/brand/videos/reels/jetset-atelier.mp4 b/public/brand/videos/reels/jetset-atelier.mp4 new file mode 100644 index 0000000..8ca6b49 Binary files /dev/null and b/public/brand/videos/reels/jetset-atelier.mp4 differ diff --git a/public/brand/videos/reels/jetset-dew.mp4 b/public/brand/videos/reels/jetset-dew.mp4 new file mode 100644 index 0000000..4e103d0 Binary files /dev/null and b/public/brand/videos/reels/jetset-dew.mp4 differ diff --git a/public/brand/videos/reels/jetset-gilded.mp4 b/public/brand/videos/reels/jetset-gilded.mp4 new file mode 100644 index 0000000..01c5569 Binary files /dev/null and b/public/brand/videos/reels/jetset-gilded.mp4 differ diff --git a/public/brand/videos/reels/jetset-luxe.mp4 b/public/brand/videos/reels/jetset-luxe.mp4 new file mode 100644 index 0000000..0b65891 Binary files /dev/null and b/public/brand/videos/reels/jetset-luxe.mp4 differ diff --git a/public/brand/videos/reels/jetset-muse.mp4 b/public/brand/videos/reels/jetset-muse.mp4 new file mode 100644 index 0000000..9cbcd62 Binary files /dev/null and b/public/brand/videos/reels/jetset-muse.mp4 differ diff --git a/public/brand/videos/reels/jetset-sillage.mp4 b/public/brand/videos/reels/jetset-sillage.mp4 new file mode 100644 index 0000000..ee63e4d Binary files /dev/null and b/public/brand/videos/reels/jetset-sillage.mp4 differ diff --git a/public/brand/videos/reels/posters/jetset-atelier.jpg b/public/brand/videos/reels/posters/jetset-atelier.jpg new file mode 100644 index 0000000..fe326ab Binary files /dev/null and b/public/brand/videos/reels/posters/jetset-atelier.jpg differ diff --git a/public/brand/videos/reels/posters/jetset-dew.jpg b/public/brand/videos/reels/posters/jetset-dew.jpg new file mode 100644 index 0000000..826b665 Binary files /dev/null and b/public/brand/videos/reels/posters/jetset-dew.jpg differ diff --git a/public/brand/videos/reels/posters/jetset-gilded.jpg b/public/brand/videos/reels/posters/jetset-gilded.jpg new file mode 100644 index 0000000..f4e915a Binary files /dev/null and b/public/brand/videos/reels/posters/jetset-gilded.jpg differ diff --git a/public/brand/videos/reels/posters/jetset-luxe.jpg b/public/brand/videos/reels/posters/jetset-luxe.jpg new file mode 100644 index 0000000..e58180a Binary files /dev/null and b/public/brand/videos/reels/posters/jetset-luxe.jpg differ diff --git a/public/brand/videos/reels/posters/jetset-muse.jpg b/public/brand/videos/reels/posters/jetset-muse.jpg new file mode 100644 index 0000000..8c8991f Binary files /dev/null and b/public/brand/videos/reels/posters/jetset-muse.jpg differ diff --git a/public/brand/videos/reels/posters/jetset-sillage.jpg b/public/brand/videos/reels/posters/jetset-sillage.jpg new file mode 100644 index 0000000..fa854f3 Binary files /dev/null and b/public/brand/videos/reels/posters/jetset-sillage.jpg differ diff --git a/src/components/ProductGrid.tsx b/src/components/ProductGrid.tsx index 00e3014..143e9c4 100644 --- a/src/components/ProductGrid.tsx +++ b/src/components/ProductGrid.tsx @@ -19,6 +19,10 @@ type Props = { className?: string compact?: boolean every?: number + /** Min products between inserts (default 2) */ + minGap?: number + /** Max products between inserts (default 6) */ + maxGap?: number maxInserts?: number minProducts?: number /** When false, pure product grid (default true) */ @@ -29,7 +33,7 @@ type Props = { /** * Product card grid with optional randomized fashion reel inserts. - * Insert density scales as CATEGORY_REELS grows. + * Gaps random 2–6 products; same reel never repeats on one page. */ export function ProductGrid({ products, @@ -38,6 +42,8 @@ export function ProductGrid({ className = DEFAULT_COLS, compact = false, every, + minGap, + maxGap, maxInserts, minProducts, inserts = true, @@ -55,6 +61,8 @@ export function ProductGrid({ listName, excludeCategory, every, + minGap, + maxGap, maxInserts, minProducts, } @@ -64,6 +72,8 @@ export function ProductGrid({ listName, excludeCategory, every, + minGap, + maxGap, maxInserts, minProducts, inserts, diff --git a/src/components/ReelInsertCard.tsx b/src/components/ReelInsertCard.tsx index 7659f32..08c26e6 100644 --- a/src/components/ReelInsertCard.tsx +++ b/src/components/ReelInsertCard.tsx @@ -1,12 +1,17 @@ import { Link } from 'react-router-dom' import { Play, Sparkles } from 'lucide-react' import { useEffect, useRef } from 'react' -import type { CategoryReel } from '../data/reels' +import { + reelCtaLabel, + reelHref, + type CategoryReel, +} from '../data/reels' import { trackEvent } from '../lib/analytics' /** * Fashion film tile in product grids (same 4:5 well as ProductCard). - * Muted autoplay when in view; links to the related shop category. + * Muted autoplay when in view. + * Category reels → shop room; persona jet-set → vibe check (/quiz). */ export function ReelInsertCard({ reel, @@ -18,7 +23,10 @@ export function ReelInsertCard({ compact?: boolean }) { const videoRef = useRef(null) - const shopTo = `/shop?cat=${reel.category}` + const to = reelHref(reel) + const cta = reelCtaLabel(reel) + const isPersona = Boolean(reel.vibeId) + const kicker = reel.kicker ?? (isPersona ? 'House persona' : 'From the house') useEffect(() => { const el = videoRef.current @@ -42,13 +50,15 @@ export function ReelInsertCard({ return (
trackEvent('reel_insert_click', { - reel_category: reel.category, + reel_id: reel.id, + reel_category: reel.category ?? 'persona', + vibe_id: reel.vibeId, list_name: listName, - engagement_type: 'cross_promo', + engagement_type: isPersona ? 'persona_jetset' : 'cross_promo', }) } > @@ -68,7 +78,7 @@ export function ReelInsertCard({ - Discover + {isPersona ? 'Persona' : 'Discover'} @@ -87,41 +97,43 @@ export function ReelInsertCard({ className={`flex flex-col flex-1 ${compact ? 'p-3.5 gap-1' : 'p-4 gap-1.5'}`} >

- From the house + {kicker}

- Shop {reel.title} + {isPersona ? 'Which woman are you today?' : cta}

{reel.blurb}

- - Open the room - + {cta} - Shop + Open
trackEvent('reel_insert_click', { - reel_category: reel.category, + reel_id: reel.id, + reel_category: reel.category ?? 'persona', + vibe_id: reel.vibeId, list_name: listName, - engagement_type: 'cross_promo_cta', + engagement_type: isPersona + ? 'persona_jetset_cta' + : 'cross_promo_cta', }) } > - Shop {reel.title} + {cta}
diff --git a/src/data/reels.ts b/src/data/reels.ts index 2592af2..01f4617 100644 --- a/src/data/reels.ts +++ b/src/data/reels.ts @@ -1,22 +1,29 @@ /** - * Category fashion reels — Amazon insert size (4:5 product-card well). + * Fashion reels for product-grid inserts and /reels. * - * Multiple **generations** per category grow the insert pool. Product grids - * pick from the full pool via ProductGrid / interleaveReelInserts (cross-promo). - * See docs/amazon-insert-video.md. + * Pool includes category room clips + persona jet-set films. + * Product grids interleave unique reels with random 2–6 product gaps + * (see lib/reelInserts.ts). */ import type { Category } from './types' import { CATEGORY_LABELS } from './catalog' import { HEROES } from './categoryHeroes' +import { VIBE_LIST } from './vibes' -/** Creative wave — gen 1 = launch set, gen 2 = second campaign wave, … */ -export type ReelGeneration = 1 | 2 +/** Creative wave — gen 1 launch, gen 2 second wave, gen 3 persona jet-set */ +export type ReelGeneration = 1 | 2 | 3 export type CategoryReel = { - /** Stable id for keys / analytics: e.g. handbags-g1 */ + /** Stable id for keys / analytics */ id: string - category: Category - /** Generation wave (1, 2, …) */ + /** + * Shop room for category reels. Omitted for persona jet-set inserts + * (those link to the vibe check, not a shop category). + */ + category?: Category + /** Persona id when this is a house-model jet-set film */ + vibeId?: string + /** Generation wave (1, 2, 3, …) */ generation: ReelGeneration title: string blurb: string @@ -26,6 +33,12 @@ export type CategoryReel = { poster: string /** One-line motion note for a11y / captions */ motionLabel: string + /** Deep link override (default: shop category or /quiz) */ + href?: string + /** CTA label for insert card */ + hrefLabel?: string + /** Kicker above the title */ + kicker?: string } /** Shop categories that get fashion insert reels (all catalog rooms). */ @@ -47,7 +60,7 @@ export const REEL_CATEGORIES: Category[] = [ ] /** Latest generation number in the catalog (bump when adding a wave). */ -export const REEL_LATEST_GENERATION: ReelGeneration = 2 +export const REEL_LATEST_GENERATION: ReelGeneration = 3 const MOTION_G1: Record = { handbags: 'Soft light glide across structured leather', @@ -100,6 +113,9 @@ function buildReel( video: `/brand/videos/reels/${category}${suffix}.mp4`, poster: `/brand/videos/reels/posters/${category}${suffix}.jpg`, motionLabel: motion, + href: `/shop?cat=${category}`, + hrefLabel: `Shop ${hero?.title ?? CATEGORY_LABELS[category]}`, + kicker: 'From the house', } } @@ -114,10 +130,87 @@ export const REELS_GEN2: CategoryReel[] = REEL_CATEGORIES.map((category) => ) /** - * Full insert + /reels catalog (all generations). - * Product-grid randomizer uses this entire pool. + * Persona jet-set inserts — house models abroad in extravagance. + * Link to the vibe check (/quiz), not a shop category. */ -export const CATEGORY_REELS: CategoryReel[] = [...REELS_GEN1, ...REELS_GEN2] +const JETSET: { + vibeId: string + destination: string + motionLabel: string + blurb: string +}[] = [ + { + vibeId: 'luxe', + destination: 'Private Alps', + motionLabel: 'Champagne over the Alps', + blurb: + 'Quiet luxe at altitude — Vivienne between cities, gold low, light expensive.', + }, + { + vibeId: 'muse', + destination: 'Dubai night', + motionLabel: 'Helipad soft glam', + blurb: + 'Camille above the skyline — fuchsia silk, night flash, the room already hers.', + }, + { + vibeId: 'sillage', + destination: 'Capri yacht', + motionLabel: 'Scent on the sea', + blurb: + 'Noor on deck at golden hour — a trail that arrives before the boat does.', + }, + { + vibeId: 'atelier', + destination: 'Milan tarmac', + motionLabel: 'Jet stairs, gold bag', + blurb: + 'Margot finishes the exit — ivory trench, architecture bag, fashion week dusk.', + }, + { + vibeId: 'dew', + destination: 'Maldives dawn', + motionLabel: 'Seaplane to glass water', + blurb: + 'Isla in white linen at sunrise — barrier glow that holds under travel light.', + }, + { + vibeId: 'gilded', + destination: 'Monaco harbor', + motionLabel: 'Emerald night arrival', + blurb: + 'Aurelia at the harbor — bronze silk, emerald armor, collector energy.', + }, +] + +export const REELS_JETSET: CategoryReel[] = JETSET.map((j) => { + const vibe = VIBE_LIST.find((v) => v.id === j.vibeId) + const name = vibe?.avatar.name ?? j.vibeId + const personaTitle = vibe?.title ?? 'House persona' + return { + id: `jetset-${j.vibeId}`, + vibeId: j.vibeId, + generation: 3 as ReelGeneration, + title: `${name} · ${j.destination}`, + blurb: j.blurb, + video: `/brand/videos/reels/jetset-${j.vibeId}.mp4`, + poster: `/brand/videos/reels/posters/jetset-${j.vibeId}.jpg`, + motionLabel: j.motionLabel, + href: '/quiz', + hrefLabel: 'Find your persona', + kicker: `${personaTitle} · Jet set`, + } +}) + +/** + * Full insert + /reels catalog (all generations + persona jet-set). + * Product-grid randomizer uses this entire pool — unique per page. + */ +export const CATEGORY_REELS: CategoryReel[] = [ + ...REELS_GEN1, + ...REELS_GEN2, + ...REELS_JETSET, +] export function reelsForGeneration(gen: ReelGeneration): CategoryReel[] { return CATEGORY_REELS.filter((r) => r.generation === gen) @@ -137,3 +230,18 @@ export function reelForCategory(cat: Category | string | null | undefined) { list[list.length - 1] ) } + +/** Resolved deep link for an insert card */ +export function reelHref(reel: CategoryReel): string { + if (reel.href) return reel.href + if (reel.category) return `/shop?cat=${reel.category}` + return '/quiz' +} + +export function reelCtaLabel(reel: CategoryReel): string { + if (reel.hrefLabel) return reel.hrefLabel + if (reel.category) { + return `Shop ${CATEGORY_LABELS[reel.category] ?? reel.title}` + } + return 'Find your persona' +} diff --git a/src/data/videoWatch.ts b/src/data/videoWatch.ts index caa685c..c05dd3a 100644 --- a/src/data/videoWatch.ts +++ b/src/data/videoWatch.ts @@ -2,11 +2,13 @@ * Short-form video gallery catalog — every public brand clip we ship. * Muted visual feed only; not the product-grid insert pool (see reels.ts). */ -import { CATEGORY_LABELS } from './catalog' import { CATEGORY_REELS, REELS_GEN1, REELS_GEN2, + REELS_JETSET, + reelCtaLabel, + reelHref, type CategoryReel, } from './reels' import { VIBE_LIST } from './vibes' @@ -21,13 +23,14 @@ export type WatchClip = { poster?: string aspect: WatchAspect /** Filter chip */ - group: 'category' | 'carpet' | 'cafe' | 'all' + group: 'category' | 'carpet' | 'cafe' | 'jetset' | 'all' /** Optional deep link */ href?: string hrefLabel?: string } function fromReel(reel: CategoryReel): WatchClip { + const isPersona = Boolean(reel.vibeId) return { id: reel.id, title: reel.title, @@ -35,9 +38,9 @@ function fromReel(reel: CategoryReel): WatchClip { video: reel.video, poster: reel.poster, aspect: 'portrait', - group: 'category', - href: `/shop?cat=${reel.category}`, - hrefLabel: `Shop ${CATEGORY_LABELS[reel.category] ?? reel.title}`, + group: isPersona ? 'jetset' : 'category', + href: reelHref(reel), + hrefLabel: reelCtaLabel(reel), } } @@ -86,7 +89,7 @@ const CAFE_CLIPS: WatchClip[] = VIBE_LIST.flatMap((v) => { }) /** - * Full watch feed: category reels + carpet premieres + café lifestyle films. + * Full watch feed: category + jet-set + carpet + café films. */ export const WATCH_CLIPS: WatchClip[] = [ ...CATEGORY_REELS.map(fromReel), @@ -95,6 +98,7 @@ export const WATCH_CLIPS: WatchClip[] = [ ] export const WATCH_CATEGORY_CLIPS = WATCH_CLIPS.filter((c) => c.group === 'category') +export const WATCH_JETSET_CLIPS = WATCH_CLIPS.filter((c) => c.group === 'jetset') export const WATCH_CARPET_CLIPS = WATCH_CLIPS.filter((c) => c.group === 'carpet') export const WATCH_CAFE_CLIPS = WATCH_CLIPS.filter((c) => c.group === 'cafe') @@ -102,9 +106,11 @@ export function watchClipCount() { return { total: WATCH_CLIPS.length, category: WATCH_CATEGORY_CLIPS.length, + jetset: WATCH_JETSET_CLIPS.length, carpet: WATCH_CARPET_CLIPS.length, cafe: WATCH_CAFE_CLIPS.length, gen1: REELS_GEN1.length, gen2: REELS_GEN2.length, + jetsetPool: REELS_JETSET.length, } } diff --git a/src/lib/reelInserts.ts b/src/lib/reelInserts.ts index 796a5b0..6e256da 100644 --- a/src/lib/reelInserts.ts +++ b/src/lib/reelInserts.ts @@ -1,7 +1,12 @@ /** - * Interleave fashion reels into product lists for cross-category promo. - * Pool = all generations in CATEGORY_REELS (gen1 + gen2 + …). - * Density rules unchanged; more generations → more variety, not denser by default. + * Interleave fashion reels into product lists. + * + * Rules: + * - Pool = full CATEGORY_REELS (category waves + persona jet-set) + * - Never repeat the same reel id on one page + * - Gap between inserts is random 2–6 products (immersive / unpredictable) + * - Daily seed keeps order stable for a given list within a day + * * See docs/amazon-insert-video.md. */ import type { Product } from '../data/types' @@ -21,11 +26,19 @@ export type ReelInsertOptions = { /** Skip reels for the room the shopper is already in (cross-promo only) */ excludeCategory?: Category | string | null /** - * Insert roughly after every N products (default 5). - * Lower = denser fashion inserts. + * Minimum products between inserts (inclusive). Default 2. + */ + minGap?: number + /** + * Maximum products between inserts (inclusive). Default 6. + */ + maxGap?: number + /** + * @deprecated Use minGap/maxGap. Kept for callers that pass `every` — + * maps to both min and max when set alone. */ every?: number - /** Hard cap on inserts for this grid (default: unlimited up to density) */ + /** Hard cap on inserts for this grid */ maxInserts?: number /** * Minimum product count before any insert (default 3). @@ -72,23 +85,44 @@ function dayKey() { return new Date().toISOString().slice(0, 10) } +/** + * Random integer in [min, max] inclusive using rand ∈ [0,1). + */ +function randInt(rand: () => number, min: number, max: number): number { + const lo = Math.min(min, max) + const hi = Math.max(min, max) + return lo + Math.floor(rand() * (hi - lo + 1)) +} + /** * Build a mixed list of products + reel insert tiles. - * Prefer reels from *other* categories so inserts cross-promote rooms. + * Same video never appears twice on one page. + * Inserts land after random gaps of minGap–maxGap products (default 2–6). */ export function interleaveReelInserts( products: Product[], opts: ReelInsertOptions, ): ProductGridItem[] { - const every = Math.max(3, opts.every ?? 5) const minProducts = opts.minProducts ?? 3 + // Prefer minGap/maxGap; legacy `every` collapses the range + const minGap = Math.max( + 2, + opts.minGap ?? (opts.every != null ? opts.every : 2), + ) + const maxGap = Math.max( + minGap, + opts.maxGap ?? (opts.every != null ? opts.every : 6), + ) const exclude = opts.excludeCategory || null if (products.length < minProducts) { return products.map((product) => ({ kind: 'product' as const, product })) } - const pool = CATEGORY_REELS.filter((r) => r.category !== exclude) + // Category reels from other rooms + all persona jet-set (no category) + const pool = CATEGORY_REELS.filter( + (r) => !r.category || r.category !== exclude, + ) if (pool.length === 0) { return products.map((product) => ({ kind: 'product' as const, product })) } @@ -99,28 +133,43 @@ export function interleaveReelInserts( `${opts.listName}|${exclude ?? ''}|${dayKey()}|${productKey}` const rand = mulberry32(hashSeed(seedStr)) + // Unique reels only — shuffle full pool, never reuse an id const reels = shuffleInPlace([...pool], rand) - const maxByDensity = Math.floor(products.length / every) + const seen = new Set() + const uniqueReels: CategoryReel[] = [] + for (const r of reels) { + if (seen.has(r.id)) continue + seen.add(r.id) + uniqueReels.push(r) + } + + // How many inserts fit with average gap ≈ mid of range + const avgGap = (minGap + maxGap) / 2 + const maxByDensity = Math.max(0, Math.floor(products.length / avgGap)) const maxInserts = Math.min( - reels.length, + uniqueReels.length, maxByDensity, - opts.maxInserts ?? maxByDensity, + opts.maxInserts ?? uniqueReels.length, ) if (maxInserts <= 0) { return products.map((product) => ({ kind: 'product' as const, product })) } - // Spread insert slots across the list (not all stacked at the end) + // Place slots: first after a random lead-in gap, then random 2–6 gaps const slots: number[] = [] + let pos = 0 for (let i = 0; i < maxInserts; i++) { - // After every-th product, with a small deterministic jitter 0..1 - const base = (i + 1) * every - const jitter = Math.floor(rand() * 2) // 0 or 1 product - const at = Math.min(products.length, base + jitter) - if (!slots.includes(at)) slots.push(at) + const gap = randInt(rand, minGap, maxGap) + pos += gap + if (pos > products.length) break + // Avoid stacking two inserts at the exact same product index + if (slots.length && slots[slots.length - 1] === pos) { + pos = Math.min(products.length, pos + 1) + } + if (pos > products.length) break + slots.push(pos) } - slots.sort((a, b) => a - b) const out: ProductGridItem[] = [] let reelIdx = 0 @@ -128,15 +177,15 @@ export function interleaveReelInserts( for (let i = 0; i < products.length; i++) { out.push({ kind: 'product', product: products[i] }) - // Insert after product index i when (i+1) matches a planned slot count while ( slotPtr < slots.length && slots[slotPtr] === i + 1 && + reelIdx < uniqueReels.length && reelIdx < maxInserts ) { out.push({ kind: 'reel', - reel: reels[reelIdx % reels.length], + reel: uniqueReels[reelIdx], slot: reelIdx, }) reelIdx++ diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 3299a3c..08efd62 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -176,8 +176,9 @@ export function Home() { @@ -209,8 +210,9 @@ export function Home() { @@ -278,8 +280,9 @@ export function Home() { @@ -319,8 +322,9 @@ export function Home() { diff --git a/src/pages/Product.tsx b/src/pages/Product.tsx index 1a97955..eb605bd 100644 --- a/src/pages/Product.tsx +++ b/src/pages/Product.tsx @@ -441,7 +441,8 @@ export function ProductPage() { products={similar} listName="product_similar" excludeCategory={product.category} - every={3} + minGap={2} + maxGap={5} maxInserts={2} compact className="grid sm:grid-cols-2 lg:grid-cols-4 gap-5" @@ -462,7 +463,8 @@ export function ProductPage() { products={alsoLike} listName="product_also_like" excludeCategory={product.category} - every={3} + minGap={2} + maxGap={5} maxInserts={2} compact className="grid sm:grid-cols-2 lg:grid-cols-4 gap-5" diff --git a/src/pages/Reels.tsx b/src/pages/Reels.tsx index d044693..38fd317 100644 --- a/src/pages/Reels.tsx +++ b/src/pages/Reels.tsx @@ -2,20 +2,20 @@ import { Link } from 'react-router-dom' import { Play } from 'lucide-react' import { Seo } from '../components/Seo' import { - REEL_LATEST_GENERATION, - reelsForGeneration, + REELS_GEN1, + REELS_GEN2, + REELS_JETSET, + reelCtaLabel, + reelHref, type CategoryReel, } from '../data/reels' import { reelsSeo } from '../lib/seoData' import { useEffect, useRef } from 'react' /** - * Short fashion films — portrait clips for every room of the house. + * Short fashion films — portrait clips for rooms + persona jet-set. */ export function ReelsPage() { - const latest = reelsForGeneration(REEL_LATEST_GENERATION) - const archive = reelsForGeneration(1) - return ( <> @@ -28,34 +28,37 @@ export function ReelsPage() { Moving pictures

- Short fashion films from every room of the house — beauty, - fragrance, gold, and the bag that finishes the exit. Scroll to - watch. Shop when something holds your eye. + House personas jet-setting the world — and short films from every + room. They appear in the shop scroll so the house finds you. Scroll + to watch.

- - Enter the house - - + Find your persona + + Enter the house +
- {REEL_LATEST_GENERATION > 1 && archive.length > 0 ? ( - - ) : null} + + ) } @@ -71,6 +74,7 @@ function GenerationSection({ reels: CategoryReel[] featured?: boolean }) { + if (reels.length === 0) return null return (
(null) - const shopTo = `/shop?cat=${reel.category}` + const to = reelHref(reel) + const cta = reelCtaLabel(reel) useEffect(() => { const el = videoRef.current @@ -139,7 +144,7 @@ function ReelCard({ reel }: { reel: CategoryReel }) {
- Film + {reel.vibeId ? 'Persona' : 'Film'}
@@ -157,10 +162,10 @@ function ReelCard({ reel }: { reel: CategoryReel }) {

- Shop {reel.title} + {cta}
diff --git a/src/pages/Shop.tsx b/src/pages/Shop.tsx index d9655a1..a018216 100644 --- a/src/pages/Shop.tsx +++ b/src/pages/Shop.tsx @@ -236,8 +236,9 @@ export function Shop() { : 'shop_all' } excludeCategory={cat || null} - every={5} - maxInserts={6} + minGap={2} + maxGap={6} + maxInserts={10} className="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5" /> )} diff --git a/src/pages/Watch.tsx b/src/pages/Watch.tsx index e09560f..1b81c67 100644 --- a/src/pages/Watch.tsx +++ b/src/pages/Watch.tsx @@ -7,12 +7,13 @@ import { WATCH_CARPET_CLIPS, WATCH_CATEGORY_CLIPS, WATCH_CLIPS, + WATCH_JETSET_CLIPS, watchClipCount, type WatchClip, } from '../data/videoWatch' import { watchSeo } from '../lib/seoData' -type Filter = 'all' | 'category' | 'carpet' | 'cafe' +type Filter = 'all' | 'category' | 'jetset' | 'carpet' | 'cafe' /** * Visual short-film gallery — muted autoplay from the house library. @@ -23,6 +24,7 @@ export function WatchPage() { const clips = useMemo(() => { if (filter === 'category') return WATCH_CATEGORY_CLIPS + if (filter === 'jetset') return WATCH_JETSET_CLIPS if (filter === 'carpet') return WATCH_CARPET_CLIPS if (filter === 'cafe') return WATCH_CAFE_CLIPS return WATCH_CLIPS @@ -40,8 +42,8 @@ export function WatchPage() { The house on film

- {counts.total} short films — the rooms, the carpet, the café. Scroll - to play. Sound stays off until you invite it. + {counts.total} short films — jet-set personas, the rooms, the carpet, + the café. Scroll to play. Sound stays off until you invite it.

@@ -51,6 +53,7 @@ export function WatchPage() { {( [ ['all', `All (${counts.total})`], + ['jetset', `Jet set (${counts.jetset})`], ['category', `The rooms (${counts.category})`], ['carpet', `The Carpet (${counts.carpet})`], ['cafe', `The Café (${counts.cafe})`], @@ -123,7 +126,9 @@ function WatchTile({ clip }: { clip: WatchClip }) { ? 'The Carpet' : clip.group === 'cafe' ? 'The Café' - : 'The rooms' + : clip.group === 'jetset' + ? 'Jet set' + : 'The rooms' const body = ( <>