[object Object]

← 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

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,'&quot;')}" ${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,'&quot;')}">${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 →