[object Object]

← back to Corkwallcovering

auto-save: 2026-06-30T14:41:08 (2 files) — public/index.html server.js

3df64c02c4e9f7cdb865fe1b8b9cbc6bd513833f · 2026-06-30 14:41:08 -0700 · Steve Abrams

Files touched

Diff

commit 3df64c02c4e9f7cdb865fe1b8b9cbc6bd513833f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 30 14:41:08 2026 -0700

    auto-save: 2026-06-30T14:41:08 (2 files) — public/index.html server.js
---
 public/index.html | 161 +++++++++++++++++++++++++++++++++++++++++++-----------
 server.js         | 125 +++++++++++++++++++++++++++++++++++++-----
 2 files changed, 242 insertions(+), 44 deletions(-)

diff --git a/public/index.html b/public/index.html
index c5017e9..e35a766 100644
--- a/public/index.html
+++ b/public/index.html
@@ -411,6 +411,41 @@ textarea:focus-visible,
   
 </section>
 
+<style id="leftnav-styles">
+  .section-header{position:relative}
+  .filters-toggle{display:inline-flex;align-items:center;gap:8px;background:var(--card,#17171b);color:var(--ink,#f5f1e8);border:1px solid var(--line,rgba(245,241,232,.20));border-radius:24px;padding:9px 16px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;cursor:pointer;font-family:inherit}
+  .filters-toggle:hover{border-color:var(--accent,#c8a96a)}
+  .filters-toggle .ft-bars{display:inline-flex;flex-direction:column;gap:3px}
+  .filters-toggle .ft-bars span{display:block;width:15px;height:1.5px;background:currentColor}
+  .filters-toggle .ft-count{color:var(--accent,#c8a96a);font-weight:700}
+  .shop-body{display:flex;gap:26px;align-items:flex-start}
+  .shop-main{flex:1 1 auto;min-width:0}
+  .filter-rail{flex:0 0 244px;width:244px;display:none;border:1px solid var(--line,rgba(245,241,232,.14));border-radius:12px;padding:14px 16px;position:sticky;top:90px;max-height:calc(100vh - 110px);overflow:auto;background:rgba(127,127,127,.04)}
+  .shop-body.filters-open .filter-rail{display:block}
+  .rail-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mut,#9a9aa2)}
+  .rail-head button{background:none;border:0;color:var(--mut,#9a9aa2);font-size:16px;cursor:pointer;line-height:1}
+  .fgroup{border-top:1px solid var(--line,rgba(127,127,127,.18));padding:11px 0}
+  .fgroup:first-child{border-top:0}
+  .fgroup h4{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mut,#9a9aa2);margin:0 0 9px}
+  .fchips{display:flex;flex-wrap:wrap;gap:6px}
+  .fchip{display:inline-flex;align-items:center;gap:6px;background:var(--card,#17171b);border:1px solid var(--line,rgba(127,127,127,.28));border-radius:18px;padding:4px 10px;font-size:11.5px;color:var(--ink,#e8e8ea);cursor:pointer}
+  .fchip:hover{border-color:var(--accent,#c8a96a)}
+  .fchip.on{background:var(--accent,#c8a96a);color:#1a1407;border-color:var(--accent,#c8a96a);font-weight:600}
+  .fchip.zero{opacity:.34;pointer-events:none}
+  .fchip .dot{width:13px;height:13px;border-radius:50%;box-shadow:0 0 0 1px rgba(0,0,0,.28) inset}
+  .fchip .ct{opacity:.6;font-size:10px}
+  .active-row{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:12px 0 0}
+  .apill{display:inline-flex;align-items:center;gap:6px;background:rgba(200,169,106,.14);border:1px solid var(--accent,#c8a96a);color:var(--ink,#e8e8ea);border-radius:18px;padding:3px 10px;font-size:11px;cursor:pointer}
+  .apill b{color:var(--accent,#c8a96a);font-weight:600}
+  .clearall{background:none;border:1px solid var(--line,rgba(127,127,127,.3));color:var(--mut,#9a9aa2);border-radius:18px;padding:3px 11px;font-size:11px;cursor:pointer}
+  .card .cdots{display:flex;gap:3px;margin-top:5px}
+  .card .cdots i{width:11px;height:11px;border-radius:50%;display:inline-block;box-shadow:0 0 0 1px rgba(0,0,0,.3) inset}
+  @media(max-width:760px){
+    .filter-rail{position:fixed;top:0;left:0;bottom:0;z-index:300;width:min(330px,88vw);max-height:none;border-radius:0;display:block;transform:translateX(-100%);transition:transform .25s ease}
+    .shop-body.filters-open .filter-rail{transform:translateX(0);box-shadow:0 0 60px rgba(0,0,0,.6)}
+    .shop-body{display:block}
+  }
+</style>
 <section class="section" id="shop">
   <div class="section-header">
     <div>
@@ -418,11 +453,12 @@ textarea:focus-visible,
       <h2 class="section-title"><span class="accent" id="totalCount">—</span> Patterns.</h2>
     </div>
     <div class="section-meta">Cork · Bark · Sustainable</div>
+    <button type="button" id="filtersToggle" class="filters-toggle" aria-expanded="false" aria-controls="filterRail">
+      <span class="ft-bars"><span></span><span></span><span></span></span> Filters <span class="ft-count" id="filtersCount"></span>
+    </button>
   </div>
 
-  <div class="filters" id="facets">
-    <button class="chip active" data-facet="all">All</button>
-  </div>
+  <div id="activeRow" class="active-row"></div>
 
   <div class="density">
     <div class="viewtoggle" id="viewToggle">
@@ -451,6 +487,12 @@ textarea:focus-visible,
     </div>
   </div>
 
+  <div class="shop-body" id="shopBody">
+   <aside id="filterRail" class="filter-rail" aria-hidden="true">
+     <div class="rail-head"><span>Filter</span><button type="button" id="railClose" aria-label="Close filters">✕</button></div>
+     <div id="railGroups"></div>
+   </aside>
+   <div class="shop-main">
   <div class="stat-line" id="statLine">Loading…</div>
   <div class="list-head" id="listHead">
     <span></span>
@@ -462,6 +504,8 @@ textarea:focus-visible,
   <div class="grid" id="grid"></div>
   <div class="loading" id="loading">Loading more…</div>
   <div class="sentinel" id="sentinel"></div>
+   </div><!-- /shop-main -->
+  </div><!-- /shop-body -->
 </section>
 
 <footer>
@@ -573,6 +617,28 @@ const HOSTKEY = 'cork';
 })();
 const LABELS = {"all":"All","natural":"Natural Cork","metallic":"Metallic Cork"};
 
+// ── left-nav facets (Amazon/Kravet style, tag-driven) ──────────────────────────
+const loadSet = k => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
+state.colors = loadSet('cork_colors'); state.patterns = loadSet('cork_patterns'); state.styles = loadSet('cork_styles');
+state.materials = loadSet('cork_materials'); state.types = loadSet('cork_types'); state.prices = loadSet('cork_prices');
+// group key (state field) → facet field in /api/facets + display label + render flags
+const FACET_GROUPS = [
+  { key:'colors',    field:'color_family', label:'Color',    color:true },
+  { key:'patterns',  field:'pattern',      label:'Pattern' },
+  { key:'styles',    field:'style',        label:'Style' },
+  { key:'materials', field:'material',     label:'Material' },
+  { key:'types',     field:'type',         label:'Type' },
+  { key:'prices',    field:'price_band',   label:'Price',    order:'price_order' },
+];
+function facetParams(){
+  const p = new URLSearchParams();
+  if (state.q) p.set('q', state.q);
+  for (const g of FACET_GROUPS) if (state[g.key].size) p.set(g.key, [...state[g.key]].join(','));
+  return p;
+}
+function persistFacets(){ for (const g of FACET_GROUPS) localStorage.setItem('cork_'+g.key, JSON.stringify([...state[g.key]])); }
+function applyFilterChange(){ persistFacets(); loadFacets(); resetGrid(); }
+
 function escAttr(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }
 function cleanSku(s){
   var V=/(?:^|-)(versace|kravet|fentucci|sandberg|harlequin|blithfield|westport|thibaut|koroseal|schumacher|scalamandre|fromental|dedar|carnegie|marburg|fabricut|coordonne|nina-campbell|lee-jofa(?:-modern)?|clarke(?:-and)?-clarke|clarke|brunschwig(?:-and)?(?:-fils)?|designers-guild|graham-(?:and-)?brown|andrew-martin|candice-olson|ronald-redding|jeffrey-stevens|sister-parish|arte-international|wolf-gordon|phillip-jeffries|cole-(?:and-)?son|maya-romanoff|g-p-j-baker|les-ensembliers|breegan(?:-jane)?)(?:-\\d+)?(?=-|$)/gi;
@@ -591,6 +657,7 @@ function cardHTML(p) {
     + '<div class="overlay">'
     + '<div class="pat">' + escAttr(p.pattern_name || p.title) + '</div>'
     + '<div class="ven">Designer Wallcoverings' + (cleanSku(p.sku || p.handle_display || p.handle) ? ' · ' + escAttr(cleanSku(p.sku || p.handle_display || p.handle)) : '') + '</div>'
+    + ((p.color_dots && p.color_dots.length) ? '<div class="cdots">' + p.color_dots.map(h => '<i style="background:' + escAttr(h) + '"></i>').join('') + '</div>' : '')
     + '<div class="actions">'
     + '<button class="sample-btn" onclick="event.stopPropagation();dwmOpen(\'Sample\',' + JSON.stringify({sku:p.sku||p.handle_display||p.handle, title:p.title, image_url:safeImg(p.image_url)}).replace(/"/g,'&quot;') + ')">Sample</button>'
     + '</div></div>';
@@ -658,35 +725,55 @@ function openDetails(p) {
 async function loadFacets() {
   let f;
   try {
-    const fp = new URLSearchParams();
-    if (state.q) fp.set('q', state.q);
-    const r = await fetch('/api/facets' + (fp.toString() ? '?' + fp : ''));
+    const r = await fetch('/api/facets?' + facetParams());
     if (!r.ok) throw new Error('HTTP ' + r.status);
     f = await r.json();
   } catch (e) { console.error('loadFacets failed:', e); return; }
-  const el = document.getElementById('facets');
-  for (const [k, v] of Object.entries(f.aesthetics).sort((a,b) => b[1] - a[1])) {
-    const b = document.createElement('button');
-    b.className = 'chip'; b.dataset.facet = k;
-    b.innerHTML = (LABELS[k] || k) + ' <span style="opacity:.55;font-weight:500;margin-left:4px">' + v + '</span>';
-    el.appendChild(b);
+  const FAMILY_DOT = f.family_dot || {};
+  const groups = document.getElementById('railGroups');
+  groups.innerHTML = '';
+  for (const g of FACET_GROUPS) {
+    const counts = f[g.field] || {};
+    let entries = Object.entries(counts);
+    if (g.order && Array.isArray(f[g.order])) entries.sort((a,b) => f[g.order].indexOf(a[0]) - f[g.order].indexOf(b[0]));
+    else if (g.color && Array.isArray(f.family_order)) entries.sort((a,b) => f.family_order.indexOf(a[0]) - f.family_order.indexOf(b[0]));
+    else entries.sort((a,b) => b[1] - a[1]);
+    if (!entries.length) continue;
+    const set = state[g.key];
+    const wrap = document.createElement('div'); wrap.className = 'fgroup';
+    wrap.innerHTML = '<h4>' + g.label + '</h4>';
+    const chips = document.createElement('div'); chips.className = 'fchips';
+    for (const [val, ct] of entries) {
+      const on = set.has(val);
+      const el = document.createElement('span');
+      el.className = 'fchip' + (on ? ' on' : '') + (ct ? '' : ' zero');
+      el.innerHTML = (g.color ? '<span class="dot" style="background:' + (FAMILY_DOT[val] || '#888') + '"></span>' : '') + val + ' <span class="ct">' + ct + '</span>';
+      el.onclick = () => { on ? set.delete(val) : set.add(val); applyFilterChange(); };
+      chips.appendChild(el);
+    }
+    wrap.appendChild(chips); groups.appendChild(wrap);
   }
-  el.addEventListener('click', e => {
-    if (e.target.tagName !== 'BUTTON') return;
-    document.querySelectorAll('#facets button').forEach(b => b.classList.remove('active'));
-    e.target.classList.add('active');
-    state.facet = e.target.dataset.facet;
-    resetGrid();
-  });
+  renderActive();
   document.getElementById('totalCount').textContent = f.total;
-  document.getElementById('footerStat').textContent = f.total + ' patterns · live archive';
-  // Footer aesthetic links
-  const footerEl = document.getElementById('footerFacets');
-  for (const [k] of Object.entries(f.aesthetics)) {
-    const b = document.createElement('button');
-    b.textContent = LABELS[k] || k;
-    b.onclick = () => { state.facet = k; resetGrid(); document.querySelectorAll('#facets button').forEach(x => x.classList.toggle('active', x.dataset.facet === k)); document.getElementById('shop').scrollIntoView({behavior:'smooth'}); };
-    footerEl.appendChild(b);
+  const fs = document.getElementById('footerStat'); if (fs) fs.textContent = f.total + ' patterns · live archive';
+}
+function renderActive() {
+  const row = document.getElementById('activeRow'); if (!row) return;
+  row.innerHTML = ''; let n = 0;
+  for (const g of FACET_GROUPS) {
+    state[g.key].forEach(v => {
+      n++;
+      const el = document.createElement('span'); el.className = 'apill';
+      el.innerHTML = '<b>' + g.label + ':</b> ' + v + ' ✕';
+      el.onclick = () => { state[g.key].delete(v); applyFilterChange(); };
+      row.appendChild(el);
+    });
+  }
+  const fc = document.getElementById('filtersCount'); if (fc) fc.textContent = n ? '(' + n + ')' : '';
+  if (n) {
+    const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
+    c.onclick = () => { FACET_GROUPS.forEach(g => state[g.key].clear()); applyFilterChange(); };
+    row.appendChild(c);
   }
 }
 
@@ -700,9 +787,8 @@ function resetGrid() {
 async function loadGridPage() {
   if (state.loading || state.exhausted) return;
   state.loading = true;
-  const params = new URLSearchParams({ page:state.page, limit:24 });
-  if (state.q) params.set('q', state.q);
-  if (state.facet !== 'all') params.set('aesthetic', state.facet);
+  const params = facetParams();
+  params.set('page', state.page); params.set('limit', 24);
   if (state.sort && state.sort !== 'newest') params.set('sort', state.sort);
   let data;
   try {
@@ -718,7 +804,8 @@ async function loadGridPage() {
   state.total = data.total; state.pages = data.pages;
 
   if (state.page === 1) {
-    document.getElementById('statLine').textContent = data.total + ' patterns · ' + (state.facet === 'all' ? 'all aesthetics' : (LABELS[state.facet] || state.facet)) + (state.q ? ' · matching "' + state.q + '"' : '');
+    const nSel = FACET_GROUPS.reduce((a,g) => a + state[g.key].size, 0);
+    document.getElementById('statLine').textContent = data.total + ' patterns' + (nSel ? ' · ' + nSel + ' filter' + (nSel>1?'s':'') : '') + (state.q ? ' · matching "' + state.q + '"' : '');
   }
   const grid = document.getElementById('grid');
   for (const p of data.items) {
@@ -857,6 +944,18 @@ function setTheme(t){ document.documentElement.dataset.theme = t; try { localSto
 setTheme(document.documentElement.dataset.theme || 'light');
 if (tb) tb.addEventListener('click', () => setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'));
 
+// ── left filter rail: collapsed on load (Steve's rule), toggled via the upper-right hamburger ──
+const shopBody = document.getElementById('shopBody');
+const filtersToggle = document.getElementById('filtersToggle');
+function setFilters(open) {
+  shopBody.classList.toggle('filters-open', open);
+  filtersToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
+  const rail = document.getElementById('filterRail'); if (rail) rail.setAttribute('aria-hidden', open ? 'false' : 'true');
+}
+if (filtersToggle) filtersToggle.addEventListener('click', () => setFilters(!shopBody.classList.contains('filters-open')));
+const railClose = document.getElementById('railClose'); if (railClose) railClose.onclick = () => setFilters(false);
+setFilters(false); // always collapsed on load
+
 loadFacets();
 loadGridPage();
 </script>
diff --git a/server.js b/server.js
index eafba8a..bdfd15d 100644
--- a/server.js
+++ b/server.js
@@ -185,6 +185,83 @@ function colorDots(p){
 function luminance(p){const rgb=hexToRgb(dominantHex(p));return rgb?(0.2126*rgb[0]+0.7152*rgb[1]+0.0722*rgb[2]):128;}
 function hue(p){const rgb=hexToRgb(dominantHex(p));if(!rgb)return 999;const [h,s]=rgbToHsl(rgb[0],rgb[1],rgb[2]);return s<0.08?999:h;}
 
+// ── LEFT-NAV FACETS (Amazon/Kravet style, tag-driven) ─────────────────────────
+// Canonical 12 DW color families + representative dot hex (shared with the
+// japan-enrich viewer). One family per SKU for the Color facet + swatch dots.
+const FAMILY_ORDER = ['Red','Orange/Brown','Yellow','Green','Teal','Blue','Purple','Pink','Neutral/Beige','Black','Grey','White'];
+const FAMILY_DOT = {
+  'Red':'#c0392b','Orange/Brown':'#a8642a','Yellow':'#d4ac0d','Green':'#4e8b3b',
+  'Teal':'#1aa5a5','Blue':'#2e6fb0','Purple':'#7d5ba6','Pink':'#d87aa5',
+  'Neutral/Beige':'#cdbd96','Black':'#1a1a1a','Grey':'#9a9aa2','White':'#f2f2f2',
+};
+// color tag/word -> family (most reliable when an explicit color tag exists)
+const TAG_FAMILY = {
+  black:'Black',charcoal:'Black',ebony:'Black',noir:'Black',onyx:'Black',
+  gray:'Grey',grey:'Grey',silver:'Grey',pewter:'Grey',slate:'Grey',ash:'Grey',smoke:'Grey',graphite:'Grey',
+  white:'White',ivory:'White',cream:'White',chalk:'White',alabaster:'White',porcelain:'White',
+  beige:'Neutral/Beige',sand:'Neutral/Beige',taupe:'Neutral/Beige',greige:'Neutral/Beige',oatmeal:'Neutral/Beige',linen:'Neutral/Beige',flax:'Neutral/Beige',stone:'Neutral/Beige',mushroom:'Neutral/Beige',putty:'Neutral/Beige',wheat:'Neutral/Beige',champagne:'Neutral/Beige',blonde:'Neutral/Beige',natural:'Neutral/Beige',
+  brown:'Orange/Brown',chestnut:'Orange/Brown',umber:'Orange/Brown',tan:'Orange/Brown',khaki:'Orange/Brown',honey:'Orange/Brown',copper:'Orange/Brown',bronze:'Orange/Brown',camel:'Orange/Brown',caramel:'Orange/Brown',cognac:'Orange/Brown',walnut:'Orange/Brown',espresso:'Orange/Brown',mocha:'Orange/Brown',terracotta:'Orange/Brown',rust:'Orange/Brown',sienna:'Orange/Brown',orange:'Orange/Brown',peach:'Orange/Brown',apricot:'Orange/Brown',clay:'Orange/Brown',
+  gold:'Yellow',brass:'Yellow',mustard:'Yellow',ochre:'Yellow',saffron:'Yellow',amber:'Yellow',yellow:'Yellow',butter:'Yellow',citrine:'Yellow',maize:'Yellow',
+  green:'Green',olive:'Green',lime:'Green',mint:'Green',sage:'Green',moss:'Green',fern:'Green',forest:'Green',emerald:'Green',celadon:'Green',hunter:'Green',
+  teal:'Teal',aqua:'Teal',turquoise:'Teal',cyan:'Teal',aquamarine:'Teal',peacock:'Teal',
+  blue:'Blue',navy:'Blue',indigo:'Blue',periwinkle:'Blue',cerulean:'Blue',denim:'Blue',cornflower:'Blue',sky:'Blue',
+  purple:'Purple',violet:'Purple',lavender:'Purple',plum:'Purple',aubergine:'Purple',orchid:'Purple',mauve:'Purple',amethyst:'Purple',
+  red:'Red',crimson:'Red',brick:'Red',burgundy:'Red',oxblood:'Red',garnet:'Red',claret:'Red',cinnabar:'Red',
+  pink:'Pink',rose:'Pink',blush:'Pink',coral:'Pink',salmon:'Pink',magenta:'Pink',fuchsia:'Pink',
+};
+function familyFromTags(tags){
+  const ts=(tags||[]).map(t=>String(t).toLowerCase());
+  for (const t of ts) for (const k of Object.keys(TAG_FAMILY)) if (t.includes(k)) return TAG_FAMILY[k];
+  return null;
+}
+function familyFromHex(hex){
+  const rgb=hexToRgb(hex); if(!rgb) return null;
+  const [h,s,l]=rgbToHsl(rgb[0],rgb[1],rgb[2]);
+  if (s<0.12){ if(l>0.82) return 'White'; if(l<0.20) return 'Black';
+    return (h>=20&&h<=60&&l>0.55)?'Neutral/Beige':'Grey'; }
+  if (s<0.25 && h>=20 && h<=55 && l>0.5) return 'Neutral/Beige';
+  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<200) return 'Teal';
+  if (h<255) return 'Blue';
+  if (h<290) return 'Purple';
+  return 'Pink';
+}
+function colorFamily(p){ return familyFromTags(p.tags) || familyFromHex(dominantHex(p)); }
+
+// Controlled vocab (Kravet/Amazon facet wording, from dw-seo-optimizer). Multi-value.
+const PATTERN_VOCAB  = ['Botanical','Floral','Geometric','Damask','Palm','Trellis','Stripe','Abstract','Toile','Scenic','Paisley','Plaid','Animal Print','Medallion','Chevron','Herringbone','Solid','Textured','Faux','Mural'];
+const STYLE_VOCAB    = ['Traditional','Contemporary','Modern','Coastal','Tropical','Art Deco','Mid-Century Modern','Bohemian','Transitional','Victorian','Chinoiserie','Minimalist','Maximalist','Rustic','Industrial','Scandinavian','Hollywood Regency','Japandi','Farmhouse'];
+const MATERIAL_VOCAB = ['Grasscloth','Cork','Silk','Vinyl','Non-Woven','Paper','Suede','Metallic','Glass Bead','Mica','Linen','Jute','Raffia','Mylar','String','Bamboo'];
+function matchVocab(p, vocab){
+  const blob=(p.tags||[]).map(t=>String(t).toLowerCase()).join(' | ');
+  const out=[]; for (const term of vocab) if (blob.includes(term.toLowerCase())) out.push(term);
+  return out;
+}
+// USD price bands (comma-free labels — multi-select is CSV-encoded in the query).
+const PRICE_BANDS = [
+  {key:'Under $50', t:v=>v<50},{key:'$50–100', t:v=>v>=50&&v<100},
+  {key:'$100–200', t:v=>v>=100&&v<200},{key:'$200–400', t:v=>v>=200&&v<400},{key:'$400+', t:v=>v>=400},
+];
+const PRICE_ORDER = PRICE_BANDS.map(b=>b.key);
+function priceBand(v){ v=Number(v); if(!v||isNaN(v)||v<=0) return null; const b=PRICE_BANDS.find(x=>x.t(v)); return b?b.key:null; }
+
+// Attach all facet fields once (precompute at load → filter/facets/cards share).
+function decorateFacets(p){
+  return Object.assign({}, p, {
+    color_family: colorFamily(p),
+    color_dots:   colorDots(p),
+    patterns:     matchVocab(p, PATTERN_VOCAB),
+    styles:       matchVocab(p, STYLE_VOCAB),
+    materials:    matchVocab(p, MATERIAL_VOCAB),
+    type:         p.product_type || null,
+    price_band:   priceBand(p.max_price),
+  });
+}
+const csvSet = v => new Set(String(v||'').split(',').map(s=>s.trim()).filter(Boolean));
+
 function sortProducts(list, mode) {
   if (mode === 'sku') return [...list].sort((a,b) => String(a.sku || a.handle || '').localeCompare(String(b.sku || b.handle || '')));
   if (mode === 'title') return [...list].sort((a,b) => String(a.title || '').localeCompare(String(b.title || '')));
@@ -210,7 +287,7 @@ function sortProducts(list, mode) {
 // catalog. Used by both /api/products (the grid) and /api/facets (the chip
 // counts) so the counts always reflect the FILTERED result set, not the raw
 // catalog. `skip` lets each facet exclude its own dimension for drill-down.
-function filterProducts({ q, aesthetic, vendor } = {}, skip = null) {
+function filterProducts({ q, aesthetic, vendor, colors, patterns, styles, materials, types, prices } = {}, skip = null) {
   let list = PRODUCTS_NICHE;
   if (q) {
     const needle = String(q).toLowerCase();
@@ -218,12 +295,25 @@ function filterProducts({ q, aesthetic, vendor } = {}, skip = null) {
   }
   if (skip !== 'aesthetic' && aesthetic && aesthetic !== 'all') list = list.filter(p => productInRail(p, aesthetic, RAIL_SYN));
   if (skip !== 'vendor' && vendor && vendor !== 'all') list = list.filter(p => p.vendor === vendor);
+  // tag-derived left-nav facets (multi-select Sets; array dims match if ANY selected value is present)
+  if (skip !== 'color'    && colors    && colors.size)    list = list.filter(p => p.color_family && colors.has(p.color_family));
+  if (skip !== 'pattern'  && patterns  && patterns.size)  list = list.filter(p => p.patterns && p.patterns.some(x => patterns.has(x)));
+  if (skip !== 'style'    && styles    && styles.size)    list = list.filter(p => p.styles && p.styles.some(x => styles.has(x)));
+  if (skip !== 'material' && materials && materials.size) list = list.filter(p => p.materials && p.materials.some(x => materials.has(x)));
+  if (skip !== 'type'     && types     && types.size)     list = list.filter(p => p.type && types.has(p.type));
+  if (skip !== 'price'    && prices    && prices.size)    list = list.filter(p => p.price_band && prices.has(p.price_band));
   return list;
 }
+// Build the full filter spec from a request's query string (shared by products + facets).
+function facetSpec(q){
+  return { q:q.q, aesthetic:q.aesthetic, vendor:q.vendor,
+    colors:csvSet(q.colors), patterns:csvSet(q.patterns), styles:csvSet(q.styles),
+    materials:csvSet(q.materials), types:csvSet(q.types), prices:csvSet(q.prices) };
+}
 
 app.get('/api/products', (req, res) => {
-  const { q, aesthetic, vendor, sort = 'newest', page = 1, limit = 24 } = req.query;
-  let list = filterProducts({ q, aesthetic, vendor });
+  const { sort = 'newest', page = 1, limit = 24 } = req.query;
+  let list = filterProducts(facetSpec(req.query));
   list = sortProducts(list, sort);
   const total = list.length;
   const pageNum = Math.max(1, parseInt(page) || 1);
@@ -238,15 +328,24 @@ app.get('/api/sliders', (req, res) => {
 });
 
 app.get('/api/facets', (req, res) => {
-  const { q, aesthetic, vendor } = req.query;
+  const f = facetSpec(req.query);
   // Each facet dimension is counted over the set filtered by the OTHER active
-  // selections (drill-down), so counts reflect what selecting that value would
-  // yield rather than the unfiltered catalog. `total` honors ALL active filters.
-  const aesthetics = railFacets(filterProducts({ q, aesthetic, vendor }, 'aesthetic'), SITE_RAILS, RAIL_SYN); const vendors = {};
-  for (const p of filterProducts({ q, aesthetic, vendor }, 'vendor')) {
-    vendors[p.vendor] = (vendors[p.vendor] || 0) + 1;
-  }
-  res.json({ aesthetics, vendors, total: filterProducts({ q, aesthetic, vendor }).length });
+  // selections (drill-down via `skip`), so a count reflects what selecting that
+  // value would yield. `total` honors ALL active filters.
+  const tally    = (rows, key) => { const m = {}; for (const p of rows) { const v = p[key]; if (v) m[v] = (m[v]||0)+1; } return m; };
+  const tallyArr = (rows, key) => { const m = {}; for (const p of rows) for (const v of (p[key]||[])) m[v] = (m[v]||0)+1; return m; };
+  res.json({
+    color_family: tally(filterProducts(f, 'color'),    'color_family'),
+    pattern:      tallyArr(filterProducts(f, 'pattern'),  'patterns'),
+    style:        tallyArr(filterProducts(f, 'style'),    'styles'),
+    material:     tallyArr(filterProducts(f, 'material'), 'materials'),
+    type:         tally(filterProducts(f, 'type'),     'type'),
+    price_band:   tally(filterProducts(f, 'price'),    'price_band'),
+    // keep the legacy aesthetic facet for the footer links (degrades to {} on most sites)
+    aesthetics:   railFacets(filterProducts(f, 'aesthetic'), SITE_RAILS, RAIL_SYN),
+    family_order: FAMILY_ORDER, family_dot: FAMILY_DOT, price_order: PRICE_ORDER,
+    total: filterProducts(f).length,
+  });
 });
 
 app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS_NICHE.length, dropped: DROPPED }));
@@ -336,8 +435,8 @@ app.use((req, res) => {
       console.log(`[${__SITE}] loaded ${PRODUCTS.length} from data/products.json (static mode)`);
     } catch (_) { PRODUCTS = []; }
   }
-  PRODUCTS_NICHE = PRODUCTS.filter(nicheFit);
-  console.log(`[${__SITE}] niche filter: kept ${PRODUCTS_NICHE.length} of ${PRODUCTS.length}`);
+  PRODUCTS_NICHE = PRODUCTS.filter(nicheFit).map(decorateFacets);
+  console.log(`[${__SITE}] niche filter: kept ${PRODUCTS_NICHE.length} of ${PRODUCTS.length} (facet-decorated)`);
   buildSampleDisplayMap(); // Step 2: redacted->raw /sample resolver keys
   app.listen(PORT, '127.0.0.1', () => {
     console.log(`corkwallcovering listening on http://127.0.0.1:${PORT}`);

← 2677980 Add promo-banner LIVE verification screenshot from prior run  ·  back to Corkwallcovering  ·  cork: Amazon/Kravet left-nav filter — tag-derived facets (Co 8a68457 →