← back to Dw Theme Boost Fix

snippets/boost-infinite-override.liquid

201 lines

{% comment %}
  ============================================================================
  Boost Infinite-Scroll Override  (DW, 2026-06-24)
  ----------------------------------------------------------------------------
  Boost AI Search & Discovery exposes NO API to flip pagination to infinite
  scroll, and the account-level dashboard setting serves
  paginationType:"default" (numbered) to the collection/search surfaces.

  This snippet forces infinite scroll by rewriting Boost's own runtime config
  object (window.boostSDAppConfig) BEFORE Boost's lazy bundle reads it.

  TIMING — this MUST render in <head> IMMEDIATELY BEFORE {{ content_for_header }}.
  Boost injects boostSDAppConfig via content_for_header app-embed scripts, so a
  defineProperty setter installed *before* that injection intercepts the moment
  Boost assigns the global. A theme asset (boost-sd-custom.js) loads on Boost's
  own schedule and can run AFTER Boost has already read the config, so the
  config-patch cannot live there — only the belt-and-suspenders fallbacks do.

  Strategy (belt + suspenders):
    1. defineProperty setter on window.boostSDAppConfig — patches on assignment.
    2. If already defined (re-entry / SPA nav), patch immediately.
    3. Short rAF/interval poll re-applies in case Boost replaces the object.
    4. Patch every pagination knob found: generalSettings.* and
       additionalElements.pagination.* across all surfaces.

  ----------------------------------------------------------------------------
  ENDPOINT REPOINT  (DW, 2026-07-13 — customer-facing regression fix)
  Boost's app-embed config snippet ("app snippet: config", rendered before
  </body> via the boost-sd app embed in settings_data.json) hardcodes the
  boostSDAppConfig.api URLs to https://staging.bc-solutions.net/... which
  returns 403 on every call → empty filter trees, dead search page, broken
  infinite scroll. The snippet has a production self-correction block keyed on
  themeInfo env, but at runtime the staging values still win (the final
  `merge(boostSDAppConfig, window.boostSDAppConfig)` lets a pre-existing
  window config's keys — populated with the staging api — take precedence).
  We cannot edit the app embed's source, so patchApi() below rewrites any
  staging bc-solutions endpoint to the VERIFIED production values every time
  the config passes through this interceptor. TARGETED: only values containing
  'staging.bc-solutions.net' / 'boost-cdn-staging' are rewritten, so if Boost
  fixes this upstream the patch becomes a no-op. Production values verified
  2026-07-13 against services.mybcapps.com (filter: 75,278 products;
  search 'grasscloth': 8,203; suggest 'milano': 85) and against Boost's own
  env==='production' Object.assign block in the same app snippet
  (recommendUrl /discovery/recommend, analyticsUrl lambda.mybcapps.com/e,
  cdn boost-cdn-prod.bc-solutions.net).
  ============================================================================
{% endcomment %}
<script>
(function () {
  'use strict';
  if (window.__dwBoostInfinite) return;
  window.__dwBoostInfinite = true;

  // USER PAGINATION CHOICE (DW, 2026-07-13 — Steve's verdict on customer
  // pushback): infinite scroll stays the DEFAULT, but the shopper can switch
  // to classic numbered pages of 100 via the floating toggle rendered below.
  // Choice persists in localStorage and survives reloads/navigation.
  var PAGED_LIMIT = 100;
  var mode = 'infinite';
  try { if (localStorage.getItem('dwPaginationMode') === 'paged') mode = 'paged'; } catch (e) {}

  var TARGET = mode === 'paged' ? 'default' : 'infinite_scroll';

  // Verified production Boost SD endpoints for this shop (2026-07-13).
  var PROD_API = {
    filterUrl:     'https://services.mybcapps.com/bc-sf-filter/filter',
    searchUrl:     'https://services.mybcapps.com/bc-sf-filter/search',
    suggestionUrl: 'https://services.mybcapps.com/bc-sf-filter/search/suggest',
    productsUrl:   'https://services.mybcapps.com/bc-sf-filter/search/products',
    recommendUrl:  'https://services.mybcapps.com/discovery/recommend',
    analyticsUrl:  'https://lambda.mybcapps.com/e',
    cdn:           'https://boost-cdn-prod.bc-solutions.net'
  };

  // Rewrite ONLY staging bc-solutions endpoints to production. Anything that
  // already points at a non-staging host is left untouched, so an upstream
  // Boost fix (or an intentional custom endpoint) is never clobbered.
  function isStaging(v) {
    return typeof v === 'string' &&
      (v.indexOf('staging.bc-solutions.net') !== -1 ||
       v.indexOf('boost-cdn-staging') !== -1);
  }
  function patchApi(api) {
    if (!api || typeof api !== 'object') return;
    for (var k in PROD_API) {
      if (!Object.prototype.hasOwnProperty.call(PROD_API, k)) continue;
      try {
        if (isStaging(api[k])) api[k] = PROD_API[k];
      } catch (e) {}
    }
  }

  // Recursively rewrite any "paginationType" key to infinite_scroll, plus the
  // common Boost shapes (generalSettings + additionalElements.pagination).
  // `seen` guards against cyclic / shared references so a self-referential
  // Boost config can't loop or get re-walked needlessly.
  function patchObj(o, depth, seen) {
    if (!o || typeof o !== 'object' || depth > 6) return;
    if (seen.indexOf(o) !== -1) return;
    seen.push(o);
    try {
      if (Object.prototype.hasOwnProperty.call(o, 'paginationType') &&
          typeof o.paginationType === 'string') {
        o.paginationType = TARGET;
      }
      // Boost sometimes nests under .pagination: { paginationType, ... }
      if (o.pagination && typeof o.pagination === 'object' &&
          typeof o.pagination.paginationType === 'string') {
        o.pagination.paginationType = TARGET;
      }
      // Paged mode: bump the grid's per-page product count to 100. Only keys
      // that sit next to a pagination knob are touched — searchPanelBlocks'
      // pageSize (the instant-search dropdown, 25 items) must stay untouched.
      if (mode === 'paged' &&
          (Object.prototype.hasOwnProperty.call(o, 'paginationType') ||
           (o.pagination && typeof o.pagination === 'object'))) {
        ['limit', 'productPerPage', 'productsPerPage'].forEach(function (k) {
          if (typeof o[k] === 'number' && o[k] > 0 && o[k] < PAGED_LIMIT) o[k] = PAGED_LIMIT;
        });
      }
    } catch (e) {}
    for (var k in o) {
      if (!Object.prototype.hasOwnProperty.call(o, k)) continue;
      var v = o[k];
      if (v && typeof v === 'object') patchObj(v, depth + 1, seen);
    }
  }

  function patchConfig(cfg) {
    if (!cfg || typeof cfg !== 'object') return cfg;
    try {
      // One shared `seen` set across all walks keeps total work O(n) and
      // cycle-safe — known surfaces get a full-depth targeted walk, then the
      // catch-all root walk skips anything already visited (schema-drift safe).
      var seen = [];
      patchApi(cfg.api); // endpoint repoint — staging → production (2026-07-13)
      if (cfg.generalSettings) patchObj(cfg.generalSettings, 0, seen);
      if (cfg.additionalElements) patchObj(cfg.additionalElements, 0, seen);
      patchObj(cfg, 0, seen);
    } catch (e) {}
    return cfg;
  }

  // (1) defineProperty setter — intercept Boost's assignment.
  var _cfg = window.boostSDAppConfig;
  try {
    Object.defineProperty(window, 'boostSDAppConfig', {
      configurable: true,
      enumerable: true,
      get: function () { return _cfg; },
      set: function (val) { _cfg = patchConfig(val); }
    });
  } catch (e) {
    // If defineProperty fails (already non-configurable), fall through to poll.
  }

  // (2) Patch immediately if it already exists.
  if (_cfg) patchConfig(_cfg);

  // (3) Short poll — re-apply in case Boost replaces / re-inits the object,
  // and re-patch after a few frames so the value is correct at bundle init.
  var ticks = 0;
  var iv = setInterval(function () {
    ticks++;
    try { if (window.boostSDAppConfig) patchConfig(window.boostSDAppConfig); } catch (e) {}
    if (ticks > 80) clearInterval(iv); // ~8s @ 100ms, well past Boost init
  }, 100);

  {% if request.page_type == 'collection' or request.page_type == 'search' %}
  // Floating browse-mode toggle (collection + search grids only).
  document.addEventListener('DOMContentLoaded', function () {
    if (document.getElementById('dw-page-mode')) return;
    var wrap = document.createElement('div');
    wrap.id = 'dw-page-mode';
    wrap.setAttribute('role', 'group');
    wrap.setAttribute('aria-label', 'Browsing mode');
    wrap.style.cssText = 'position:fixed;bottom:18px;left:18px;z-index:9990;display:flex;' +
      'background:#fff;border:1px solid #d8d4cc;border-radius:999px;box-shadow:0 2px 10px rgba(0,0,0,.12);' +
      'font:12px/1 -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;overflow:hidden';
    function btn(label, m, active) {
      var b = document.createElement('button');
      b.type = 'button';
      b.textContent = label;
      b.setAttribute('aria-pressed', active ? 'true' : 'false');
      b.style.cssText = 'border:0;cursor:pointer;padding:9px 14px;letter-spacing:.04em;' +
        (active ? 'background:#1a1a1a;color:#fff;' : 'background:transparent;color:#444;');
      b.addEventListener('click', function () {
        if (mode === m) return;
        try { localStorage.setItem('dwPaginationMode', m); } catch (e) {}
        location.reload();
      });
      return b;
    }
    wrap.appendChild(btn('Endless scroll', 'infinite', mode === 'infinite'));
    wrap.appendChild(btn('Pages of 100', 'paged', mode === 'paged'));
    document.body.appendChild(wrap);
  });
  {% endif %}
})();
</script>