[object Object]

← back to Microsite Engine

microsite-engine: live rotating-hero preview + companion hero-allocation emit (config schema stays pure)

c509c2b8197694b2c0ff2360c7ebbcdd6058af28 · 2026-07-17 07:11:55 -0700 · Steve

Files touched

Diff

commit c509c2b8197694b2c0ff2360c7ebbcdd6058af28
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 17 07:11:55 2026 -0700

    microsite-engine: live rotating-hero preview + companion hero-allocation emit (config schema stays pure)
---
 public/index.html | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++---
 server.js         | 30 ++++++++++++++++++++++++----
 2 files changed, 81 insertions(+), 7 deletions(-)

diff --git a/public/index.html b/public/index.html
index 0e8f880..ccd4920 100644
--- a/public/index.html
+++ b/public/index.html
@@ -30,6 +30,17 @@
   .count small{font:400 14px var(--sans);color:var(--dim);display:block;text-transform:uppercase;letter-spacing:.1em;margin-top:2px}
   .toolbar{display:flex;align-items:center;gap:14px;margin:14px 0;flex-wrap:wrap}
   .toolbar .sp{margin-left:auto}
+  .hero{position:relative;height:300px;border-radius:10px;overflow:hidden;border:1px solid var(--rule);background:#0c0a08;margin-bottom:6px}
+  .hero-layers{position:absolute;inset:0}
+  .hero-layers .lyr{position:absolute;inset:0;background-size:cover;background-position:center;opacity:0;transition:opacity 1.6s ease-in-out}
+  .hero-layers .lyr.on{opacity:1}
+  .hero-vignette{position:absolute;inset:0;background:radial-gradient(120% 100% at 50% 40%,transparent 40%,#000a 100%);pointer-events:none}
+  .hero-overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding-bottom:34px;text-align:center;text-shadow:0 2px 18px #000b}
+  .hero-wm{font:600 40px var(--serif);letter-spacing:.06em;color:#fff}
+  .hero-tag{font:italic 400 16px var(--serif);color:#f3ece0;margin-top:4px}
+  .hero-cta{margin-top:16px;font:600 12px var(--sans);letter-spacing:.16em;text-transform:uppercase;color:#191510;background:#fff;border-radius:22px;padding:9px 26px;opacity:.94}
+  .hero-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:13px}
+  .hero-note{color:var(--dim);font-size:12px;margin:0 0 8px}
   .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px}
   .card{background:var(--panel);border:1px solid var(--rule);border-radius:8px;overflow:hidden}
   .card .img{aspect-ratio:1/1;background:#0c0a08 center/cover no-repeat}
@@ -103,6 +114,18 @@
 
   <!-- ============ MAIN ============ -->
   <div class="main">
+    <div class="hero" id="hero">
+      <div class="hero-layers" id="heroLayers"></div>
+      <div class="hero-vignette"></div>
+      <div class="hero-overlay">
+        <div class="hero-wm" id="heroWm">Cork Wallcovering</div>
+        <div class="hero-tag" id="heroTag">Hand-crafted cork wallcoverings.</div>
+        <div class="hero-cta">Enter</div>
+      </div>
+      <div class="hero-empty" id="heroEmpty">hero preview — define a niche with matching products</div>
+    </div>
+    <div class="hero-note" id="heroNote"></div>
+
     <div class="toolbar">
       <div><div class="count"><span id="cnt">0</span><small>products match this niche</small></div></div>
       <div class="sp"></div>
@@ -184,6 +207,34 @@ async function doPreview(){
       <div class="r"><span class="dot" style="background:${c.hex}"></span>${esc(c.product_type||'')}</div></div>
     </div>`).join('');
   $('gridNote').textContent = r.count>r.shown ? `showing first ${r.shown} of ${r.count.toLocaleString()}` : (r.count?'':'no products match — loosen the niche');
+  setHero(r.heroes || (r.cards||[]).map(c=>c.image_url).filter(Boolean));
+}
+
+// ---- hero preview: rotating crossfade over the niche's hero images ----
+let heroTimer, heroIdx = 0, heroUrls = [];
+function setHero(urls){
+  heroUrls = (urls||[]).slice(0,8);
+  const L = $('heroLayers');
+  L.innerHTML = heroUrls.map((u,i)=>`<div class="lyr${i===0?' on':''}" style="background-image:url('${(u||'').replace(/'/g,'')}')"></div>`).join('');
+  $('heroEmpty').style.display = heroUrls.length ? 'none' : 'flex';
+  $('heroNote').textContent = heroUrls.length ? `${heroUrls.length} hero images (newest matched products) — emitted as a companion hero-allocation on Generate` : '';
+  heroIdx = 0;
+  clearTimeout(heroTimer);
+  if (heroUrls.length > 1) rotateHero();
+}
+function rotateHero(){
+  const lyrs = document.querySelectorAll('#heroLayers .lyr');
+  if (lyrs.length < 2) return;
+  heroTimer = setTimeout(()=>{
+    lyrs[heroIdx].classList.remove('on');
+    heroIdx = (heroIdx + 1) % lyrs.length;
+    lyrs[heroIdx].classList.add('on');
+    rotateHero();
+  }, 3200);
+}
+function syncHeroText(){
+  $('heroWm').textContent = $('siteName').value || $('heroHeadline').value || 'Your Microsite';
+  $('heroTag').textContent = $('tagline').value || 'Explore the collection';
 }
 function esc(s){return String(s||'').replace(/[&<>"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]));}
 
@@ -222,16 +273,17 @@ async function generate(){
   const r = await (await fetch('/api/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
   if(r.error){alert(r.error);return;}
   $('dlgPath').textContent=r.outPath; $('dlgCount').textContent=(r.matchCount||0).toLocaleString(); $('dlgPort').textContent=r.port;
-  $('dlgCmd').textContent=r.copyCmd; $('dlgJson').textContent=JSON.stringify(r.config,null,2);
+  $('dlgCmd').textContent=r.copyCmd + '\n' + (r.heroCmd||''); $('dlgJson').textContent=JSON.stringify(r.config,null,2);
   $('dlgClash').innerHTML = (r.clashes&&r.clashes.length)?('<div class="banner">Conflict: '+esc(r.clashes.join(', '))+'. Pick a different slug/domain before staging.</div>'):'';
   $('genDlg').showModal(); loadDrafts();
 }
 
 ['pos','neg'].forEach(id=>$(id).addEventListener('input',doPreviewDebounced));
+['siteName','heroHeadline','tagline'].forEach(id=>$(id).addEventListener('input',syncHeroText));
 $('sort').addEventListener('change',doPreview);
 $('density').addEventListener('input',e=>document.documentElement.style.setProperty('--cols',e.target.value));
 $('genBtn').onclick=generate;
-$('resetBtn').onclick=()=>{['slug','domain','siteName','tagline','heroHeadline','heroSub','pos','neg'].forEach(id=>$(id).value='');selTypes.clear();document.querySelectorAll('.chip.on').forEach(c=>c.classList.remove('on'));doPreview();};
-boot();
+$('resetBtn').onclick=()=>{['slug','domain','siteName','tagline','heroHeadline','heroSub','pos','neg'].forEach(id=>$(id).value='');selTypes.clear();document.querySelectorAll('.chip.on').forEach(c=>c.classList.remove('on'));syncHeroText();doPreview();};
+boot().then(syncHeroText);
 </script>
 </body></html>
diff --git a/server.js b/server.js
index ea6f846..4cf744a 100644
--- a/server.js
+++ b/server.js
@@ -146,7 +146,24 @@ function preview(body) {
     price: p.price,
     hex: sortMod.dominantHex(p),
   }));
-  return { count: all.length, shown: cards.length, cards, niche };
+  return { count: all.length, shown: cards.length, cards, niche, heroes: heroPick(all) };
+}
+
+// ---- hero allocation: mirror the fleet's "niche top-N" hero fallback ----
+// The fleet's render.js pulls heroes from data/hero-allocation.json[slug], falling
+// back to the niche's top products. We pick the newest matched products with a real
+// image, deduped, capped — the deploy-ready hero list for this microsite.
+function heroPick(all, n = 8) {
+  const seen = new Set();
+  const out = [];
+  for (const p of sortMod.sortProducts(all, 'newest')) {
+    const u = (p.image_url || '').trim();
+    if (!u || seen.has(u)) continue;
+    seen.add(u);
+    out.push(u);
+    if (out.length >= n) break;
+  }
+  return out;
 }
 
 // ---- basic auth (house rule for the preview surface) ----
@@ -207,17 +224,22 @@ const server = http.createServer(async (req, res) => {
       // write ONLY to this project's output/ (never the live fleet)
       const outPath = path.join(OUTPUT, slug + '.json');
       fs.writeFileSync(outPath, JSON.stringify(cfg, null, 2));
+      // companion heroes file in the fleet's hero-allocation shape { "<slug>": [urls] }.
+      // Kept SEPARATE from the config so sites/<slug>.json stays schema-pure.
+      const heroPath = path.join(OUTPUT, slug + '.heroes.json');
+      fs.writeFileSync(heroPath, JSON.stringify({ [slug]: pv.heroes }, null, 2));
       const draft = {
         slug, domain: cfg.domain, siteName: cfg.siteName, port,
-        matchCount: pv.count, niche: cfg.niche,
+        matchCount: pv.count, niche: cfg.niche, heroCount: pv.heroes.length,
         created_at: new Date().toISOString(),
-        outPath, status: clashes.length ? 'conflict' : 'draft', clashes,
+        outPath, heroPath, status: clashes.length ? 'conflict' : 'draft', clashes,
       };
       const idx = drafts.findIndex(d => d.slug === slug);
       if (idx >= 0) drafts[idx] = draft; else drafts.push(draft);
       saveDrafts(drafts);
       const copyCmd = `cp ${outPath} ${path.join(FLEET, 'sites', slug + '.json')}`;
-      return json(res, 200, { config: cfg, outPath, port, matchCount: pv.count, clashes, copyCmd, draft });
+      const heroCmd = `# heroes → merge into ${path.join(FLEET, 'data', 'hero-allocation.json')}\ncat ${heroPath}`;
+      return json(res, 200, { config: cfg, outPath, heroPath, port, matchCount: pv.count, heroes: pv.heroes, clashes, copyCmd, heroCmd, draft });
     }
     if (p === '/api/delete-draft' && req.method === 'POST') {
       const body = await readBody(req);

← f2735d8 microsite-engine: config-generator dashboard driving dw-doma  ·  back to Microsite Engine  ·  chore: lint, refactor, harden (path-traversal guard + readBo 0714e9f →