← 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);