← back to Dw Vendor Microsites
build british_walls (British Walls) -> BUILT, port 10095, 506 skus, img 0.975
4827277f1bf4dfbef4bf8eae730021681188e42f · 2026-06-30 15:05:41 -0700 · Steve
Files touched
M manifest.jsonA vendors/british_walls/viewer/fieldmap.jsonA vendors/british_walls/viewer/public/buybutton.jsA vendors/british_walls/viewer/public/index.htmlA vendors/british_walls/viewer/server.js
Diff
commit 4827277f1bf4dfbef4bf8eae730021681188e42f
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jun 30 15:05:41 2026 -0700
build british_walls (British Walls) -> BUILT, port 10095, 506 skus, img 0.975
---
manifest.json | 10 +-
vendors/british_walls/viewer/fieldmap.json | 6 +
vendors/british_walls/viewer/public/buybutton.js | 70 ++++
vendors/british_walls/viewer/public/index.html | 414 ++++++++++++++++++++++
vendors/british_walls/viewer/server.js | 417 +++++++++++++++++++++++
5 files changed, 914 insertions(+), 3 deletions(-)
diff --git a/manifest.json b/manifest.json
index c76c398..5e2a317 100644
--- a/manifest.json
+++ b/manifest.json
@@ -2192,9 +2192,13 @@
"table_count": 506,
"samples_only": true,
"status": "OK",
- "build_status": "PENDING",
+ "build_status": "BUILT",
"deploy_status": "PENDING",
- "wave": 11
+ "wave": 11,
+ "port": 10095,
+ "sku_count": 506,
+ "image_health": 0.975,
+ "image_status": "HEALTHY"
},
"brunschwig": {
"vendor_code": "brunschwig",
@@ -2257,7 +2261,7 @@
"wave": 16
}
},
- "updated_at": "2026-06-30T15:05:40.188501",
+ "updated_at": "2026-06-30T15:05:41.300982",
"stats": {
"total_vendors": 118,
"buildable": 117,
diff --git a/vendors/british_walls/viewer/fieldmap.json b/vendors/british_walls/viewer/fieldmap.json
new file mode 100644
index 0000000..75924f2
--- /dev/null
+++ b/vendors/british_walls/viewer/fieldmap.json
@@ -0,0 +1,6 @@
+{
+ "title": "British Walls",
+ "samples_only": true,
+ "vendor_code": "british_walls",
+ "subdomain": "british-walls"
+}
diff --git a/vendors/british_walls/viewer/public/buybutton.js b/vendors/british_walls/viewer/public/buybutton.js
new file mode 100644
index 0000000..f479e9a
--- /dev/null
+++ b/vendors/british_walls/viewer/public/buybutton.js
@@ -0,0 +1,70 @@
+/* Shopify sample checkout for the DW vendor microsites.
+ *
+ * These vendor lines are OFF Shopify (no per-product Shopify variant), so "Request
+ * Sample" cannot add a per-product variant. Instead it uses ONE generic "Memo Sample"
+ * Shopify product/variant and adds it to a real Shopify cart once per requested swatch,
+ * carrying each swatch's pattern/color/SKU/vendor as line-item custom properties, then
+ * opens Shopify's secure hosted checkout. The order line-items tell fulfillment which
+ * swatches to pull.
+ *
+ * Config is injected by the server at /config.js as window.DWV_CFG = {
+ * domain: '<store>.myshopify.com', storefrontToken: '<token>', sampleVariantId: '<gid or numeric>'
+ * }. Until storefrontToken + sampleVariantId are set, this falls back to the no-payment
+ * JSONL sample-request endpoint (POST /api/sample-request) so the button always works.
+ */
+(function () {
+ var CFG = window.DWV_CFG || {};
+ var ready = CFG.storefrontToken && CFG.sampleVariantId && CFG.domain;
+ var client = null, checkoutId = null;
+
+ function loadSDK(cb) {
+ if (window.ShopifyBuy && window.ShopifyBuy.buildClient) return cb();
+ var s = document.createElement('script');
+ s.src = 'https://sdks.shopifycdn.com/js-buy-sdk/latest/index.umd.min.js';
+ s.onload = cb; s.onerror = function () { ready = false; cb(); };
+ document.head.appendChild(s);
+ }
+
+ function ensureClient(cb) {
+ if (client) return cb();
+ loadSDK(function () {
+ if (!window.ShopifyBuy || !ready) { ready = false; return cb(); }
+ client = window.ShopifyBuy.buildClient({ domain: CFG.domain, storefrontAccessToken: CFG.storefrontToken });
+ client.checkout.create().then(function (co) { checkoutId = co.id; cb(); }).catch(function () { ready = false; cb(); });
+ });
+ }
+
+ // items: [{sku, mfr_sku, pattern_name, color_name, line}] -> Shopify checkout
+ function shopifyCheckout(items, contact) {
+ return new Promise(function (resolve, reject) {
+ ensureClient(function () {
+ if (!ready || !client || !checkoutId) return reject(new Error('shopify-unavailable'));
+ var lines = items.slice(0, 250).map(function (it) {
+ return {
+ variantId: CFG.sampleVariantId,
+ quantity: 1,
+ customAttributes: [
+ { key: 'Pattern', value: String(it.pattern_name || it.sku || '') },
+ { key: 'Color', value: String(it.color_name || '') },
+ { key: 'SKU', value: String(it.mfr_sku || it.sku || '') },
+ { key: 'Line', value: String(it.line || '') }
+ ]
+ };
+ });
+ var attrs = [];
+ if (contact && contact.name) attrs.push({ key: 'Requested by', value: contact.name });
+ var p = client.checkout.addLineItems(checkoutId, lines);
+ p.then(function (co) {
+ if (attrs.length) return client.checkout.updateAttributes(checkoutId, { customAttributes: attrs }).then(function () { return co; });
+ return co;
+ }).then(function (co) { window.location.href = co.webUrl; resolve(co); }).catch(reject);
+ });
+ });
+ }
+
+ // Expose for the viewer's sample modal. Returns true if Shopify path is wired.
+ window.DWV_SAMPLE = {
+ available: function () { return !!ready; },
+ checkout: shopifyCheckout
+ };
+})();
diff --git a/vendors/british_walls/viewer/public/index.html b/vendors/british_walls/viewer/public/index.html
new file mode 100644
index 0000000..ce0d82b
--- /dev/null
+++ b/vendors/british_walls/viewer/public/index.html
@@ -0,0 +1,414 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex, nofollow">
+<title>Trade Lines</title>
+<style>
+ :root{
+ --cols: 5; /* density-driven grid columns */
+ --ink:#1c1a17; --muted:#6f6a62; --line:#e7e2da; --bg:#faf8f4; --card:#fff;
+ --accent:#7a6a52; --accent2:#3c5a4a;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0}
+ body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:var(--ink);background:var(--bg)}
+ a{color:inherit}
+ header.top{padding:22px 24px 12px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:0;z-index:30}
+ .brand{font-size:24px;letter-spacing:.04em;font-weight:600}
+ .brand small{display:block;font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);font-weight:500;margin-top:3px}
+ .count{color:var(--muted);font-size:13px;margin-left:auto}
+ .toprow{display:flex;align-items:flex-end;gap:18px;flex-wrap:wrap}
+ /* controls bar */
+ .controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 24px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:73px;z-index:20}
+ .controls label{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);display:flex;flex-direction:column;gap:4px}
+ select,input[type=search],input[type=text],input[type=email],textarea{font:inherit;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink)}
+ input[type=range]{width:120px}
+ .grow{flex:1;min-width:180px}
+ .density{display:flex;flex-direction:column;gap:4px}
+ .cartbtn{margin-left:auto;background:var(--ink);color:#fff;border:none;border-radius:8px;padding:9px 16px;font-weight:600;cursor:pointer;letter-spacing:.02em}
+ .cartbtn .n{display:inline-block;background:var(--accent2);border-radius:20px;padding:1px 8px;margin-left:6px;font-size:12px}
+ /* active-filter chips bar */
+ .activebar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:10px 24px;border-bottom:1px solid var(--line);background:#fcfbf8}
+ .activebar.empty{display:none}
+ .achip{display:inline-flex;align-items:center;gap:6px;background:var(--ink);color:#fff;border-radius:20px;padding:4px 6px 4px 11px;font-size:12.5px;line-height:1}
+ .achip .k{opacity:.6;text-transform:uppercase;letter-spacing:.06em;font-size:10px}
+ .achip .x{border:none;background:rgba(255,255,255,.2);color:#fff;border-radius:50%;width:18px;height:18px;cursor:pointer;font-size:13px;line-height:1;padding:0}
+ .clearall{margin-left:auto;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 12px;cursor:pointer;font-size:12.5px}
+ /* layout */
+ .wrap{display:flex;align-items:flex-start}
+ aside{width:248px;flex:0 0 248px;padding:18px 16px 60px;border-right:1px solid var(--line);position:sticky;top:135px;max-height:calc(100vh - 135px);overflow:auto}
+ aside h3{font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);margin:18px 0 8px}
+ aside h3:first-child{margin-top:0}
+ /* facet CHIPS (toggle, multi-select) */
+ .chips{display:flex;flex-wrap:wrap;gap:6px}
+ .chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:18px;padding:4px 10px;font-size:12.5px;cursor:pointer;user-select:none}
+ .chip .cc{color:var(--muted);font-size:10.5px}
+ .chip.on{background:var(--ink);color:#fff;border-color:var(--ink)}
+ .chip.on .cc{color:rgba(255,255,255,.7)}
+ .chip.zero{opacity:.38;cursor:default;pointer-events:none}
+ .chip .swatch{width:11px;height:11px;border-radius:50%;border:1px solid rgba(0,0,0,.2);flex:0 0 auto}
+ .facet-search{width:100%;margin-bottom:8px;padding:5px 8px;font-size:13px}
+ .facet-scroll{max-height:230px;overflow:auto;padding-right:4px}
+ .clearbtn{margin-top:14px;width:100%;padding:8px;border:1px solid var(--line);background:#fff;border-radius:7px;cursor:pointer;font-size:13px}
+ main{flex:1;padding:18px 24px 80px;min-width:0}
+ .grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:14px}
+ /* MINIMAL image-forward cards: image + hex dot(s) only; detail on hover/focus */
+ .card{position:relative;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
+ .card .imgwrap{aspect-ratio:1/1;background:#f1ede6;overflow:hidden;position:relative}
+ .card img{width:100%;height:100%;object-fit:cover;display:block}
+ .card .dots{position:absolute;left:8px;bottom:8px;display:flex;gap:4px;z-index:2}
+ .card .dot{width:14px;height:14px;border-radius:50%;border:1px solid rgba(255,255,255,.85);box-shadow:0 0 0 1px rgba(0,0,0,.18)}
+ /* reveal overlay (hover or .open via click) */
+ .reveal{position:absolute;inset:auto 0 0 0;background:linear-gradient(to top,rgba(20,18,15,.92),rgba(20,18,15,.78) 60%,rgba(20,18,15,0));color:#fff;padding:30px 11px 11px;transform:translateY(101%);transition:transform .18s ease;display:flex;flex-direction:column;gap:3px}
+ .card:hover .reveal,.card.open .reveal,.card:focus-within .reveal{transform:translateY(0)}
+ .reveal .ptitle{font-weight:600;font-size:13.5px;line-height:1.25}
+ .reveal .rmeta{font-size:11.5px;color:rgba(255,255,255,.82)}
+ .reveal .badge{display:inline-block;font-size:9.5px;letter-spacing:.06em;text-transform:uppercase;background:rgba(255,255,255,.16);border-radius:20px;padding:2px 7px;align-self:flex-start;margin-bottom:1px}
+ .reqbtn{margin-top:6px;border:1px solid #fff;background:transparent;color:#fff;border-radius:7px;padding:6px;font-size:12px;font-weight:600;cursor:pointer}
+ .reqbtn.in{background:var(--accent2);border-color:var(--accent2)}
+ #sentinel{height:60px}
+ .status{text-align:center;color:var(--muted);padding:24px;font-size:13px}
+ /* modal */
+ .overlay{position:fixed;inset:0;background:rgba(28,26,23,.45);display:none;align-items:flex-start;justify-content:center;z-index:50;padding:40px 16px;overflow:auto}
+ .overlay.open{display:flex}
+ .modal{background:#fff;border-radius:12px;max-width:560px;width:100%;padding:22px 24px 26px}
+ .modal h2{margin:0 0 4px;font-size:20px}
+ .modal p.sub{margin:0 0 16px;color:var(--muted);font-size:13px}
+ .cartlist{max-height:230px;overflow:auto;border:1px solid var(--line);border-radius:8px;margin-bottom:16px}
+ .citem{display:flex;align-items:center;gap:10px;padding:8px 10px;border-bottom:1px solid var(--line);font-size:13px}
+ .citem:last-child{border-bottom:none}
+ .citem img{width:42px;height:42px;object-fit:cover;border-radius:6px;background:#eee}
+ .citem .x{margin-left:auto;border:none;background:none;color:var(--muted);cursor:pointer;font-size:18px}
+ .field{display:flex;flex-direction:column;gap:4px;margin-bottom:11px}
+ .field label{font-size:12px;color:var(--muted)}
+ .row2{display:flex;gap:11px}.row2 .field{flex:1}
+ .submit{width:100%;background:var(--ink);color:#fff;border:none;border-radius:8px;padding:12px;font-weight:600;font-size:15px;cursor:pointer;margin-top:4px}
+ .submit:disabled{opacity:.5;cursor:default}
+ .closex{float:right;border:none;background:none;font-size:22px;cursor:pointer;color:var(--muted);line-height:1}
+ .ok{background:#eef5ef;border:1px solid #cfe3d4;color:#2c5d3c;padding:12px;border-radius:8px;font-size:14px}
+ @media(max-width:820px){aside{display:none}.controls{top:auto;position:static}}
+</style>
+</head>
+<body>
+<header class="top">
+ <div class="toprow">
+ <div class="brand" id="brand">Trade Lines<small>Designer Wallcoverings</small></div>
+ <div class="count" id="count">loading…</div>
+ </div>
+</header>
+
+<div class="controls">
+ <label class="grow">Search
+ <input type="search" id="q" placeholder="pattern, color, SKU, collection…" autocomplete="off">
+ </label>
+ <label>Sort
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="family">Color Family</option>
+ <option value="style">Style</option>
+ <option value="pattern">Pattern A→Z</option>
+ <option value="color_name">Color name A→Z</option>
+ <option value="collection">Collection A→Z</option>
+ <option value="line">Line</option>
+ <option value="product_type">Product Type</option>
+ <option value="material">Material</option>
+ <option value="light">Light → Dark</option>
+ <option value="dark">Dark → Light</option>
+ <option value="hue">Color Wheel (hue)</option>
+ <option value="sku">SKU A→Z</option>
+ </select>
+ </label>
+ <label class="density">Density
+ <input type="range" id="density" min="2" max="8" step="1" value="5">
+ </label>
+ <button class="cartbtn" id="cartbtn">Request Samples<span class="n" id="cartn">0</span></button>
+</div>
+
+<!-- active-filter chips -->
+<div class="activebar empty" id="activebar"></div>
+
+<div class="wrap">
+ <aside id="facets"></aside>
+ <main>
+ <div class="grid" id="grid"></div>
+ <div id="sentinel"></div>
+ <div class="status" id="status"></div>
+ </main>
+</div>
+
+<!-- sample request modal -->
+<div class="overlay" id="overlay">
+ <div class="modal">
+ <button class="closex" id="closex">×</button>
+ <h2>Request Samples</h2>
+ <p class="sub">No payment — we’ll mail trade samples of the items below.</p>
+ <div id="formArea">
+ <div class="cartlist" id="cartlist"></div>
+ <form id="reqform">
+ <div class="row2">
+ <div class="field"><label>Name *</label><input type="text" name="name" required></div>
+ <div class="field"><label>Email *</label><input type="email" name="email" required></div>
+ </div>
+ <div class="row2">
+ <div class="field"><label>Company / Studio</label><input type="text" name="company"></div>
+ <div class="field"><label>Phone</label><input type="text" name="phone"></div>
+ </div>
+ <div class="field"><label>Shipping address</label><textarea name="address" rows="2"></textarea></div>
+ <div class="field"><label>Notes</label><textarea name="notes" rows="2" placeholder="Optional"></textarea></div>
+ <button class="submit" id="submitbtn" type="submit">Submit sample request</button>
+ </form>
+ </div>
+ <div id="doneArea" style="display:none"></div>
+ </div>
+</div>
+
+<script>
+// per-vendor localStorage namespace (set after /api/config so two microsites don't collide)
+let NS = 'dwvm';
+const LS = {
+ get:(k,d)=>{try{const v=localStorage.getItem(NS+'.'+k);return v==null?d:JSON.parse(v);}catch{return d;}},
+ set:(k,v)=>{try{localStorage.setItem(NS+'.'+k,JSON.stringify(v));}catch{}}
+};
+const $ = (s)=>document.querySelector(s);
+const grid=$('#grid'), statusEl=$('#status'), countEl=$('#count');
+
+// approximate swatch color per family (for the chip dot)
+const FAMILY_SWATCH = {
+ 'Red':'#c0392b','Orange/Brown':'#b5651d','Yellow':'#e1c340','Green':'#4a7c3f','Teal':'#2a9d8f',
+ 'Blue':'#3a6ea5','Purple':'#7e57c2','Pink':'#e08aa8','Neutral/Beige':'#d8cbb3','Black':'#222',
+ 'Grey':'#9a9a9a','White':'#f4f4f0'
+};
+// pretty labels for the active-chip key
+const FACET_LABEL = {family:'Color',style:'Style',material_bucket:'Material',enriched:'Status',line:'Line',product_type:'Type',collection:'Collection'};
+
+// ---- persisted UI state ----
+let sort, density, cart, active;
+let offset=0, total=0, loading=false, done=false;
+const LIMIT=60;
+
+function qs(){
+ const p=new URLSearchParams();
+ p.set('sort',sort); p.set('offset',offset); p.set('limit',LIMIT);
+ const q=$('#q').value.trim(); if(q) p.set('q',q);
+ for(const k of Object.keys(active)){ if(active[k] && active[k].length) p.set(k, active[k].join('|')); }
+ return p.toString();
+}
+function reset(){ offset=0; total=0; done=false; grid.innerHTML=''; statusEl.textContent=''; renderActive(); load(); }
+
+async function load(){
+ if(loading||done) return;
+ loading=true; statusEl.textContent='loading…';
+ try{
+ const r=await fetch('/api/skus?'+qs());
+ const d=await r.json();
+ total=d.total;
+ for(const row of d.rows) grid.appendChild(card(row));
+ offset += d.rows.length;
+ if(offset>=total || d.rows.length===0) done=true;
+ countEl.textContent = total.toLocaleString()+' of '+d.all.toLocaleString()+' products';
+ statusEl.textContent = done ? (total? 'End of results — '+total.toLocaleString()+' shown' : 'No products match your filters.') : '';
+ }catch(e){ statusEl.textContent='Error loading products.'; }
+ loading=false;
+}
+
+function inCart(sku){ return cart.some(c=>c.sku===sku); }
+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+
+// MINIMAL card: image + hex dot(s) only; everything else in hover/click reveal
+function card(row){
+ const el=document.createElement('div'); el.className='card'; el.tabIndex=0;
+ const img = row.image ? `<img loading="lazy" src="${row.image}" alt="${esc(row.pattern_name)}" onerror="this.style.opacity=.25">` : '';
+ let dots='';
+ if(row.color_hex) dots += `<span class="dot" style="background:${row.color_hex}" title="${row.color_hex}"></span>`;
+ if(!row.color_hex && row.family) dots += `<span class="dot" style="background:${FAMILY_SWATCH[row.family]||'#bbb'}" title="${esc(row.family)}"></span>`;
+ const lineBadge = row.line ? `<span class="badge">${esc(row.line)}</span>` : '';
+ el.innerHTML = `
+ <div class="imgwrap">
+ ${img}
+ <div class="dots">${dots}</div>
+ <div class="reveal">
+ ${lineBadge}
+ <div class="ptitle">${esc(row.pattern_name)}</div>
+ <div class="rmeta">${esc(row.color_name||'—')}${row.family?' · '+esc(row.family):''}</div>
+ ${row.collection?`<div class="rmeta">${esc(row.collection)}</div>`:''}
+ <button class="reqbtn ${inCart(row.sku)?'in':''}" data-sku="${esc(row.sku)}">${inCart(row.sku)?'✓ In sample request':'Request Sample'}</button>
+ </div>
+ </div>`;
+ // click anywhere on card (not the button) toggles the reveal open (touch/click reveal)
+ el.addEventListener('click',(e)=>{ if(e.target.closest('.reqbtn')) return; el.classList.toggle('open'); });
+ el.querySelector('.reqbtn').addEventListener('click',(e)=>{e.stopPropagation();toggleCart(row, e.currentTarget);});
+ return el;
+}
+
+// ---- cart ----
+function toggleCart(row, btn){
+ if(inCart(row.sku)){ cart=cart.filter(c=>c.sku!==row.sku); }
+ else cart.push({sku:row.sku,mfr_sku:row.mfr_sku,pattern_name:row.pattern_name,color_name:row.color_name,line:row.line,image:row.image});
+ LS.set('cart',cart);
+ if(btn){ btn.classList.toggle('in',inCart(row.sku)); btn.textContent=inCart(row.sku)?'✓ In sample request':'Request Sample'; }
+ $('#cartn').textContent=cart.length;
+}
+
+// ---- active-filter chip bar (removable) ----
+function renderActive(){
+ const bar=$('#activebar'); bar.innerHTML='';
+ let any=false;
+ for(const k of Object.keys(active)){
+ for(const v of (active[k]||[])){
+ any=true;
+ const c=document.createElement('span'); c.className='achip';
+ const label = k==='enriched' ? (v==='enriched'?'Enriched':'Unenriched') : v;
+ c.innerHTML=`<span class="k">${esc(FACET_LABEL[k]||k)}</span><span>${esc(label)}</span><button class="x" title="remove">×</button>`;
+ c.querySelector('.x').addEventListener('click',()=>{ active[k]=active[k].filter(x=>x!==v); if(!active[k].length) delete active[k]; LS.set('filters',active); loadFacets(); reset(); });
+ bar.appendChild(c);
+ }
+ }
+ if(any){
+ const clr=document.createElement('button'); clr.className='clearall'; clr.textContent='Clear all';
+ clr.addEventListener('click',()=>{active={};LS.set('filters',active);loadFacets();reset();});
+ bar.appendChild(clr);
+ bar.classList.remove('empty');
+ } else bar.classList.add('empty');
+}
+
+function toggleFacet(key,value){
+ active[key]=active[key]||[];
+ if(active[key].includes(value)) active[key]=active[key].filter(v=>v!==value);
+ else active[key].push(value);
+ if(!active[key].length) delete active[key];
+ LS.set('filters',active); loadFacets(); reset();
+}
+
+// ---- facets as CHIPS ----
+async function loadFacets(){
+ const r=await fetch('/api/facets'); const d=await r.json();
+ const f=d.facets;
+ const meta=[
+ ['family','Color Family',f.family,false,true],
+ ['style','Style',f.style,false,false],
+ ['material_bucket','Material',f.material_bucket,false,false],
+ ['enriched','Enrichment',f.enriched,false,false],
+ ['line','Line',f.line,false,false],
+ ['product_type','Product Type',f.product_type,false,false],
+ ['collection','Collection',f.collection,true,false],
+ ];
+ const root=$('#facets'); root.innerHTML='';
+ for(const [key,title,vals,searchable,swatch] of meta){
+ if(!vals || !vals.length) continue;
+ const h=document.createElement('h3'); h.textContent=title; root.appendChild(h);
+ let search=null;
+ if(searchable){
+ search=document.createElement('input'); search.type='text'; search.className='facet-search'; search.placeholder='Filter '+title.toLowerCase()+'…';
+ root.appendChild(search);
+ }
+ const box=document.createElement('div'); box.className=searchable?'chips facet-scroll':'chips';
+ for(const {value,count} of vals){
+ const on=(active[key]||[]).includes(value);
+ const chip=document.createElement('span');
+ chip.className='chip'+(on?' on':'')+(count===0&&!on?' zero':'');
+ chip.dataset.v=value.toLowerCase();
+ const label = key==='enriched' ? (value==='enriched'?'Enriched':'Unenriched') : value;
+ const sw = swatch ? `<span class="swatch" style="background:${FAMILY_SWATCH[value]||'#bbb'}"></span>` : '';
+ chip.innerHTML=`${sw}<span>${esc(label)}</span><span class="cc">${count}</span>`;
+ if(!(count===0&&!on)) chip.addEventListener('click',()=>toggleFacet(key,value));
+ box.appendChild(chip);
+ }
+ root.appendChild(box);
+ if(search) search.addEventListener('input',()=>{const t=search.value.toLowerCase();box.querySelectorAll('.chip').forEach(l=>l.style.display=l.dataset.v.includes(t)?'':'none');});
+ }
+ const clear=document.createElement('button'); clear.className='clearbtn'; clear.textContent='Clear all filters';
+ clear.addEventListener('click',()=>{active={};LS.set('filters',active);loadFacets();reset();});
+ root.appendChild(clear);
+}
+
+// ---- controls ----
+$('#sort').addEventListener('change',e=>{sort=e.target.value;LS.set('sort',sort);reset();});
+$('#density').addEventListener('input',e=>{density=+e.target.value;LS.set('density',density);document.documentElement.style.setProperty('--cols',density);});
+let qt; $('#q').addEventListener('input',()=>{clearTimeout(qt);qt=setTimeout(reset,250);});
+
+// ---- infinite scroll ----
+new IntersectionObserver((ents)=>{ if(ents[0].isIntersecting) load(); },{rootMargin:'600px'}).observe($('#sentinel'));
+
+// ---- sample modal ----
+const overlay=$('#overlay');
+function openCart(){
+ renderCartList();
+ $('#formArea').style.display = cart.length? '' : 'none';
+ $('#doneArea').style.display='none'; $('#doneArea').innerHTML='';
+ overlay.classList.add('open');
+}
+function closeCart(){ overlay.classList.remove('open'); }
+$('#cartbtn').addEventListener('click',openCart);
+$('#closex').addEventListener('click',closeCart);
+overlay.addEventListener('click',e=>{if(e.target===overlay)closeCart();});
+
+function renderCartList(){
+ const list=$('#cartlist');
+ if(!cart.length){ list.innerHTML='<div class="status">No items yet. Click “Request Sample” on a product.</div>'; return; }
+ list.innerHTML='';
+ cart.forEach(it=>{
+ const row=document.createElement('div'); row.className='citem';
+ row.innerHTML=`${it.image?`<img src="${it.image}">`:''}
+ <div><strong>${esc(it.pattern_name||it.sku)}</strong><br><span style="color:#6f6a62">${esc(it.color_name||'')} ${it.line?'· '+esc(it.line):''}</span></div>
+ <button class="x" title="remove">×</button>`;
+ row.querySelector('.x').addEventListener('click',()=>{cart=cart.filter(c=>c.sku!==it.sku);LS.set('cart',cart);$('#cartn').textContent=cart.length;document.querySelectorAll('.reqbtn').forEach(b=>{if(b.dataset.sku===it.sku){b.classList.remove('in');b.textContent='Request Sample';}});openCart();});
+ list.appendChild(row);
+ });
+}
+
+$('#reqform').addEventListener('submit',async e=>{
+ e.preventDefault();
+ if(!cart.length) return;
+ const fd=new FormData(e.target);
+ const payload={items:cart};
+ for(const [k,v] of fd.entries()) payload[k]=v;
+ const btn=$('#submitbtn'); btn.disabled=true; btn.textContent='Submitting…';
+ // Shopify checkout path (real sample order) when wired; else no-payment request below.
+ if(window.DWV_SAMPLE && window.DWV_SAMPLE.available()){
+ try{ btn.textContent='Opening secure checkout…'; await window.DWV_SAMPLE.checkout(cart,{name:payload.name,email:payload.email}); return; }
+ catch(err){ /* fall through to the no-payment request flow */ }
+ }
+ try{
+ const r=await fetch('/api/sample-request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
+ const d=await r.json();
+ if(d.ok){
+ cart=[]; LS.set('cart',cart); $('#cartn').textContent=0;
+ document.querySelectorAll('.reqbtn.in').forEach(b=>{b.classList.remove('in');b.textContent='Request Sample';});
+ $('#formArea').style.display='none';
+ $('#doneArea').style.display='';
+ $('#doneArea').innerHTML=`<div class="ok">Thank you, ${esc(payload.name)} — your request for ${d.count} sample${d.count===1?'':'s'} was received. We’ll be in touch at ${esc(payload.email)}.</div>`;
+ } else { btn.disabled=false; btn.textContent='Submit sample request'; alert(d.error||'Could not submit.'); }
+ }catch(err){ btn.disabled=false; btn.textContent='Submit sample request'; alert('Network error.'); }
+});
+
+// ---- boot: read /api/config for title + per-vendor namespace, then hydrate state ----
+(async function boot(){
+ try{
+ const cfg=await (await fetch('/api/config')).json();
+ if(cfg.title){
+ document.title = cfg.title;
+ $('#brand').innerHTML = esc(cfg.title)+'<small>Designer Wallcoverings</small>';
+ NS = 'dwvm.'+cfg.title.replace(/[^a-z0-9]+/gi,'_').toLowerCase();
+ }
+ window.DWV_CFG = cfg.shopify || {};
+ if(window.DWV_CFG.storefrontToken && window.DWV_CFG.sampleVariantId){
+ await new Promise(res=>{const s=document.createElement('script');s.src='/buybutton.js';s.onload=res;s.onerror=res;document.head.appendChild(s);});
+ }
+ }catch{}
+ sort = LS.get('sort','newest');
+ density = LS.get('density',5);
+ cart = LS.get('cart',[]);
+ active = LS.get('filters',{});
+ $('#sort').value=sort;
+ $('#density').value=density;
+ document.documentElement.style.setProperty('--cols',density);
+ $('#cartn').textContent=cart.length;
+ renderActive();
+ loadFacets();
+ load();
+})();
+</script>
+</body>
+</html>
diff --git a/vendors/british_walls/viewer/server.js b/vendors/british_walls/viewer/server.js
new file mode 100644
index 0000000..9904dbf
--- /dev/null
+++ b/vendors/british_walls/viewer/server.js
@@ -0,0 +1,417 @@
+#!/usr/bin/env node
+/**
+ * DW Vendor Microsite — generalized catalog viewer (LOCAL, read-only browse + sample-order).
+ *
+ * Generalized from ~/Projects/cowtan-lines/viewer/server.js. The engine is
+ * vendor-agnostic: buildRows / sorters / facets / the /api/sample-request flow are
+ * uniform across every vendor's catalog schema (the staging JSONL is already
+ * normalized to the canonical cowtan shape by lib/export-catalog.sh).
+ *
+ * What is vendor-specific (read from argv / env / fieldmap.json):
+ * - JSONL source path : argv[3] || env VENDOR_JSONL || ../staging/cowtan.jsonl
+ * - banner / page title : env VENDOR_TITLE || fieldmap.title || 'Vendor Trade Lines'
+ * - samples-only policy : fieldmap.samples_only (no prices/checkout when true)
+ *
+ * These lines are intentionally OFF Shopify — the ONLY purchase action is a no-payment
+ * sample request. No prices are sold, no Shopify links, no checkout.
+ *
+ * Zero dependencies. Images are live vendor CDN URLs, used as-is (never upscaled).
+ * node server.js [port] [jsonl] [fieldmap]
+ * port default 0 = OS-assigned free port
+ * jsonl default env VENDOR_JSONL || ../staging/cowtan.jsonl
+ * fieldmap default env VENDOR_FIELDMAP || ./fieldmap.json (optional)
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = parseInt(process.argv[2] || '0', 10);
+const DIR = path.join(__dirname, '..', '..', '..', 'staging'); // repo/vendors/<v>/viewer -> repo/staging
+const SRC = process.argv[3] || process.env.VENDOR_JSONL || path.join(__dirname, '..', 'staging', 'cowtan.jsonl');
+const FIELDMAP_PATH = process.argv[4] || process.env.VENDOR_FIELDMAP || path.join(__dirname, 'fieldmap.json');
+const PUBLIC = path.join(__dirname, 'public');
+const DATA = path.join(__dirname, 'data');
+const REQUESTS = path.join(DATA, 'sample-requests.jsonl');
+
+let FIELDMAP = {};
+try { if (fs.existsSync(FIELDMAP_PATH)) FIELDMAP = JSON.parse(fs.readFileSync(FIELDMAP_PATH, 'utf8')); } catch { FIELDMAP = {}; }
+const TITLE = process.env.VENDOR_TITLE || FIELDMAP.title || 'Vendor Trade Lines';
+const SAMPLES_ONLY = FIELDMAP.samples_only !== false; // default true (samples-only / no checkout)
+
+const readJsonl = (f) => (fs.existsSync(f)
+ ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
+ : []);
+
+const num = (v) => { if (v == null) return null; const m = String(v).match(/[\d.]+/); return m ? parseFloat(m[0]) : null; };
+
+// perceived luminance 0..255 from #rrggbb
+function lum(hex) {
+ if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
+ const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+// hue 0..360 from #rrggbb (for color-wheel sort)
+function hue(hex) {
+ if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
+ let r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+ if (d === 0) return -1; // grayscale -> sort to end
+ let h;
+ if (mx === r) h = ((g - b) / d) % 6;
+ else if (mx === g) h = (b - r) / d + 2;
+ else h = (r - g) / d + 4;
+ h *= 60; if (h < 0) h += 360;
+ return h;
+}
+
+// ---- ENHANCED bucketing (Steve's spec) ------------------------------------
+// Full HSL from #rrggbb -> {h:0..360, s:0..1, l:0..1} or null.
+function hsl(hex) {
+ if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
+ let r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
+ if ([r, g, b].some((v) => Number.isNaN(v))) return null;
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+ const l = (mx + mn) / 2;
+ let s = 0, h = 0;
+ if (d !== 0) {
+ s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
+ if (mx === r) h = ((g - b) / d) % 6;
+ else if (mx === g) h = (b - r) / d + 2;
+ else h = (r - g) / d + 4;
+ h *= 60; if (h < 0) h += 360;
+ }
+ return { h, s, l };
+}
+
+// Canonical color families (order = display order; 0-count still listed by facets()).
+const COLOR_FAMILIES = ['Red', 'Orange/Brown', 'Yellow', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Neutral/Beige', 'Black', 'Grey', 'White'];
+// ai_colors hint words -> family (fallback when hex absent/ambiguous).
+const AI_COLOR_TO_FAMILY = {
+ red: 'Red', crimson: 'Red', scarlet: 'Red', burgundy: 'Red', maroon: 'Red', wine: 'Red',
+ orange: 'Orange/Brown', terracotta: 'Orange/Brown', rust: 'Orange/Brown', brown: 'Orange/Brown', tan: 'Orange/Brown', camel: 'Orange/Brown', copper: 'Orange/Brown', bronze: 'Orange/Brown', amber: 'Orange/Brown', coffee: 'Orange/Brown', chocolate: 'Orange/Brown',
+ yellow: 'Yellow', gold: 'Yellow', mustard: 'Yellow', ochre: 'Yellow',
+ green: 'Green', olive: 'Green', sage: 'Green', lime: 'Green', emerald: 'Green', forest: 'Green', moss: 'Green',
+ teal: 'Teal', turquoise: 'Teal', aqua: 'Teal', cyan: 'Teal',
+ blue: 'Blue', navy: 'Blue', indigo: 'Blue', cobalt: 'Blue', azure: 'Blue', denim: 'Blue',
+ purple: 'Purple', violet: 'Purple', lavender: 'Purple', plum: 'Purple', mauve: 'Purple', lilac: 'Purple',
+ pink: 'Pink', rose: 'Pink', blush: 'Pink', fuchsia: 'Pink', magenta: 'Pink', coral: 'Pink', salmon: 'Pink',
+ beige: 'Neutral/Beige', cream: 'Neutral/Beige', ivory: 'Neutral/Beige', taupe: 'Neutral/Beige', sand: 'Neutral/Beige', linen: 'Neutral/Beige', natural: 'Neutral/Beige', oatmeal: 'Neutral/Beige', greige: 'Neutral/Beige', stone: 'Neutral/Beige',
+ black: 'Black', charcoal: 'Black', ebony: 'Black', onyx: 'Black',
+ grey: 'Grey', gray: 'Grey', silver: 'Grey', slate: 'Grey', pewter: 'Grey', smoke: 'Grey',
+ white: 'White', chalk: 'White', alabaster: 'White', snow: 'White',
+};
+// hue-degrees -> chromatic family bucket.
+function familyFromHue(h) {
+ if (h < 15 || h >= 345) return 'Red';
+ if (h < 45) return 'Orange/Brown';
+ if (h < 70) return 'Yellow';
+ if (h < 160) return 'Green';
+ if (h < 195) return 'Teal';
+ if (h < 255) return 'Blue';
+ if (h < 290) return 'Purple';
+ if (h < 345) return 'Pink';
+ return 'Red';
+}
+// Derive the color family for a row. hex first (HSL), ai_colors as fallback hint.
+function colorFamily(hex, aiColors) {
+ const c = hsl(hex);
+ if (c) {
+ // low-saturation -> achromatic by luminance
+ if (c.s < 0.12) {
+ if (c.l < 0.18) return 'Black';
+ if (c.l > 0.88) return 'White';
+ if (c.l > 0.70) return 'Neutral/Beige';
+ return 'Grey';
+ }
+ // low-ish sat + warm hue + mid/high lum -> beige rather than a vivid family
+ if (c.s < 0.22 && c.l > 0.62 && (c.h < 70 || c.h >= 330)) return 'Neutral/Beige';
+ return familyFromHue(c.h);
+ }
+ // hex missing/invalid -> ai_colors hint (first matchable word)
+ if (Array.isArray(aiColors)) {
+ for (const w of aiColors) {
+ const key = String(w || '').trim().toLowerCase();
+ if (AI_COLOR_TO_FAMILY[key]) return AI_COLOR_TO_FAMILY[key];
+ // partial: "dark green" -> green
+ for (const word of key.split(/[^a-z]+/)) if (AI_COLOR_TO_FAMILY[word]) return AI_COLOR_TO_FAMILY[word];
+ }
+ }
+ return null;
+}
+
+// Canonical styles (display order). Derived from ai_styles + ai_patterns keywords.
+const STYLE_BUCKETS = ['Floral', 'Botanical', 'Damask', 'Toile', 'Stripe', 'Geometric', 'Plaid/Check', 'Trellis', 'Animal/Fauna', 'Scenic/Mural', 'Abstract', 'Texture/Plain', 'Metallic', 'Traditional', 'Modern'];
+const STYLE_KEYWORDS = [
+ ['Floral', ['floral', 'flower', 'rose', 'peony', 'bloom', 'blossom', 'chintz']],
+ ['Botanical', ['botanical', 'leaf', 'leaves', 'fern', 'palm', 'foliage', 'tree', 'vine', 'garden', 'jungle', 'tropical']],
+ ['Damask', ['damask', 'medallion', 'ogee']],
+ ['Toile', ['toile']],
+ ['Stripe', ['stripe', 'striped', 'pinstripe', 'ticking']],
+ ['Plaid/Check', ['plaid', 'check', 'tartan', 'gingham', 'houndstooth', 'windowpane']],
+ ['Trellis', ['trellis', 'lattice', 'fretwork']],
+ ['Geometric', ['geometric', 'chevron', 'herringbone', 'diamond', 'hexagon', 'grid', 'dot', 'polka', 'moroccan', 'fret', 'greek key', 'op art']],
+ ['Animal/Fauna', ['animal', 'bird', 'butterfly', 'fauna', 'leopard', 'zebra', 'tiger', 'peacock', 'fish', 'insect', 'skin', 'hide']],
+ ['Scenic/Mural', ['scenic', 'mural', 'landscape', 'panoramic', 'chinoiserie', 'view', 'cityscape', 'map']],
+ ['Metallic', ['metallic', 'foil', 'mica', 'gilt', 'gilded', 'shimmer', 'glitter']],
+ ['Texture/Plain', ['texture', 'textured', 'plain', 'solid', 'grasscloth', 'linen', 'woven', 'weave', 'sisal', 'cork', 'faux', 'concrete', 'stucco', 'plaster', 'leather', 'suede', 'tweed']],
+ ['Abstract', ['abstract', 'organic', 'watercolor', 'marble', 'painterly', 'brushstroke', 'ombre', 'tie dye', 'splatter']],
+ ['Traditional', ['traditional', 'classic', 'heritage', 'colonial', 'victorian']],
+ ['Modern', ['modern', 'contemporary', 'minimal', 'mid-century', 'midcentury', 'scandinavian']],
+];
+// First matching style bucket from the row's ai_styles + ai_patterns words.
+function styleBucket(aiStyles, aiPatterns) {
+ const words = [];
+ for (const arr of [aiStyles, aiPatterns]) if (Array.isArray(arr)) for (const w of arr) words.push(String(w || '').toLowerCase());
+ const blob = ' ' + words.join(' ') + ' ';
+ for (const [bucket, kws] of STYLE_KEYWORDS) for (const kw of kws) if (blob.includes(kw)) return bucket;
+ return null;
+}
+
+// Material buckets from material / product_type strings.
+const MATERIAL_BUCKETS = ['Non-woven', 'Paper', 'Vinyl', 'Grasscloth', 'Fabric/Textile', 'Cork', 'Mylar/Metallic', 'Leather/Suede', 'Mural', 'Other'];
+const MATERIAL_KEYWORDS = [
+ ['Non-woven', ['non-woven', 'nonwoven', 'non woven']],
+ ['Grasscloth', ['grasscloth', 'grass cloth', 'sisal', 'jute', 'hemp', 'raffia', 'arrowroot', 'seagrass']],
+ ['Vinyl', ['vinyl', 'pvc', 'type ii', 'type 2', 'commercial']],
+ ['Cork', ['cork']],
+ ['Mylar/Metallic', ['mylar', 'metallic', 'foil', 'mica']],
+ ['Leather/Suede', ['leather', 'suede', 'ultrasuede', 'hide']],
+ ['Mural', ['mural', 'panel', 'panoramic']],
+ ['Fabric/Textile', ['fabric', 'textile', 'linen', 'silk', 'cotton', 'wool', 'velvet', 'woven', 'weave']],
+ ['Paper', ['paper', 'wallpaper', 'wallcovering', 'pulp']],
+];
+function materialBucket(material, ptype) {
+ const blob = ' ' + (String(material || '') + ' ' + String(ptype || '')).toLowerCase() + ' ';
+ if (blob.trim() === '') return null;
+ for (const [bucket, kws] of MATERIAL_KEYWORDS) for (const kw of kws) if (blob.includes(kw)) return bucket;
+ return null;
+}
+
+function buildRows() {
+ const rows = [];
+ let i = 0;
+ for (const r of readJsonl(SRC)) {
+ let images = [];
+ if (Array.isArray(r.all_images)) images = r.all_images;
+ else if (typeof r.all_images === 'string' && r.all_images.trim()) { try { images = JSON.parse(r.all_images); } catch { images = []; } }
+ if (!images.length && r.image_url) images = [r.image_url];
+ const ptype = (r.product_type || '').replace(/^./, (c) => c.toUpperCase()); // normalize casing
+ const parseArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' && v.trim() ? (() => { try { return JSON.parse(v); } catch { return []; } })() : []);
+ const aiColors = parseArr(r.ai_colors), aiStyles = parseArr(r.ai_styles), aiPatterns = parseArr(r.ai_patterns);
+ const enriched = (aiColors.length > 0 || aiStyles.length > 0); // ai_colors OR ai_styles non-empty
+ rows.push({
+ idx: i++,
+ sku: r.dw_sku || r.mfr_sku || String(i),
+ mfr_sku: r.mfr_sku || null,
+ pattern_name: r.pattern_name || r.mfr_sku || '(untitled)',
+ color_name: r.color_name || null,
+ collection: r.collection || null,
+ product_type: ptype || null,
+ material: r.material || null,
+ width: r.width || null,
+ repeat_v: r.repeat_v || null,
+ repeat_h: r.repeat_h || null,
+ color_primary: r.color_primary || null,
+ color_hex: r.color_hex || null,
+ ai_colors: aiColors,
+ ai_styles: aiStyles,
+ ai_patterns: aiPatterns,
+ repeat_classification: r.repeat_classification || null,
+ finish: r.finish || null,
+ application: r.application || null,
+ coverage: r.coverage || null,
+ fire_rating: r.fire_rating || null,
+ line: r.line || r.material || null,
+ image: r.image_url || images[0] || null,
+ images,
+ image_count: images.length,
+ // ENHANCED buckets (server-side)
+ family: colorFamily(r.color_hex, aiColors),
+ style: styleBucket(aiStyles, aiPatterns),
+ material_bucket: materialBucket(r.material, ptype),
+ enriched,
+ _lum: lum(r.color_hex),
+ _hue: hue(r.color_hex),
+ });
+ }
+ return rows;
+}
+
+let ROWS = buildRows();
+fs.watchFile(SRC, { interval: 5000 }, () => { ROWS = buildRows(); });
+
+const cmpStr = (a, b) => String(a == null ? '' : a).localeCompare(String(b == null ? '' : b), undefined, { sensitivity: 'base' });
+const nullsLast = (v) => (v == null ? Infinity : v);
+
+const sorters = {
+ newest: null, // natural staged order
+ pattern: (a, b) => cmpStr(a.pattern_name, b.pattern_name),
+ color_name: (a, b) => cmpStr(a.color_name, b.color_name),
+ collection: (a, b) => cmpStr(a.collection, b.collection) || cmpStr(a.pattern_name, b.pattern_name),
+ line: (a, b) => cmpStr(a.line, b.line) || cmpStr(a.pattern_name, b.pattern_name),
+ product_type: (a, b) => cmpStr(a.product_type, b.product_type) || cmpStr(a.pattern_name, b.pattern_name),
+ material: (a, b) => cmpStr(a.material, b.material) || cmpStr(a.pattern_name, b.pattern_name),
+ // light -> dark: brightest first; missing-hex always sort last
+ light: (a, b) => { const la = a._lum == null ? -1 : a._lum, lb = b._lum == null ? -1 : b._lum; return lb - la; },
+ // dark -> light: darkest first; missing-hex always sort last
+ dark: (a, b) => { const la = a._lum == null ? 999 : a._lum, lb = b._lum == null ? 999 : b._lum; return la - lb; },
+ hue: (a, b) => {
+ // grayscale (-1) and null sort to the very end
+ const ha = a._hue == null || a._hue < 0 ? 9999 : a._hue;
+ const hb = b._hue == null || b._hue < 0 ? 9999 : b._hue;
+ return ha - hb;
+ },
+ sku: (a, b) => cmpStr(a.mfr_sku || a.sku, b.mfr_sku || b.sku),
+ // group by canonical color family (display order), nulls last, then by hue within
+ family: (a, b) => {
+ const ia = a.family == null ? 999 : COLOR_FAMILIES.indexOf(a.family);
+ const ib = b.family == null ? 999 : COLOR_FAMILIES.indexOf(b.family);
+ if (ia !== ib) return ia - ib;
+ const ha = a._hue == null || a._hue < 0 ? 9999 : a._hue, hb = b._hue == null || b._hue < 0 ? 9999 : b._hue;
+ return ha - hb;
+ },
+ // group by canonical style (display order), nulls last, then pattern name
+ style: (a, b) => {
+ const ia = a.style == null ? 999 : STYLE_BUCKETS.indexOf(a.style);
+ const ib = b.style == null ? 999 : STYLE_BUCKETS.indexOf(b.style);
+ return ia - ib || cmpStr(a.pattern_name, b.pattern_name);
+ },
+};
+
+function facets() {
+ // ENHANCED facets: color-family / style / material (bucketed) REPLACE raw color_primary
+ // as the color facet. Plus enriched, line, product_type, collection.
+ const fam = {}, sty = {}, mat = {};
+ const f = { line: {}, product_type: {}, collection: {} };
+ let enrichedN = 0, unenrichedN = 0;
+ for (const r of ROWS) {
+ const bump = (o, v) => { if (v == null || v === '') return; o[v] = (o[v] || 0) + 1; };
+ bump(fam, r.family);
+ bump(sty, r.style);
+ bump(mat, r.material_bucket);
+ bump(f.line, r.line);
+ bump(f.product_type, r.product_type);
+ bump(f.collection, r.collection);
+ if (r.enriched) enrichedN++; else unenrichedN++;
+ }
+ // canonical-ordered facets: every value listed even at count 0 (UI greys 0s)
+ const ordered = (canon, counts) => canon.map((value) => ({ value, count: counts[value] || 0 }));
+ // count-desc for free-form facets; collection alpha (searchable)
+ const byCount = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
+ const byAlpha = (counts) => Object.entries(counts).sort((a, b) => a[0].localeCompare(b[0])).map(([value, count]) => ({ value, count }));
+ return {
+ family: ordered(COLOR_FAMILIES, fam),
+ style: ordered(STYLE_BUCKETS, sty),
+ material_bucket: ordered(MATERIAL_BUCKETS, mat),
+ enriched: [
+ { value: 'enriched', count: enrichedN },
+ { value: 'unenriched', count: unenrichedN },
+ ],
+ line: byCount(f.line),
+ product_type: byCount(f.product_type),
+ collection: byAlpha(f.collection),
+ };
+}
+
+const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://localhost');
+
+ if (u.pathname === '/api/config') {
+ return json(res, 200, { title: TITLE, samples_only: SAMPLES_ONLY, total: ROWS.length,
+ shopify: {
+ domain: process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com',
+ storefrontToken: process.env.SHOPIFY_STOREFRONT_TOKEN || '',
+ sampleVariantId: process.env.SHOPIFY_SAMPLE_VARIANT_ID || ''
+ } });
+ }
+
+ if (u.pathname === '/api/facets') {
+ return json(res, 200, { total: ROWS.length, facets: facets() });
+ }
+
+ if (u.pathname === '/api/skus') {
+ const q = (u.searchParams.get('q') || '').trim().toLowerCase();
+ const sort = u.searchParams.get('sort') || 'newest';
+ const offset = parseInt(u.searchParams.get('offset') || '0', 10);
+ const limit = Math.min(parseInt(u.searchParams.get('limit') || '60', 10), 200);
+ // multi-select facets: pipe-separated ('|'). Buckets + free-form + enriched.
+ const sel = (k) => { const v = u.searchParams.get(k); return v ? v.split('|').filter(Boolean) : []; };
+ const fFam = sel('family'), fStyle = sel('style'), fMat = sel('material_bucket');
+ const fEnr = sel('enriched'); // 'enriched' | 'unenriched' (multi tolerated -> OR)
+ const fLine = sel('line'), fType = sel('product_type'), fColl = sel('collection');
+
+ let rows = ROWS;
+ const inAny = (arr, val) => arr.length === 0 || arr.includes(val == null ? '' : val);
+ const enrMatch = (r) => {
+ if (!fEnr.length) return true;
+ const wantE = fEnr.includes('enriched'), wantU = fEnr.includes('unenriched');
+ return (wantE && r.enriched) || (wantU && !r.enriched);
+ };
+ rows = rows.filter((r) =>
+ inAny(fFam, r.family) && inAny(fStyle, r.style) && inAny(fMat, r.material_bucket) &&
+ inAny(fLine, r.line) && inAny(fType, r.product_type) && inAny(fColl, r.collection) && enrMatch(r));
+ if (q) {
+ rows = rows.filter((r) => (
+ (r.pattern_name || '') + ' ' + (r.color_name || '') + ' ' + (r.mfr_sku || '') + ' ' +
+ (r.sku || '') + ' ' + (r.collection || '')).toLowerCase().includes(q));
+ }
+ if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
+ const page = rows.slice(offset, offset + limit);
+ return json(res, 200, { total: rows.length, all: ROWS.length, offset, limit, rows: page });
+ }
+
+ if (u.pathname === '/api/sample-request' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
+ req.on('end', () => {
+ let p; try { p = JSON.parse(body || '{}'); } catch { return json(res, 400, { ok: false, error: 'bad json' }); }
+ const name = (p.name || '').toString().trim();
+ const email = (p.email || '').toString().trim();
+ const items = Array.isArray(p.items) ? p.items.slice(0, 200) : [];
+ if (!name || !email || !/.+@.+\..+/.test(email) || !items.length) {
+ return json(res, 400, { ok: false, error: 'name, valid email, and at least one item are required' });
+ }
+ const rec = {
+ ts: new Date().toISOString(),
+ vendor: TITLE,
+ name, email,
+ company: (p.company || '').toString().trim() || null,
+ phone: (p.phone || '').toString().trim() || null,
+ address: (p.address || '').toString().trim() || null,
+ notes: (p.notes || '').toString().trim() || null,
+ items: items.map((it) => ({
+ sku: (it.sku || '').toString(),
+ mfr_sku: (it.mfr_sku || '').toString() || null,
+ pattern_name: (it.pattern_name || '').toString() || null,
+ color_name: (it.color_name || '').toString() || null,
+ line: (it.line || '').toString() || null,
+ })),
+ ua: (req.headers['user-agent'] || '').slice(0, 200),
+ };
+ try {
+ fs.mkdirSync(DATA, { recursive: true });
+ fs.appendFileSync(REQUESTS, JSON.stringify(rec) + '\n');
+ } catch (e) {
+ return json(res, 500, { ok: false, error: 'could not save request' });
+ }
+ return json(res, 200, { ok: true, count: rec.items.length });
+ });
+ return;
+ }
+
+ // static
+ let pth = u.pathname === '/' ? '/index.html' : u.pathname;
+ const file = path.join(PUBLIC, path.normalize(pth).replace(/^(\.\.[/\\])+/, ''));
+ if (!file.startsWith(PUBLIC) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end('not found'); }
+ const ext = path.extname(file);
+ const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon' }[ext] || 'text/plain';
+ res.writeHead(200, { 'Content-Type': mime });
+ res.end(fs.readFileSync(file));
+});
+
+server.listen(PORT, () => {
+ const addr = server.address();
+ console.log(`${TITLE} viewer -> http://localhost:${addr.port} (${ROWS.length} products${SAMPLES_ONLY ? ', samples-only' : ''})`);
+});
← ddedb2f build arte (Arte International) -> BUILT, port 10094, 1991 s
·
back to Dw Vendor Microsites
·
build fentucci_naturals (Fentucci Naturals) -> BUILT, port 1 9b40b8c →