← 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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`);