← back to Designer Wallcoverings
Brand admin micro-site template: Amazon-style collapsed facets, density slider 1-12, grid/list toggle, brand-parameterized (DaisyBennett+CarlRobinson live)
1167f239e0dfed499b36469fa926618bd98281a1 · 2026-07-06 13:30:03 -0700 · Steve
Files touched
A scripts/brand-microsites/.gitignoreA scripts/brand-microsites/build-brand-data.cjsA scripts/brand-microsites/public/index.htmlA scripts/brand-microsites/server.cjs
Diff
commit 1167f239e0dfed499b36469fa926618bd98281a1
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 6 13:30:03 2026 -0700
Brand admin micro-site template: Amazon-style collapsed facets, density slider 1-12, grid/list toggle, brand-parameterized (DaisyBennett+CarlRobinson live)
---
scripts/brand-microsites/.gitignore | 4 +
scripts/brand-microsites/build-brand-data.cjs | 65 +++++++++
scripts/brand-microsites/public/index.html | 188 ++++++++++++++++++++++++++
scripts/brand-microsites/server.cjs | 48 +++++++
4 files changed, 305 insertions(+)
diff --git a/scripts/brand-microsites/.gitignore b/scripts/brand-microsites/.gitignore
new file mode 100644
index 00000000..949d3983
--- /dev/null
+++ b/scripts/brand-microsites/.gitignore
@@ -0,0 +1,4 @@
+data/
+img/
+node_modules/
+*.log
diff --git a/scripts/brand-microsites/build-brand-data.cjs b/scripts/brand-microsites/build-brand-data.cjs
new file mode 100644
index 00000000..7930bb4c
--- /dev/null
+++ b/scripts/brand-microsites/build-brand-data.cjs
@@ -0,0 +1,65 @@
+// Build per-brand admin micro-site data from LIVE Shopify (authoritative).
+// Usage: BRAND=daisybennett SCOPE="mfr_sku ~ '^(DB[0-9]|BX)'" node build-brand-data.cjs
+// Writes data/<brand>.json (products) + downloads primary images to img/<brand>/<dw_sku>.jpg
+const fs = require('fs');
+const https = require('https');
+const { Client } = require('pg');
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const CONN = process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified';
+const BRAND = process.env.BRAND || 'brand';
+const SCOPE = process.env.SCOPE || "mfr_sku ~ '^DB[0-9]'";
+const DIR = __dirname;
+const IMGDIR = `${DIR}/img/${BRAND}`;
+fs.mkdirSync(IMGDIR, { recursive: true });
+
+function api(path) {
+ return new Promise((res, rej) => {
+ https.request({ hostname: STORE, path: `/admin/api/2024-10/${path}`, headers: { 'X-Shopify-Access-Token': TOKEN }, timeout: 30000 },
+ r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { res(JSON.parse(d)); } catch (e) { rej(e); } }); })
+ .on('error', rej).end();
+ });
+}
+function dl(url, out) {
+ return new Promise(res => {
+ if (fs.existsSync(out) && fs.statSync(out).size > 2000) return res(1);
+ const f = fs.createWriteStream(out);
+ https.get(url, r => { if (r.statusCode !== 200) { f.close(); return res(0); } r.pipe(f); f.on('finish', () => { f.close(); res(1); }); })
+ .on('error', () => { f.close(); res(0); });
+ });
+}
+
+(async () => {
+ const db = new Client({ connectionString: CONN }); await db.connect();
+ const { rows } = await db.query(`SELECT dw_sku, mfr_sku, shopify_product_id FROM dw_sku_registry WHERE ${SCOPE} AND shopify_product_id IS NOT NULL ORDER BY dw_sku`);
+ console.log(`${BRAND}: ${rows.length} products to pull from live Shopify…`);
+ const out = [];
+ for (const r of rows) {
+ try {
+ const p = (await api(`products/${r.shopify_product_id}.json?fields=id,title,handle,vendor,tags,variants,images`)).product;
+ if (!p) continue;
+ const mf = (await api(`products/${r.shopify_product_id}/metafields.json`)).metafields || [];
+ const get = (ns, k) => { const m = mf.find(x => x.namespace === ns && x.key === k); return m ? m.value : ''; };
+ const std = p.variants.find(v => v.sku === r.dw_sku) || p.variants[p.variants.length - 1];
+ const book = get('private_label', 'source_book');
+ // pattern family = leading word(s) of title before the color (title = "<Name> <Color> | Phillipe Romano")
+ const namePart = (p.title.split('|')[0] || '').trim();
+ const family = namePart.split(/\s+/)[0] || namePart;
+ const imgSrc = (p.images[0] || {}).src || '';
+ if (imgSrc) await dl(imgSrc, `${IMGDIR}/${r.dw_sku}.jpg`);
+ out.push({
+ dw_sku: r.dw_sku, mfr: r.mfr_sku, handle: p.handle, title: p.title, family,
+ color: get('custom', 'color'), material: get('specs', 'material') || 'Grasscloth & Naturals',
+ price: std ? std.price : '', unit: get('global', 'unit_of_measure'),
+ packaged: get('global', 'packaged'), width: get('global', 'width'),
+ book, tags: (p.tags || '').split(',').map(s => s.trim()).filter(Boolean),
+ img: `img/${BRAND}/${r.dw_sku}.jpg`, live_url: `https://www.designerwallcoverings.com/products/${p.handle}`,
+ });
+ if (out.length % 20 === 0) console.log(` …${out.length}`);
+ } catch (e) { /* skip */ }
+ }
+ fs.writeFileSync(`${DIR}/data/${BRAND}.json`, JSON.stringify(out, null, 2));
+ console.log(`${BRAND}: wrote ${out.length} → data/${BRAND}.json (+images img/${BRAND}/)`);
+ await db.end();
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });
diff --git a/scripts/brand-microsites/public/index.html b/scripts/brand-microsites/public/index.html
new file mode 100644
index 00000000..b8da2afc
--- /dev/null
+++ b/scripts/brand-microsites/public/index.html
@@ -0,0 +1,188 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Brand Admin — Designer Wallcoverings</title>
+<style>
+ :root{--cols:5;--fs:13px;--ink:#1a1a1a;--sub:#8a8378;--line:#e7e2d9;--teal:#0f6b5c;--bg:#faf9f7}
+ *{box-sizing:border-box}
+ body{margin:0;font:var(--fs)/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
+ header{position:sticky;top:0;z-index:20;background:#fff;border-bottom:1px solid var(--line);padding:9px 16px;display:flex;flex-wrap:wrap;gap:12px;align-items:center}
+ .brand{font-size:16px;font-weight:600;letter-spacing:.3px}
+ .brand b{color:var(--teal)}
+ .cnt{color:var(--sub);font-size:12px}
+ .grow{flex:1}
+ .ctl{display:flex;align-items:center;gap:6px;font-size:12px;color:#6b6459}
+ select,input[type=search]{font:inherit;padding:5px 8px;border:1px solid #d5cfc4;border-radius:7px;background:#fff}
+ input[type=search]{width:210px}
+ input[type=range]{accent-color:var(--teal)}
+ .seg{display:inline-flex;border:1px solid #d5cfc4;border-radius:7px;overflow:hidden}
+ .seg button{border:0;background:#fff;padding:5px 10px;cursor:pointer;font:inherit;color:#6b6459}
+ .seg button.on{background:var(--teal);color:#fff}
+ .wrap{display:flex;align-items:flex-start}
+ /* ---- Amazon-style left facet rail (collapsed on load) ---- */
+ aside{position:sticky;top:49px;width:262px;flex:0 0 262px;max-height:calc(100vh - 49px);overflow:auto;border-right:1px solid var(--line);background:#fff;padding:6px 0 30px}
+ .facet{border-bottom:1px solid #f0ece4}
+ .facet>h4{margin:0;padding:11px 16px;font-size:12.5px;font-weight:700;letter-spacing:.02em;cursor:pointer;display:flex;justify-content:space-between;align-items:center;user-select:none}
+ .facet>h4:hover{background:#f6f2ea}
+ .facet>h4 .chev{transition:transform .15s;color:var(--sub)}
+ .facet.open>h4 .chev{transform:rotate(90deg)}
+ .facet .body{display:none;padding:2px 10px 12px}
+ .facet.open .body{display:block}
+ .opt{display:flex;align-items:center;gap:8px;padding:3px 6px;border-radius:5px;cursor:pointer;color:#4a453d}
+ .opt:hover{background:#f2eee6}
+ .opt input{accent-color:var(--teal);margin:0}
+ .opt .n{margin-left:auto;color:var(--sub);font-size:11px}
+ .facet .body.range{display:none}
+ .facet.open .body.range{display:block}
+ .selbar{padding:8px 16px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--line);background:#fbfaf7}
+ .chip{background:#e3f3ec;color:var(--teal);border-radius:16px;padding:2px 10px;font-size:11.5px;display:inline-flex;gap:6px;align-items:center;cursor:pointer}
+ .chip b{cursor:pointer}
+ main{flex:1;padding:12px 16px 60px}
+ .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px}
+ .card{background:#fff;border:1px solid var(--line);border-radius:9px;overflow:hidden;display:flex;flex-direction:column}
+ .card img{width:100%;aspect-ratio:1/1;object-fit:cover;background:#f0ece4;display:block}
+ .card .m{padding:7px 9px;display:flex;flex-direction:column;gap:2px}
+ .fam{font-weight:600}
+ .cw{color:#6b6459}
+ .sku{color:var(--sub);font-size:11px;font-family:ui-monospace,Menlo,monospace}
+ .pr{font-weight:600;color:var(--teal)}
+ .unit{color:var(--sub);font-size:11px}
+ .pk{color:#8a6d16;font-size:10.5px}
+ a.live{color:var(--sub);font-size:11px;text-decoration:none}
+ a.live:hover{color:var(--teal);text-decoration:underline}
+ /* ---- list view ---- */
+ .list .grid{display:block}
+ .list .card{flex-direction:row;align-items:center;margin-bottom:8px}
+ .list .card img{width:74px;height:74px;aspect-ratio:auto;flex:0 0 74px}
+ .list .card .m{flex-direction:row;align-items:center;gap:18px;flex:1;flex-wrap:wrap}
+ .list .fam{min-width:150px}
+ .empty{padding:60px 20px;text-align:center;color:var(--sub)}
+</style></head><body>
+<header>
+ <span class="brand" id="brand">…</span><span class="cnt" id="count"></span>
+ <span class="grow"></span>
+ <input type="search" id="q" placeholder="Search name / SKU / color…">
+ <label class="ctl">Sort
+ <select id="sort"><option value="fam">Family</option><option value="new">Newest</option><option value="plo">Price ↑</option><option value="phi">Price ↓</option><option value="sku">SKU</option></select></label>
+ <span class="seg" id="view"><button data-v="grid" class="on">▦ Grid</button><button data-v="list">☰ List</button></span>
+ <label class="ctl">Density <input type="range" id="dens" min="1" max="12" value="5"></label>
+ <select id="brandsel" title="brand"></select>
+</header>
+<div class="wrap">
+ <aside id="facets"></aside>
+ <main>
+ <div class="selbar" id="selbar" style="display:none"></div>
+ <div class="grid" id="grid"></div>
+ <div class="empty" id="empty" style="display:none"></div>
+ </main>
+</div>
+<script>
+let DATA=[], BRAND='', sel={}, view='grid';
+const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
+const money=v=>v?('$'+Number(v).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})):'—';
+// facet definitions (key on product) — order = display order
+const FACETS=[
+ {k:'family', label:'Pattern'},
+ {k:'color', label:'Color'},
+ {k:'material',label:'Material'},
+ {k:'book', label:'Book'},
+ {k:'packaged',label:'Packaging'},
+];
+function counts(key,rowsIn){const m={};rowsIn.forEach(r=>{const v=(r[key]||'').trim();if(v)m[v]=(m[v]||0)+1});return m;}
+function priceBands(rowsIn){return rowsIn.map(r=>+r.price).filter(x=>x>0);}
+function applyFilters(){
+ return DATA.filter(r=>{
+ for(const f of FACETS){const s=sel[f.k];if(s&&s.size&&!s.has((r[f.k]||'').trim()))return false;}
+ if(sel._pmin!=null&&+r.price<sel._pmin)return false;
+ if(sel._pmax!=null&&+r.price>sel._pmax)return false;
+ const q=($('#q').value||'').toLowerCase().trim();
+ if(q&&!([r.title,r.dw_sku,r.color,r.mfr].join(' ').toLowerCase().includes(q)))return false;
+ return true;
+ });
+}
+function renderFacets(){
+ const rows=DATA; const wrap=$('#facets'); const openState={};
+ $$('.facet').forEach(f=>openState[f.dataset.k]=f.classList.contains('open'));
+ let html='';
+ for(const f of FACETS){
+ const c=counts(f.k,rows); const keys=Object.keys(c).sort((a,b)=>c[b]-c[a]||a.localeCompare(b));
+ if(!keys.length)continue;
+ const open=openState[f.k]?' open':'';
+ html+=`<div class="facet${open}" data-k="${f.k}"><h4>${f.label} <span class="chev">▸</span></h4><div class="body">`+
+ keys.map(v=>{const on=sel[f.k]&&sel[f.k].has(v)?'checked':'';return `<label class="opt"><input type="checkbox" data-facet="${f.k}" value="${v.replace(/"/g,'"')}" ${on}><span>${v}</span><span class="n">${c[v]}</span></label>`}).join('')+
+ `</div></div>`;
+ }
+ // price range facet
+ const pr=priceBands(rows);
+ if(pr.length){const lo=Math.floor(Math.min(...pr)),hi=Math.ceil(Math.max(...pr));const open=openState['_price']?' open':'';
+ html+=`<div class="facet${open}" data-k="_price"><h4>Price / yd <span class="chev">▸</span></h4><div class="body range">
+ <div style="display:flex;gap:6px;align-items:center;padding:4px 6px">
+ <input type="number" id="pmin" placeholder="${lo}" style="width:70px;padding:4px;border:1px solid #d5cfc4;border-radius:6px">
+ <span style="color:var(--sub)">–</span>
+ <input type="number" id="pmax" placeholder="${hi}" style="width:70px;padding:4px;border:1px solid #d5cfc4;border-radius:6px">
+ <button id="pgo" style="border:1px solid #d5cfc4;border-radius:6px;background:#fff;padding:4px 8px;cursor:pointer">Go</button>
+ </div></div></div>`;}
+ wrap.innerHTML=html;
+ // wire accordion toggles (collapsed on load)
+ $$('.facet>h4').forEach(h=>h.onclick=()=>h.parentElement.classList.toggle('open'));
+ $$('.opt input').forEach(cb=>cb.onclick=e=>{e.stopPropagation();const {facet,value}=cb.dataset?{facet:cb.dataset.facet,value:cb.value}:{};
+ sel[facet]=sel[facet]||new Set(); cb.checked?sel[facet].add(value):sel[facet].delete(value); render();});
+ const pgo=$('#pgo'); if(pgo)pgo.onclick=()=>{const a=$('#pmin').value,b=$('#pmax').value;sel._pmin=a?+a:null;sel._pmax=b?+b:null;render();};
+}
+function renderSelbar(){
+ const bits=[];
+ for(const f of FACETS){const s=sel[f.k];if(s)[...s].forEach(v=>bits.push([f.k,v,`${f.label}: ${v}`]));}
+ if(sel._pmin!=null||sel._pmax!=null)bits.push(['_price',null,`Price ${sel._pmin??''}–${sel._pmax??''}`]);
+ const bar=$('#selbar');
+ if(!bits.length){bar.style.display='none';return;}
+ bar.style.display='flex';
+ bar.innerHTML=bits.map(([k,v,lbl])=>`<span class="chip" data-k="${k}" data-v="${(v||'').replace(/"/g,'"')}">${lbl} <b>✕</b></span>`).join('')+
+ `<span class="chip" id="clrall" style="background:#f0ece4;color:#6b6459">Clear all</span>`;
+ $$('.chip[data-k]').forEach(c=>c.onclick=()=>{const k=c.dataset.k,v=c.dataset.v;if(k==='_price'){sel._pmin=sel._pmax=null;}else{sel[k]&&sel[k].delete(v);}render();});
+ $('#clrall').onclick=()=>{sel={};$('#q').value='';render();};
+}
+function render(){
+ let rows=applyFilters();
+ const s=$('#sort').value;
+ const cmp={fam:(a,b)=>(a.family+a.color).localeCompare(b.family+b.color),new:(a,b)=>b.dw_sku.localeCompare(a.dw_sku),
+ plo:(a,b)=>(+a.price||0)-(+b.price||0),phi:(a,b)=>(+b.price||0)-(+a.price||0),sku:(a,b)=>a.dw_sku.localeCompare(b.dw_sku)};
+ rows.sort(cmp[s]);
+ $('#count').textContent=`${rows.length} of ${DATA.length}`;
+ const g=$('#grid'),em=$('#empty');
+ if(!DATA.length){g.innerHTML='';em.style.display='block';em.textContent='No products onboarded for this brand yet.';}
+ else if(!rows.length){g.innerHTML='';em.style.display='block';em.textContent='No products match these filters.';}
+ else{em.style.display='none';
+ g.innerHTML=rows.map(r=>`<div class="card">
+ <img loading="lazy" src="${r.img}" alt="${r.family} ${r.color}" onerror="this.style.opacity=.25">
+ <div class="m">
+ <span class="fam">${r.family}</span>
+ <span class="cw">${r.color||''}</span>
+ <span class="sku">${r.dw_sku}</span>
+ <span><span class="pr">${money(r.price)}</span> <span class="unit">${r.unit||''}</span></span>
+ ${r.packaged?`<span class="pk">📦 ${r.packaged}</span>`:''}
+ <a class="live" href="${r.live_url}" target="_blank" rel="noopener noreferrer">view live ↗</a>
+ </div></div>`).join('');
+ }
+ renderFacets(); renderSelbar();
+}
+// controls
+$('#sort').onchange=render;
+$('#q').oninput=()=>{clearTimeout(window._t);window._t=setTimeout(render,180)};
+$('#dens').oninput=e=>{document.documentElement.style.setProperty('--cols',e.target.value);localStorage.bvDens=e.target.value};
+$$('#view button').forEach(b=>b.onclick=()=>{view=b.dataset.v;$$('#view button').forEach(x=>x.classList.toggle('on',x===b));
+ document.body.classList.toggle('list',view==='list');localStorage.bvView=view;});
+if(localStorage.bvDens){$('#dens').value=localStorage.bvDens;document.documentElement.style.setProperty('--cols',localStorage.bvDens);}
+if(localStorage.bvView==='list'){$('[data-v=list]').click();}
+// brand switcher
+fetch(location.origin+'/api/brands').then(r=>r.json()).then(b=>{
+ $('#brandsel').innerHTML=b.brands.map(x=>`<option ${x===b.current?'selected':''}>${x}</option>`).join('');
+ $('#brandsel').onchange=e=>location.search='?brand='+e.target.value;
+});
+function load(){fetch(location.origin+'/api/products'+location.search).then(r=>r.json()).then(d=>{
+ DATA=d.products||[];BRAND=d.brand;
+ const pretty=BRAND.replace(/(^|[^a-z])([a-z])/g,(m,a,c)=>a+c.toUpperCase());
+ $('#brand').innerHTML=pretty.replace(/([A-Z])/g,' $1').trim()+' <b>· admin</b>';
+ document.title=pretty+' — DW Brand Admin';
+ render();
+});}
+load();
+</script></body></html>
diff --git a/scripts/brand-microsites/server.cjs b/scripts/brand-microsites/server.cjs
new file mode 100644
index 00000000..5948b13e
--- /dev/null
+++ b/scripts/brand-microsites/server.cjs
@@ -0,0 +1,48 @@
+// Brand admin micro-site server (template for <brand>.designerwallcoverings.com).
+// Basic auth admin/DW2024!. Brand resolved from: ?brand= | subdomain host | BRAND env | first available.
+// Serves data/<brand>.json + img/<brand>/* + the enhanced Amazon-style viewer.
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const DIR = __dirname;
+const USER = 'admin', PASS = 'DW2024!';
+const PORT = parseInt(process.env.PORT || '0', 10);
+const MIME = { '.html': 'text/html; charset=utf-8', '.json': 'application/json', '.js': 'text/javascript', '.css': 'text/css', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.svg': 'image/svg+xml' };
+const brands = () => fs.readdirSync(`${DIR}/data`).filter(f => f.endsWith('.json')).map(f => f.replace('.json', ''));
+
+const server = http.createServer((req, res) => {
+ const [, b64] = (req.headers.authorization || '').split(' ');
+ const [u, p] = Buffer.from(b64 || '', 'base64').toString().split(':');
+ if (u !== USER || p !== PASS) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Brand Admin"' }); return res.end('Auth required'); }
+
+ const url = new URL(req.url, 'http://x');
+ const host = (req.headers.host || '').split(':')[0];
+ const sub = host.split('.')[0];
+ const avail = brands();
+ const brand = (url.searchParams.get('brand') || (avail.includes(sub) ? sub : '') || process.env.BRAND || avail[0] || 'daisybennett');
+
+ if (url.pathname === '/api/brands') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ brands: avail, current: brand })); }
+ if (url.pathname === '/api/products') {
+ const f = `${DIR}/data/${brand}.json`;
+ if (!fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ brand, products: [], empty: true })); }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ brand, products: JSON.parse(fs.readFileSync(f, 'utf8')) }));
+ }
+ let rel = decodeURIComponent(url.pathname);
+ if (rel === '/') rel = '/public/index.html';
+ else if (rel.startsWith('/img/')) rel = rel;
+ else rel = '/public' + rel;
+ const file = path.join(DIR, rel);
+ if (!file.startsWith(DIR)) { res.writeHead(403); return res.end('no'); }
+ fs.readFile(file, (e, data) => {
+ if (e) { res.writeHead(404); return res.end('not found'); }
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
+ res.end(data);
+ });
+});
+server.listen(PORT, '127.0.0.1', () => {
+ const port = server.address().port;
+ console.log(`Brand admin viewer → http://127.0.0.1:${port}/ (admin / DW2024!)`);
+ console.log(`brands: ${brands().join(', ') || '(none yet)'}`);
+ fs.writeFileSync('/tmp/brand-viewer.port', String(port));
+});
← 874d744e 5x: report — favicon 404 fixed (M3 clean x2); B6/click-throu
·
back to Designer Wallcoverings
·
sangetsu dossier: record Goodrich sole/exclusive distributor 68d732b0 →