← back to Dw Theme Hamburger

assets/boost-sd-custom.js

365 lines

/*********************** Custom JS for Boost AI Search & Discovery  ************************/

(function() {
  'use strict';

  // ── Vendor Handle Map (vendor name → collection handle) ────────────────
  // Built from the brands page. Used to make vendor names clickable.
  var VENDOR_HANDLES = {
    '1838 Wallpapers': '1838-wallpapers',
    'Andrew Martin': 'andrew-martin-wallpaper-collection',
    'Anna French': 'anna-french',
    'Anthology': 'anthology-wallcoverings',
    'Armani/Casa': 'armani-casa-wallcoverings',
    'Arte': 'arte',
    'Borastapeter': 'borastapeter-scandinavian-wallpapers',
    'Brewster': 'brewster-wallcoverings',
    'Brunschwig & Fils': 'brunschwig-wallpapers',
    'Carnegie': 'carnegie-wallcoverings',
    'China Seas': 'china-seas-wallpaper',
    'Christian Lacroix': 'christian-lacroix-wallcoverings',
    'Clarke & Clarke': 'clarke-clarke',
    'Cole & Son': 'cole-and-son',
    'Colefax and Fowler': 'colefax-and-fowler',
    'Cowtan & Tout': 'cowtan-tout-wallpaper-collection',
    'de Gournay': 'de-gournay-wallcoverings',
    'Designers Guild': 'designers-guild-wallcoverings',
    'Donghia': 'donghia-wallcoverings',
    'Elitis': 'elitis-wallcoverings',
    'Fabricut': 'fabricut-wallcoverings',
    'Farrow & Ball': 'farrow-ball-wallcoverings',
    'Fornasetti': 'fornasetti',
    'Franquemont London': 'franquemont-london',
    'Fromental': 'fromental-wallcoverings',
    'Gaston y Daniela': 'gaston-y-daniela',
    'Graham & Brown': 'graham-and-brown-exclusive-wallpaper',
    'Groundworks': 'groundworks-wallcoverings',
    'Harlequin': 'harlequin-wallcoverings',
    'Hygge & West': 'hygge-and-west',
    'Innovations': 'innovations-usa',
    'Koroseal': 'koroseal-wallpaper-collection',
    'Kravet': 'kravet',
    'Lee Jofa': 'lee-jofa',
    'Maharam': 'maharam-wallcoverings',
    'Matthew Williamson': 'matthew-williamson-wallcoverings',
    'Maya Romanoff': 'maya-romanoff',
    'MDC': 'mdc-wallcoverings',
    'Milton & King': 'milton-and-king',
    'Mind the Gap': 'mind-the-gap',
    'Missoni Home': 'missoni-home',
    'Mr Perswall': 'mr-perswall-wallcoverings',
    'Mulberry': 'mulberry-wallcoverings',
    'Nina Campbell': 'nina-campbell-wallcoverings',
    'Nobilis': 'nobilis-wallcoverings',
    'Osborne & Little': 'osborne-little-wallpapers',
    'Phillip Jeffries': 'phillip-jeffries-wallcoverings',
    'Pierre Frey': 'pierre-frey-wallcoverings',
    'Philippe Romano Fabrics': 'philippe-romano-fabrics',
    'Ralph Lauren': 'ralph-lauren-wallpaper',
    'Rebel Walls': 'rebel-walls-murals',
    'Roberto Cavalli': 'roberto-cavalli-wallpaper',
    'Romo': 'romo-europe-wallpapaper-collections',
    'Sanderson': 'sanderson-wallcoverings',
    'Schumacher': 'schumacher-wallpaper',
    'Stout Textiles': 'stout-wallcoverings',
    'Stroheim': 'stroheim-wallcoverings',
    'The Graduate Collection': 'the-graduate-collection',
    'Thibaut': 'thibaut-wallcoverings',
    'Threads': 'threads-wallcoverings',
    'Versace': 'versace-wallpaper',
    'Wolf Gordon': 'wolf-gordon-wallcoverings',
    'York Wallcoverings': 'york-wallcoverings',
    'Zoffany': 'zoffany-wallcoverings',
    'Zuber': 'zuber-wallcoverings'
  };

  // Slugify vendor name as fallback if not in map
  function vendorToHandle(vendor) {
    if (VENDOR_HANDLES[vendor]) return VENDOR_HANDLES[vendor];
    // Fallback: slugify
    return vendor.toLowerCase()
      .replace(/[&]/g, 'and')
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/^-|-$/g, '');
  }

  // ── Enhance Product Cards ─────────────────────────────────────────────
  function enhanceProductCards() {
    // Find vendor elements: <p> elements with class containing "vendor"
    // inside the Boost filter block
    var container = document.querySelector('.boost-sd__filter-block');
    if (!container) return;

    // Strategy: find all <p> elements whose className contains "vendor"
    var allP = container.querySelectorAll('p[class*="vendor"]');

    for (var i = 0; i < allP.length; i++) {
      var vendorEl = allP[i];

      // Skip if already enhanced
      if (vendorEl.getAttribute('data-dw-linked')) continue;
      vendorEl.setAttribute('data-dw-linked', '1');

      var vendorName = vendorEl.textContent.trim();
      if (!vendorName) continue;

      var handle = vendorToHandle(vendorName);
      var link = document.createElement('a');
      link.href = '/collections/' + handle;
      link.textContent = vendorName;
      link.className = 'dw-vendor-link';
      link.title = 'Browse all ' + vendorName;
      link.addEventListener('click', function(e) {
        e.stopPropagation(); // Don't trigger product card click
      });

      vendorEl.textContent = '';
      vendorEl.appendChild(link);
    }

    // Add zoom-on-hover to product image wrappers that don't have a second image
    var imgWrappers = container.querySelectorAll('[class*="product-image-wrapper"]');
    for (var j = 0; j < imgWrappers.length; j++) {
      var wrapper = imgWrappers[j];
      if (wrapper.getAttribute('data-dw-hover')) continue;
      wrapper.setAttribute('data-dw-hover', '1');

      // If no second image, add zoom class
      var hasSecond = wrapper.className.indexOf('has-second-image') > -1;
      if (!hasSecond) {
        wrapper.classList.add('dw-hover-zoom');
      }
    }
  }

  // ── Run on Boost render + MutationObserver ─────────────────────────────
  // Boost renders async via React, so we watch for DOM changes
  var observer = new MutationObserver(function(mutations) {
    var shouldEnhance = false;
    for (var i = 0; i < mutations.length; i++) {
      if (mutations[i].addedNodes.length > 0) {
        shouldEnhance = true;
        break;
      }
    }
    if (shouldEnhance) {
      // Debounce slightly
      clearTimeout(observer._timer);
      observer._timer = setTimeout(enhanceProductCards, 150);
    }
  });

  function startObserving() {
    var target = document.querySelector('.boost-sd__filter-block');
    if (target) {
      observer.observe(target, { childList: true, subtree: true });
      // Also run once immediately in case cards already rendered
      enhanceProductCards();
    } else {
      // Boost hasn't loaded yet, wait
      setTimeout(startObserving, 500);
    }
  }

  // Start when DOM is ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', startObserving);
  } else {
    startObserving();
  }

})();


/* Infinite Scroll belt-and-suspenders for Boost AI Collection/Search grids.
   The PRIMARY mechanism is snippets/boost-infinite-override.liquid, which forces
   window.boostSDAppConfig.paginationType -> "infinite_scroll" before Boost inits,
   so Boost itself appends on scroll / renders a Load-More button. This block is
   the fallback: it auto-fires the Load-More / next-page control on scroll and
   hides ONLY the numbered pager (never Boost's own Load-More button).
   Hardened 2026-06-24: no permanent allLoaded latch; recovers between Boost
   re-renders; distinguishes numbered pager from load-more.
   Hardened 2026-06-26: single-init double-bind guard; idempotent style +
   scroll-listener attach; re-arms across Boost re-renders / SPA navigation so
   the grid is found even when it mounts long after first load. */
(function() {
  'use strict';
  if (window.__dwBoostInfiniteScroll) return;   // guard against double-binding
  window.__dwBoostInfiniteScroll = true;

  var loading = false;
  var STYLE_ID = 'dw-boost-hide-pager';
  var scrollBound = false;

  // Boost's "load more" button (infinite/load-more mode) takes priority; fall
  // back to a numbered "next" link only if no load-more control exists. We NEVER
  // hide this button — only the numbered pager list is hidden via CSS below.
  function getLoadMore() {
    return document.querySelector('.boost-sd__load-more-button:not([disabled])') ||
           document.querySelector('button[class*="load-more"]:not([disabled])') ||
           document.querySelector('.boost-sd__pagination-next:not(.boost-sd__pagination--disabled)') ||
           document.querySelector('[class*="pagination"] a[rel="next"]');
  }

  function loadMore() {
    if (loading) return;
    var btn = getLoadMore();
    if (btn) {
      loading = true;
      try { btn.click(); } catch (e) {}
      // Transient latch only — always released so a later scroll can retry.
      setTimeout(function() { loading = false; }, 1200);
      return;
    }
    // No button (Boost's mobile/infinite mode uses an IntersectionObserver
    // sentinel). A fling-to-bottom can overshoot the sentinel without it ever
    // crossing the viewport, so nudge up-then-down to force it to re-cross.
    loading = true;
    var y = window.scrollY;
    try {
      window.scrollTo(0, Math.max(0, y - 400));
      setTimeout(function () {
        var h = document.documentElement.scrollHeight;
        window.scrollTo(0, h);
        setTimeout(function () { loading = false; }, 600);
      }, 120);
    } catch (e) { loading = false; }
  }

  function onScroll() {
    var scrollBottom = window.innerHeight + window.scrollY;
    var docHeight = document.documentElement.scrollHeight;
    if (docHeight - scrollBottom < 900) loadMore();
  }

  // Idempotent: hide ONLY the numbered pager (pages list), never the
  // load-more button. Guarded by element id so re-runs don't stack <style>s.
  function hidePager() {
    if (document.getElementById(STYLE_ID)) return;
    var s = document.createElement('style');
    s.id = STYLE_ID;
    s.textContent =
      '.boost-sd__pagination-list,' +
      '.boost-sd__pagination-page-list,' +
      '.boost-sd__pagination .boost-sd__pagination-item{display:none!important;}';
    (document.head || document.documentElement).appendChild(s);
  }

  // Idempotent: bind the scroll handler at most once (it lives on window, so it
  // survives Boost re-renders / SPA grid swaps without needing a rebind).
  function bindScroll() {
    if (scrollBound) return;
    scrollBound = true;
    window.addEventListener('scroll', onScroll, { passive: true });
  }

  // Poll for the Boost grid and arm when it appears. Re-armable across SPA
  // navigation — the grid can mount well after the first poll window closes.
  var check = null;
  function arm() {
    if (check) return;            // a poll cycle is already running
    var ticks = 0;
    check = setInterval(function() {
      ticks++;
      if (document.querySelector('.boost-sd__product-list') ||
          document.querySelector('.boost-sd__filter-block')) {
        clearInterval(check); check = null;
        bindScroll();
        hidePager();
      } else if (ticks > 37) {    // ~15s @ 400ms — stop this cycle, can re-arm
        clearInterval(check); check = null;
      }
    }, 400);
  }

  // Re-arm on SPA navigation so a freshly-mounted Boost grid is recaught.
  function watchSpaNav() {
    var last = location.href;
    setInterval(function() {
      if (location.href !== last) { last = location.href; arm(); }
    }, 700);
    window.addEventListener('popstate', arm);
  }

  function init() {
    arm();
    watchSpaNav();
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else { init(); }
})();


/* ============================================================================
   DW card hover label (2026-06-22) — Boost product grid cards.
   Default: clean image-only grid (full SEO title hidden).
   On hover: reveal "Pattern, Color" (full title minus "wallcovering(s) | vendor")
   + the vendor on a smaller line. Full title stays in DOM for SEO.
   Re-applies to infinite-scroll-appended / Boost-rerendered cards via observer.
   ========================================================================== */
(function () {
  if (window.__dwCardHover) return;
  window.__dwCardHover = true;

  var STYLE_ID = 'dw-card-hover-style';
  if (!document.getElementById(STYLE_ID)) {
    var st = document.createElement('style');
    st.id = STYLE_ID;
    st.textContent =
      '.boost-sd__product-item .boost-sd__product-title,' +
      '.boost-sd__product-item .boost-sd__product-vendor{opacity:0;transition:opacity .15s;}' +
      '.boost-sd__product-item:hover .dw-hover-label{opacity:1;}' +
      '.dw-hover-label{opacity:0;transition:opacity .15s;padding:6px 2px 0;pointer-events:none;}' +
      '.dw-hover-label .pat{font-size:13px;font-weight:600;color:#1a1a1a;line-height:1.2;}' +
      '.dw-hover-label .ven{font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:#888;margin-top:2px;}';
    document.head.appendChild(st);
  }

  function titleCase(s) {
    return s.replace(/\w\S*/g, function (w) { return w.charAt(0).toUpperCase() + w.slice(1); });
  }

  function labelCard(card) {
    if (card.querySelector('.dw-hover-label')) return;
    var tEl = card.querySelector('.boost-sd__product-title');
    var vEl = card.querySelector('.boost-sd__product-vendor');
    if (!tEl) return;
    var vendor = (vEl ? vEl.textContent : '').trim();
    var name = tEl.textContent.split('|')[0].trim()        // drop "| vendor"
      .replace(/\s*wallcoverings?\b/ig, '')                // drop "wallcovering(s)"
      .replace(/[,\s]+$/, '').trim();
    var lab = document.createElement('div');
    lab.className = 'dw-hover-label';
    lab.innerHTML = '<div class="pat">' + titleCase(name) + '</div>' +
      (vendor ? '<div class="ven">' + titleCase(vendor) + '</div>' : '');
    tEl.parentElement.appendChild(lab);
  }

  function run() {
    var cards = document.querySelectorAll('.boost-sd__product-item');
    for (var i = 0; i < cards.length; i++) labelCard(cards[i]);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', run);
  } else {
    run();
  }

  // Re-apply when Boost renders a grid or infinite scroll appends more cards.
  var t;
  var obs = new MutationObserver(function (muts) {
    for (var i = 0; i < muts.length; i++) {
      if (muts[i].addedNodes && muts[i].addedNodes.length) {
        clearTimeout(t);
        t = setTimeout(run, 80);
        return;
      }
    }
  });
  obs.observe(document.body, { childList: true, subtree: true });
})();