← 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
M public/index.htmlM server.js
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=>({'&':'&','<':'<','>':'>','"':'"'}[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 →