← back to Dw Marketing Reels
scripts/build-graduate-interactive.mjs
206 lines
#!/usr/bin/env node
/**
* build-graduate-interactive.mjs — emit the "unified auto-play flip book" module for
* the Graduate Collection: a draggable page-flip book (page-flip-ui) the visitor turns
* themselves, PLUS a ▶ Play that auto-advances the pages to the narration extracted
* from the existing 124.2s flip-book video. Grabbing a page pauses auto-play.
*
* Output: public/collections/graduate-flipbook/index.html (standalone local preview)
* + prints the embeddable <section> so it can be dropped into the marketing.dw page
* and the Shopify dw-collection-hero.liquid.
*
* Images come straight from cdn.shopify.com URLs in data/graduate-combined.json, so the
* SAME module works on marketing.dw and Shopify with zero image hosting. Only the two
* local assets (page-flip.js/css) + the narration .m4a live under /collections/graduate-flipbook/.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const PROJ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const OUTDIR = path.join(PROJ, 'public', 'collections', 'graduate-flipbook');
// asset base: local preview serves from same dir; on prod both surfaces reach it at this absolute URL
const BASE = process.argv.includes('--prod')
? 'https://marketing.designerwallcoverings.com/public/collections/graduate-flipbook'
: '.';
const products = JSON.parse(fs.readFileSync(path.join(PROJ, 'data', 'graduate-combined.json'), 'utf8'))
.filter(p => p.image);
const N = products.length;
// Self-contained mode: inline the flip engine + css, and serve narration from the
// public /reels/*.mp4 route (the /public/collections/<slug>/ subdir is routed to a
// different app by nginx, so external asset URLs there 401). No external deps except
// the narration mp4 (public) + cdn.shopify.com images.
const CSS_INLINE = fs.readFileSync(path.join(OUTDIR, 'page-flip.css'), 'utf8');
const JS_INLINE = fs.readFileSync(path.join(OUTDIR, 'page-flip.js'), 'utf8');
const AUDIO_SRC = process.argv.includes('--prod')
? 'https://marketing.designerwallcoverings.com/reels/graduate-narration.mp4'
: './graduate-narration.m4a';
// video timing (from the render): a ~2s cover hold, then pages riffle, ~6s of hold+outro tail.
const DUR = 124.2, COVER = 2.2, TAIL = 5.8;
const INTERVAL = (DUR - COVER - TAIL) / N; // seconds per page during auto-play
const esc = s => String(s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
// each leaf = one spread page: left verso (room-setting + name), right recto (pattern swatch)
const leaves = products.map((p, i) => `
<div class="pf-page" role="group" aria-label="${esc(p.title)}">
<div class="gb-spread">
<div class="gb-verso">
${p.room ? `<img class="gb-room" src="${esc(p.room)}" alt="${esc(p.title)} in a room setting" loading="lazy">`
: `<div class="gb-room gb-room--none"></div>`}
<div class="gb-meta">
${p.chapter ? `<span class="gb-chapter">${esc(p.chapter)}</span>` : ''}
<span class="gb-name">${esc(p.title)}</span>
<span class="gb-pageno">${i + 1} / ${N}</span>
</div>
</div>
<div class="gb-recto">
<img class="gb-swatch" src="${esc(p.image)}" alt="${esc(p.title)} pattern" loading="lazy">
</div>
</div>
</div>`).join('');
const cover = `
<div class="pf-page gb-cover" role="group" aria-label="Cover">
<div class="gb-cover__inner">
<span class="gb-cover__brand">Designer Wallcoverings</span>
<span class="gb-cover__title">The Graduate Collection</span>
<span class="gb-cover__sub">Flip through it — or press play for the narrated tour</span>
</div>
</div>`;
// The reusable module (markup + scoped CSS + JS). Absolute asset URLs via BASE.
const MODULE = `<!-- ==== Graduate interactive flip book (unified auto-play) ==== -->
<style>${CSS_INLINE}</style>
<section class="dw-gbook" aria-label="The Graduate Collection flip book">
<div class="dw-gbook__bar">
<span class="dw-gbook__label">The Flip Book · interactive</span>
<div class="dw-gbook__ctl">
<button type="button" class="gb-btn" data-gb="prev" aria-label="Previous page">‹</button>
<span class="gb-count" data-gb="count">1 / ${N + 1}</span>
<button type="button" class="gb-btn" data-gb="next" aria-label="Next page">›</button>
<button type="button" class="gb-btn gb-btn--play" data-gb="play" aria-label="Play narrated tour">▶ Play narrated</button>
</div>
</div>
<div class="pageflip dw-gbook__book" data-pageflip>
${cover}${leaves}
<div class="pf-page gb-cover gb-cover--back" role="group" aria-label="Back cover">
<div class="gb-cover__inner">
<span class="gb-cover__title">Shop the Graduate Collection</span>
<a class="gb-cover__cta" href="https://www.designerwallcoverings.com/collections/the-graduate-collection">Browse all ${N} patterns →</a>
</div>
</div>
</div>
<audio class="dw-gbook__audio" data-gb="audio" preload="none" src="${AUDIO_SRC}"></audio>
</section>
<style>
.dw-gbook{max-width:1100px;margin:0 auto 1.75rem;padding:0 1rem;font-family:'Cormorant Garamond',Georgia,serif;}
.dw-gbook__bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin:0 0 .6rem;flex-wrap:wrap;}
.dw-gbook__label{font-size:.95rem;letter-spacing:.12em;text-transform:uppercase;color:#8a7f72;}
.dw-gbook__ctl{display:flex;align-items:center;gap:.4rem;}
.gb-btn{border:1px solid #cbb8a3;background:#fff;color:#3a3128;border-radius:999px;padding:.35rem .8rem;font:inherit;font-size:.9rem;cursor:pointer;line-height:1;transition:background .15s,color .15s;}
.gb-btn:hover{background:#f3ece2;}
.gb-btn--play{background:#14110e;color:#f4ede2;border-color:#14110e;}
.gb-btn--play:hover{background:#2a2118;}
.gb-btn--play.is-playing{background:#8a1f2b;border-color:#8a1f2b;}
.gb-count{min-width:4.5em;text-align:center;color:#6b6055;font-size:.9rem;}
.dw-gbook__book{aspect-ratio:16/9;background:#14110e;border-radius:10px;box-shadow:0 14px 48px rgba(0,0,0,.22);}
/* spread: verso (room + name) | recto (pattern) */
.gb-spread{position:absolute;inset:0;display:grid;grid-template-columns:1fr 1fr;background:#fbf7f1;}
.gb-verso{position:relative;overflow:hidden;background:#efe7db;}
.gb-room{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.gb-room--none{background:linear-gradient(135deg,#e7ddce,#d8ccba);}
.gb-meta{position:absolute;left:0;right:0;bottom:0;padding:1rem 1.1rem;display:flex;flex-direction:column;gap:.15rem;background:linear-gradient(0deg,rgba(20,17,14,.72),rgba(20,17,14,0));color:#fdf8f0;}
.gb-chapter{font-size:.7rem;letter-spacing:.18em;text-transform:uppercase;opacity:.85;}
.gb-name{font-size:1.45rem;line-height:1.1;}
.gb-pageno{font-size:.72rem;opacity:.7;letter-spacing:.1em;}
.gb-recto{position:relative;overflow:hidden;background:#fff;}
.gb-swatch{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.gb-cover__inner{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.5rem;background:radial-gradient(120% 120% at 50% 20%,#241d15,#14110e);color:#f4ede2;padding:2rem;}
.gb-cover__brand{font-size:.8rem;letter-spacing:.28em;text-transform:uppercase;color:#c9a86a;}
.gb-cover__title{font-size:2.1rem;line-height:1.05;}
.gb-cover__sub{font-size:1rem;color:#b6a894;}
.gb-cover__cta{margin-top:.4rem;color:#14110e;background:#c9a86a;border-radius:999px;padding:.5rem 1.1rem;text-decoration:none;font-size:.95rem;}
@media(max-width:640px){.gb-name{font-size:1.15rem}.gb-cover__title{font-size:1.5rem}.dw-gbook__book{aspect-ratio:4/3}}
@media(prefers-reduced-motion:reduce){.dw-gbook__book{scroll-behavior:auto}}
</style>
<script>${JS_INLINE}</script>
<script>
(function(){
var root=document.querySelector('.dw-gbook'); if(!root) return;
var el=root.querySelector('[data-pageflip]');
var audio=root.querySelector('[data-gb=audio]');
var playBtn=root.querySelector('[data-gb=play]');
var countEl=root.querySelector('[data-gb=count]');
var COVER=${COVER.toFixed(2)}, INTERVAL=${INTERVAL.toFixed(3)}, DUR=${DUR.toFixed(1)}, TOTAL=${N + 1};
// page-flip auto-inits on DOMContentLoaded; init now if not yet (PageFlip is idempotent).
var book=el.__pageflip||new PageFlip(el); if(!book){ return; }
var auto=false, raf=0, startT=0;
function count(){ countEl.textContent=(book.current()+1)+' / '+TOTAL; }
book.o.onFlip=count; count();
// manual paging (buttons stop auto-play, then turn — animated)
root.querySelector('[data-gb=prev]').addEventListener('click',function(){ stop(); book.prev(); });
root.querySelector('[data-gb=next]').addEventListener('click',function(){ stop(); book.next(); });
// grabbing a page mid-play cancels auto-play (you took the wheel)
el.addEventListener('pointerdown',function(){ if(auto) stop(); },true);
// Clock: prefer the narration's own currentTime (so pages track the VO), but fall
// back to a wall-clock timer so PLAY still flips the pages even if audio is blocked.
function elapsed(){
if(audio && !audio.paused && audio.currentTime>0.05) return audio.currentTime;
return (performance.now()-startT)/1000;
}
function tick(){
if(!auto) return;
var t=elapsed();
var target=t<COVER?0:Math.min(TOTAL-1,1+Math.floor((t-COVER)/INTERVAL));
// advance with the ANIMATED page-turn (next/prev), one step per frame as needed
if(target>book.current()) book.next();
else if(target<book.current()) book.prev();
if((audio&&audio.ended) || (t>=DUR && book.current()>=TOTAL-1)){ stop(); return; }
raf=requestAnimationFrame(tick);
}
function play(){
auto=true; playBtn.classList.add('is-playing'); playBtn.textContent='⏸ Pause';
if(book.current()>=TOTAL-1) book.goTo(0);
startT=performance.now();
if(audio){ try{ audio.currentTime=0; }catch(e){}
audio.play().then(function(){ startT=performance.now()-audio.currentTime*1000; })
.catch(function(){ /* audio blocked — pages still flip on the timer */ }); }
cancelAnimationFrame(raf); raf=requestAnimationFrame(tick);
}
function stop(){
auto=false; playBtn.classList.remove('is-playing'); playBtn.textContent='▶ Play narrated';
if(audio) audio.pause(); cancelAnimationFrame(raf);
}
playBtn.addEventListener('click',function(){ auto?stop():play(); });
if(audio) audio.addEventListener('ended',stop);
})();
</script>
<!-- ==== /Graduate interactive flip book ==== -->`;
// standalone local preview page
const PREVIEW = `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Graduate Collection — Interactive Flip Book (preview)</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
<style>body{margin:0;background:#f6f1ea;color:#2a241d}
.hero{height:34vh;min-height:220px;background:#14110e url('https://marketing.designerwallcoverings.com/public/collections/graduate-banner.jpg') center/cover;display:flex;align-items:flex-end}
.hero h1{margin:0;padding:1.2rem 1.4rem;color:#f4ede2;font-family:'Cormorant Garamond',serif;font-size:2rem;text-shadow:0 2px 18px rgba(0,0,0,.5)}
.wrap{padding:2rem 0}</style></head>
<body>
<div class="hero"><h1>The Graduate Collection</h1></div>
<div class="wrap">${MODULE}</div>
</body></html>`;
fs.mkdirSync(OUTDIR, { recursive: true });
fs.writeFileSync(path.join(OUTDIR, 'index.html'), PREVIEW);
fs.writeFileSync(path.join(OUTDIR, 'module.html'), MODULE);
console.log(`Built ${N} spread leaves → ${path.relative(PROJ, OUTDIR)}/index.html`);
console.log(`Auto-play: cover ${COVER}s, ${INTERVAL.toFixed(2)}s/page over ${DUR}s narration.`);
console.log(`Embeddable module → ${path.relative(PROJ, OUTDIR)}/module.html`);