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