← back to Dw Discovery
dw-discovery.js
328 lines
/* dw-discovery.js — DW-owned faceted search & merchandising layer.
* Replicates Boost AI Search & Discovery as features we control. Zero dependencies.
* Drop into any DW grid: new DWDiscovery({ mount, products, config }).
*
* Feature map (Boost SD -> here):
* Filter trees ........ nested, collapsible, multi-select facets w/ live counts
* Merchandising ....... pin / boost / bury rules (featured-first, in-stock-first)
* Filter layout ....... sidebar <-> top-bar, persisted
* Display settings .... density slider + card-field toggles
* Analytics ........... facet-usage counts + zero-result log (Insights panel)
* Merge values ........ canonicalize raw facet values via a rules map
* Range sliders ....... dual-handle Price / Width / Repeat
* Swatches ............ color facet as dominant-hex chips
*/
(function (global) {
'use strict';
const LS = (k, d) => { try { return JSON.parse(localStorage.getItem('dwd:' + k)) ?? d; } catch { return d; } };
const SS = (k, v) => { try { localStorage.setItem('dwd:' + k, JSON.stringify(v)); } catch {} };
const el = (t, c, h) => { const e = document.createElement(t); if (c) e.className = c; if (h != null) e.innerHTML = h; return e; };
// ---- TOOLS · Merge values: canonicalize raw facet values into one bucket ----
const DEFAULT_MERGE = {
colorBucket: { 'Indigo': 'Blue', 'Sky': 'Blue', 'Navy': 'Blue' }, // alias examples
vendor: { 'Cole and Son': 'Cole & Son', 'Osborne and Little': 'Osborne & Little' }
};
function DWDiscovery(opts) {
const mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
const products = opts.products.slice();
const cfg = Object.assign({
merge: DEFAULT_MERGE,
// Filter trees: parent facet -> { field, children:[{field}] }
tree: [
{ label: 'Catalog', field: 'category', children: [{ label: 'Type', field: 'subtype' }] },
{ label: 'Style', field: 'style' },
{ label: 'Vendor', field: 'vendor' }
],
ranges: [
{ label: 'Price', field: 'price', prefix: '$', step: 1 },
{ label: 'Width', field: 'width_in', suffix: '"', step: 1 },
{ label: 'Repeat', field: 'repeat_in', suffix: '"', step: 0.5 }
],
swatchField: 'colorBucket'
}, opts.config || {});
const merge = (field, val) => (cfg.merge[field] && cfg.merge[field][val]) || val;
// persisted UI state
const state = {
facets: LS('facets', {}), // {field: [values]}
ranges: LS('ranges', {}), // {field: [min,max]}
swatches: LS('swatches', []),
sort: LS('sort', 'featured'),
cols: LS('cols', 4),
layout: LS('layout', 'sidebar'), // Filter layout
// merge stored over defaults so newly-added toggles (name/image) appear for returning visitors
cardFields: Object.assign(
{ name: false, image: false, price: true, vendor: true, swatch: true, contents: true, pattern: false, color: true, sku: true, created: true, launch: true },
LS('cardFields', {})
),
merchandising: LS('merch', { featuredFirst: true, inStockFirst: true }),
query: ''
};
// INSIGHTS · Analytics
const analytics = LS('analytics', { facetClicks: {}, zeroResults: [], applies: 0 });
const track = (field, value) => {
const k = field + ':' + value;
analytics.facetClicks[k] = (analytics.facetClicks[k] || 0) + 1;
analytics.applies++; SS('analytics', analytics);
};
// ---------- core filtering ----------
function bounds(field) {
let lo = Infinity, hi = -Infinity;
products.forEach(p => { const v = +p[field]; if (v < lo) lo = v; if (v > hi) hi = v; });
return [Math.floor(lo), Math.ceil(hi)];
}
function passes(p) {
if (state.query) {
const q = state.query.toLowerCase();
const pal = Array.isArray(p.palette) ? p.palette.map(c => c.name).join(' ') : '';
const hay = [p.title, p.vendor, p.colorName, p.sku, p.subtype, p.category, p.style, pal]
.filter(Boolean).join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
for (const f in state.facets) {
const sel = state.facets[f]; if (!sel.length) continue;
if (!sel.includes(merge(f, p[f]))) return false;
}
for (const f in state.ranges) {
const [mn, mx] = state.ranges[f]; const v = +p[f];
if (v < mn || v > mx) return false;
}
if (state.swatches.length && !state.swatches.includes(merge(cfg.swatchField, p[cfg.swatchField]))) return false;
return true;
}
function result() {
let r = products.filter(passes);
// MERCHANDISING + sort
const s = state.sort;
r.sort((a, b) => {
if (state.merchandising.featuredFirst && a.featured !== b.featured) return a.featured ? -1 : 1;
if (state.merchandising.inStockFirst && a.inStock !== b.inStock) return a.inStock ? -1 : 1;
if (s === 'priceUp') return a.price - b.price;
if (s === 'priceDown') return b.price - a.price;
if (s === 'title') return a.title.localeCompare(b.title);
if (s === 'newest') return a.createdDaysAgo - b.createdDaysAgo;
return 0;
});
return r;
}
// facet counts honor all OTHER active filters (true faceted behavior)
function countsFor(field) {
const tmp = JSON.parse(JSON.stringify(state.facets)); delete tmp[field];
const sub = products.filter(p => {
for (const f in tmp) { if (tmp[f].length && !tmp[f].includes(merge(f, p[f]))) return false; }
return true;
});
const m = {};
sub.forEach(p => { const v = merge(field, p[field]); m[v] = (m[v] || 0) + 1; });
return m;
}
// ---------- rendering ----------
mount.classList.add('dwd');
mount.dataset.layout = state.layout;
const aside = el('aside', 'dwd-panel');
const main = el('section', 'dwd-main');
mount.append(aside, main);
function persist() {
SS('facets', state.facets); SS('ranges', state.ranges); SS('swatches', state.swatches);
SS('sort', state.sort); SS('cols', state.cols); SS('layout', state.layout);
SS('cardFields', state.cardFields); SS('merch', state.merchandising);
}
function renderTree() {
const wrap = el('div', 'dwd-sec');
wrap.append(el('h4', null, 'Filters'));
const walk = (node, depth) => {
const counts = countsFor(node.field);
const vals = Object.keys(counts).sort();
const grp = el('div', 'dwd-grp');
const head = el('button', 'dwd-grp-h', `<span>${node.label}</span><span class="dwd-caret">▾</span>`);
const body = el('div', 'dwd-grp-b');
const open = LS('open:' + node.field, depth === 0); body.style.display = open ? '' : 'none';
head.onclick = () => { const v = body.style.display === 'none'; body.style.display = v ? '' : 'none'; SS('open:' + node.field, v); };
grp.append(head, body);
vals.forEach(v => {
const sel = (state.facets[node.field] || []).includes(v);
const row = el('label', 'dwd-opt' + (sel ? ' on' : ''),
`<input type="checkbox" ${sel ? 'checked' : ''}><span>${v}</span><b>${counts[v]}</b>`);
row.querySelector('input').onchange = e => {
const arr = state.facets[node.field] || (state.facets[node.field] = []);
if (e.target.checked) { arr.push(v); track(node.field, v); } else arr.splice(arr.indexOf(v), 1);
persist(); render();
};
body.append(row);
});
if (node.children) node.children.forEach(c => body.append(walk(c, depth + 1)));
return grp;
};
cfg.tree.forEach(n => wrap.append(walk(n, 0)));
return wrap;
}
// TOOLS · Swatches
function renderSwatches() {
const wrap = el('div', 'dwd-sec'); wrap.append(el('h4', null, 'Color'));
const counts = countsFor(cfg.swatchField);
const hexByBucket = {};
products.forEach(p => { hexByBucket[merge(cfg.swatchField, p[cfg.swatchField])] = p.hex; });
const row = el('div', 'dwd-swatches');
Object.keys(counts).sort().forEach(b => {
const on = state.swatches.includes(b);
const c = el('button', 'dwd-sw' + (on ? ' on' : ''),
`<i style="background:${hexByBucket[b]}"></i><span>${b}</span><b>${counts[b]}</b>`);
c.onclick = () => {
const i = state.swatches.indexOf(b);
if (i < 0) { state.swatches.push(b); track(cfg.swatchField, b); } else state.swatches.splice(i, 1);
persist(); render();
};
row.append(c);
});
wrap.append(row); return wrap;
}
// TOOLS · Custom range sliders (dual handle, zero-dep)
function renderRanges() {
const wrap = el('div', 'dwd-sec'); wrap.append(el('h4', null, 'Ranges'));
cfg.ranges.forEach(r => {
const [lo, hi] = bounds(r.field);
const cur = state.ranges[r.field] || [lo, hi];
const box = el('div', 'dwd-range');
const fmt = v => (r.prefix || '') + v + (r.suffix || '');
box.innerHTML = `<div class="dwd-range-h"><span>${r.label}</span><em>${fmt(cur[0])} – ${fmt(cur[1])}</em></div>
<div class="dwd-dual"><input type="range" min="${lo}" max="${hi}" step="${r.step}" value="${cur[0]}">
<input type="range" min="${lo}" max="${hi}" step="${r.step}" value="${cur[1]}"></div>`;
const [a, b] = box.querySelectorAll('input');
const lab = box.querySelector('em');
const sync = () => {
let mn = +a.value, mx = +b.value; if (mn > mx) [mn, mx] = [mx, mn];
state.ranges[r.field] = [mn, mx]; lab.textContent = `${fmt(mn)} – ${fmt(mx)}`;
};
const commit = () => { sync(); track('range', r.field); persist(); render(); };
a.oninput = b.oninput = sync; a.onchange = b.onchange = commit;
wrap.append(box);
});
return wrap;
}
function renderControls() {
const bar = el('div', 'dwd-controls');
// search
const q = el('input', 'dwd-search'); q.placeholder = 'Search patterns…'; q.value = state.query;
q.oninput = () => { state.query = q.value; render(); };
// sort
const sort = el('select', 'dwd-sel');
[['featured', 'Featured'], ['newest', 'Newest'], ['priceUp', 'Price ↑'], ['priceDown', 'Price ↓'], ['title', 'Title A→Z']]
.forEach(([v, l]) => { const o = el('option', null, l); o.value = v; if (v === state.sort) o.selected = true; sort.append(o); });
sort.onchange = () => { state.sort = sort.value; persist(); render(); };
// density (Display settings)
const dens = el('input'); dens.type = 'range'; dens.min = 2; dens.max = 6; dens.value = state.cols; dens.className = 'dwd-dens';
dens.oninput = () => { state.cols = +dens.value; main.querySelector('.dwd-grid').style.setProperty('--cols', state.cols); persist(); };
// layout toggle (Filter layout)
const lay = el('button', 'dwd-btn', state.layout === 'sidebar' ? '▤ Sidebar' : '▥ Top bar');
lay.onclick = () => { state.layout = state.layout === 'sidebar' ? 'top' : 'sidebar'; mount.dataset.layout = state.layout; lay.textContent = state.layout === 'sidebar' ? '▤ Sidebar' : '▥ Top bar'; persist(); };
// card fields (Display settings)
const fldWrap = el('div', 'dwd-fields');
Object.keys(state.cardFields).forEach(k => {
const b = el('button', 'dwd-chip' + (state.cardFields[k] ? ' on' : ''), k);
b.onclick = () => { state.cardFields[k] = !state.cardFields[k]; b.classList.toggle('on'); persist(); render(); };
fldWrap.append(b);
});
bar.append(q, sort, el('span', 'dwd-lab', 'Density'), dens, lay, fldWrap);
return bar;
}
// INSIGHTS · Analytics panel
function renderInsights() {
const top = Object.entries(analytics.facetClicks).sort((a, b) => b[1] - a[1]).slice(0, 6);
const z = analytics.zeroResults.slice(-5).reverse();
const box = el('details', 'dwd-insights');
box.innerHTML = `<summary>Insights · ${analytics.applies} filter actions</summary>
<div class="dwd-ins-cols">
<div><h5>Top facets</h5>${top.length ? top.map(([k, n]) => `<div class="dwd-ins-row"><span>${k}</span><b>${n}</b></div>`).join('') : '<em>none yet</em>'}</div>
<div><h5>Zero-result searches</h5>${z.length ? z.map(s => `<div class="dwd-ins-row"><span>“${s}”</span></div>`).join('') : '<em>none yet</em>'}</div>
</div>`;
return box;
}
function renderMerch() {
const wrap = el('div', 'dwd-sec'); wrap.append(el('h4', null, 'Merchandising'));
[['featuredFirst', 'Featured first'], ['inStockFirst', 'In-stock first']].forEach(([k, l]) => {
const b = el('label', 'dwd-opt' + (state.merchandising[k] ? ' on' : ''),
`<input type="checkbox" ${state.merchandising[k] ? 'checked' : ''}><span>${l}</span>`);
b.querySelector('input').onchange = e => { state.merchandising[k] = e.target.checked; persist(); render(); };
wrap.append(b);
});
return wrap;
}
function card(p) {
const cf = state.cardFields;
const sw = cf.swatch ? `<i class="dwd-card-sw" style="background:${p.hex}" title="${p.colorName}"></i>` : '';
const pal = (Array.isArray(p.palette) && p.palette.length)
? `<div class="dwd-pal" title="${p.palette.map(c=>c.name+' '+c.pct+'%').join(' · ')}">`
+ p.palette.map(c=>`<span style="width:${c.pct}%;background:${c.hex}" title="${c.name} ${c.pct}%"></span>`).join('')
+ `</div><div class="dwd-pal-legend">${p.palette.slice(0,4).map(c=>`${c.name} ${c.pct}%`).join(' · ')}</div>`
: '';
const badges = (p.featured ? '<span class="dwd-badge feat">Featured</span>' : '') + (!p.inStock ? '<span class="dwd-badge oos">Made to order</span>' : '');
const thumb = (cf.image && p.image)
? `<div class="dwd-thumb"><img class="dwd-img" src="${p.image}" alt="${p.title}" loading="lazy">${sw}${badges}</div>`
: `<div class="dwd-thumb" style="--c:${p.hex}">${sw}${badges}</div>`;
return el('article', 'dwd-card', `
${thumb}
<div class="dwd-meta">
${p.status ? `<span class="dwd-status s-${String(p.status).toLowerCase().replace(/[^a-z]+/g,'-')}">${p.status}</span>` : ''}
${cf.launch ? (p.goLiveAt ? `<span class="dwd-golive" title="Launches ${p.goLiveAt}">🚀 Launches ${new Date(p.goLiveAt).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}</span>` : (/active|published/i.test(p.status||'') && p.created_at ? `<span class="dwd-golive launched" title="Launched ${p.created_at}">🚀 Launched ${new Date(p.created_at).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}</span>` : '')) : ''}
${cf.name ? `<div class="dwd-title">${p.title}</div>` : ''}
${cf.vendor ? `<div class="dwd-vendor">${p.vendor}</div>` : ''}
${cf.created && p.created_at ? `<div class="dwd-when" title="Created ${p.created_at}">🕓 Created ${new Date(p.created_at).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}</div>` : ''}
${cf.pattern && p.title ? `<div class="dwd-fld"><b>Pattern</b> ${p.title}</div>` : ''}
${cf.contents && p.subtype ? `<div class="dwd-fld"><b>Contents</b> ${p.subtype}</div>` : ''}
${cf.color && p.colorName ? `<div class="dwd-fld"><b>Color</b> ${p.colorName}</div>` : ''}
${cf.sku && p.sku ? `<div class="dwd-fld"><b>SKU</b> ${p.sku}</div>` : ''}
${pal}
<div class="dwd-row2">
${cf.price ? `<span class="dwd-price">$${p.price.toFixed(2)}</span>` : '<span></span>'}
<span class="dwd-spec">${[p.width_in!=null?p.width_in+'"':null, p.repeat_in!=null?'rpt '+p.repeat_in+'"':null].filter(Boolean).join(' · ')}</span>
</div>
</div>`);
}
function activeChips() {
const chips = el('div', 'dwd-active');
const add = (label, clear) => { const c = el('button', 'dwd-tag', `${label} ✕`); c.onclick = clear; chips.append(c); };
for (const f in state.facets) state.facets[f].slice().forEach(v => add(v, () => { state.facets[f].splice(state.facets[f].indexOf(v), 1); persist(); render(); }));
state.swatches.slice().forEach(v => add('● ' + v, () => { state.swatches.splice(state.swatches.indexOf(v), 1); persist(); render(); }));
for (const f in state.ranges) add(f, () => { delete state.ranges[f]; persist(); render(); });
if (chips.children.length) { const c = el('button', 'dwd-tag clear', 'Clear all'); c.onclick = () => { state.facets = {}; state.ranges = {}; state.swatches = []; persist(); render(); }; chips.append(c); }
return chips;
}
function render() {
const r = result();
if (!r.length && (state.query || Object.keys(state.facets).length)) {
if (state.query) { analytics.zeroResults.push(state.query); SS('analytics', analytics); }
}
aside.innerHTML = ''; main.innerHTML = '';
aside.append(renderTree(), renderSwatches(), renderRanges(), renderMerch());
const head = el('div', 'dwd-head');
head.append(renderControls());
const count = el('div', 'dwd-count', `<strong>${r.length}</strong> of ${products.length} products`);
main.append(head, count, activeChips(), renderInsights());
const grid = el('div', 'dwd-grid'); grid.style.setProperty('--cols', state.cols);
if (!r.length) grid.append(el('div', 'dwd-empty', 'No products match — try removing a filter.'));
r.forEach(p => grid.append(card(p)));
main.append(grid);
}
render();
return { state, render, analytics };
}
global.DWDiscovery = DWDiscovery;
})(window);