/*
 * Dynasort search results widget. Lives in the extension's assets/ (the block's
 * inline script pushed the extension past the 100 KB Liquid limit); the
 * search-results.liquid block renders the markup shell + data attributes and
 * loads this file via asset_url. No Liquid in here: everything block-specific
 * arrives as data-* attributes on the [data-dynasort-search] root.
 */
(function () {
  function dsSearchInit(root) {
  // Init-once guard per root (same multi-instance hazard as the recommendations
  // block; see its comment). The shell may appear more than once if the block
  // is added twice; each script tag re-scans, the guard keeps wiring single.
  if (!root || root.__dsInit) return;
  root.__dsInit = true;
  var query = (root.getAttribute('data-q') || '').trim();
  var perPage = parseInt(root.getAttribute('data-per-page'), 10) || 24;
  var gridEl = root.querySelector('[data-grid]');
  var pagerEl = root.querySelector('[data-pager]');
  var pageInfoEl = root.querySelector('[data-page-info]');
  var prevBtn = root.querySelector('[data-page-prev]');
  var nextBtn = root.querySelector('[data-page-next]');
  var emptyEl = root.querySelector('[data-empty]');
  var summaryEl = root.querySelector('[data-summary]');
  var canHover = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;

  // Storefront-language strings, rendered per shopper locale by liquid into
  // data attributes (extension locales/). [q]/[n]/[x]/[y] are replaced at
  // render time.
  function tt(k, fb) { var v = root.getAttribute('data-t-' + k); return (v != null && v !== '') ? v : fb; }

  var products = [], currency = 'USD', page = 0, addedLabel = tt('added', 'Added to cart'), serveId = 0;
  var showVendor = root.getAttribute('data-show-vendor') === 'true';
  var showButtons = root.getAttribute('data-show-buttons') === 'true';

  // CX tracking (plans/cx-tracking-jul-2026.md): serves carry the pixel's own
  // session cookie so attribution joins line up; card clicks and inline adds
  // beacon back with the serve id + position. sendBeacon survives navigation.
  // Beacons go to the recommend proxy path: the /search subpath dispatches to
  // the search endpoint before the cx branch is reached.
  function dsSession() {
    var m = document.cookie.match(/(?:^|; )mmm_cookies=([^;]+)/);
    return m ? decodeURIComponent(m[1]) : '';
  }
  function cxBeacon(evt, cardEl) {
    if (!serveId || !cardEl) return;
    var url = '/apps/dynasort/recommend?service=cx&serve_id=' + encodeURIComponent(serveId) + '&e=' + evt +
      '&pid=' + encodeURIComponent(cardEl.getAttribute('data-pid') || '') +
      '&pos=' + encodeURIComponent(cardEl.getAttribute('data-pos') || '');
    if (navigator.sendBeacon) { navigator.sendBeacon(url); }
    else { fetch(url, { keepalive: true }).catch(function () {}); }
  }
  // Presentation settings (from the app admin via the proxy); defaults = all on,
  // recs-shaped fields (see search_default_settings app-side). Older cached
  // responses may still carry fields.badges as one bool; badgesHtml handles both.
  var settings = {
    layout: { columns: { mobile: 2, tablet: 3, desktop: 4 }, per_page: perPage },
    fields: {
      compare_at: true, color_name: true, swatches: true,
      swatch_shape: 'circle', swatch_style: 'color',
      rating: true, inline_add: true,
      badges: { sale: true, new: true, bestseller: true, low_stock: true },
      badge_colors: {}
    }
  };
  function fld(k) { return settings.fields && settings.fields[k] !== false; }

  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]); }); }
  // Market currency: themes price in the shopper's presentment currency
  // (window.Shopify.currency = {active, rate}); our catalog prices arrive in
  // the SHOP currency. Convert with the theme's own rate so the widget never
  // shows USD beside the theme's MXN. Per-market price lists render an
  // approximation (exact needs Storefront-priced cards, deferred).
  function dsMarket() {
    var sc = window.Shopify && window.Shopify.currency;
    if (sc && sc.active && sc.rate && parseFloat(sc.rate) > 0) { return { active: sc.active, rate: parseFloat(sc.rate) }; }
    return null;
  }
  function money(v, c, bare) {
    if (v == null) return '';
    var m = dsMarket();
    var converted = false;
    if (m && c && m.active !== c) { v = v * m.rate; c = m.active; converted = true; }
    c = c || 'USD';
    // Bare symbol + trailing ISO code ("$47.99 CAD"), Shopify's own
    // money-with-currency shape. Intl's default symbol mode prefixes
    // disambiguators ("CA$") for shoppers whose locale is outside the
    // currency's country. bare = number only (compare-at, range starts),
    // so the code renders once per price line.
    var num;
    try { num = new Intl.NumberFormat(undefined, { style: 'currency', currency: c, currencyDisplay: 'narrowSymbol' }).format(v); }
    catch (e) {
      try { num = new Intl.NumberFormat(undefined, { style: 'currency', currency: c }).format(v); }
      catch (e2) { num = '$' + Number(v).toFixed(2); }
    }
    // ISO code only beside CONVERTED (approximate) prices; domestic shoppers
    // see the native theme's plain format ("$349.99", not "$349.99 USD").
    return (bare || !converted) ? num : num + ' ' + c;
  }
  function priceRange(min, max, c) { if (min == null) return ''; return (max != null && max > min) ? money(min, c, true) + '–' + money(max, c) : money(min, c); }

  // Per-badge toggles + merchant colors (settings.fields.badges/badge_colors,
  // recs-shaped). A legacy bool `badges` (old cached settings) means all types.
  function badgesHtml(card) {
    var b = (settings.fields && settings.fields.badges) || {};
    if (typeof b !== 'object') { b = b ? { sale: true, new: true, bestseller: true, low_stock: true } : {}; }
    var colors = (settings.fields && settings.fields.badge_colors) || {};
    var out = [];
    function add(key, builtIn, on) {
      if (!on) return;
      var c = colors[key] || {};
      out.push({ label: builtIn, bg: c.bg || '#111111', text: c.text || '#ffffff' });
    }
    add('sale', 'Sale', b.sale && card.on_sale);
    add('new', 'New', b.new && card.badges && card.badges.is_new);
    add('bestseller', 'Bestseller', b.bestseller && card.badges && card.badges.bestseller);
    add('low_stock', 'Low stock', b.low_stock && card.badges && card.badges.low_stock);
    // Merchant-defined badge rules (settings.custom_badges) arrive pre-matched
    // per card from the server; they bypass the per-type toggles above.
    ((card.badges && card.badges.custom) || []).forEach(function (cb) {
      out.push({ label: cb.label, bg: cb.bg || '#111111', text: cb.text || '#ffffff' });
    });
    if (!out.length) return '';
    return '<div class="dynasort-search__badges">' + out.map(function (x) {
      return '<span class="dynasort-search__badge" style="background:' + x.bg + ';color:' + x.text + '">' + esc(x.label) + '</span>';
    }).join('') + '</div>';
  }
  function ratingHtml(card) {
    if (!card.rating || !card.rating.count) return '';
    var v = Math.round(card.rating.value);
    return '<div class="dynasort-search__rating">' + ('★★★★★'.slice(0, v) + '☆☆☆☆☆'.slice(0, 5 - v)) + ' (' + card.rating.count + ')</div>';
  }
  // "(Save $60)" note beside the price (settings.fields.savings: amount|percent).
  // Mirrors the Golf Discount theme's price snippet: value = compare_at_max minus
  // price_max; "up to" wording when both price and compare-at vary across variants.
  // Card-level only by design: swatch clicks do not recompute it per color group.
  function savingsHtml(card) {
    var mode = settings.fields && settings.fields.savings;
    if (mode !== 'amount' && mode !== 'percent') return '';
    if (card.compare_at_max == null || card.price_max == null) return '';
    var diff = card.compare_at_max - card.price_max;
    if (diff <= 0) return '';
    var upTo = card.price_min != null && card.price_max > card.price_min &&
               card.compare_at_min != null && card.compare_at_max > card.compare_at_min;
    var val = mode === 'percent' ? Math.round(diff * 100 / card.compare_at_max) + '%' : money(diff, currency);
    var tpl = upTo ? tt('save-up-to', '(Save up to [x])') : tt('save', '(Save [x])');
    return ' <span class="dynasort-search__save">' + esc(tpl).replace('[x]', esc(val)) + '</span>';
  }
  function swatchesHtml(card) {
    if (!card.swatches || card.swatches.length < 2) return '';
    var imageStyle = settings.fields && settings.fields.swatch_style === 'image';
    var cap = 5, shown = card.swatches.slice(0, cap), overflow = card.swatches.length - shown.length;
    var dots = shown.map(function (s, i) {
      var bg = imageStyle && s.image ? ('background-image:url(' + s.image + ')')
             : (s.swatch ? ('background-color:' + s.swatch) : (s.image ? ('background-image:url(' + s.image + ')') : ''));
      var cls = 'dynasort-search__swatch' + (s.available ? '' : ' dynasort-search__swatch--soldout');
      return '<button type="button" class="' + cls + '" style="' + bg + '" title="' + esc(s.name) + '" data-swatch="' + i + '" aria-label="' + esc(s.name) + '" aria-pressed="' + (i === 0 ? 'true' : 'false') + '"></button>';
    }).join('');
    var more = overflow > 0 ? '<a class="dynasort-search__swatch-more" href="' + esc(card.url) + '">+' + overflow + '</a>' : '';
    return '<div class="dynasort-search__swatches" data-swatches>' + dots + more + '</div>';
  }

  function cardHtml(card, cardIdx) {
    var img = card.featured_image ? '<img src="' + esc(card.featured_image) + '" alt="' + esc(card.title) + '" loading="lazy" data-main-img>' : '';
    var showSwatches = fld('swatches');
    var colorName = (fld('color_name') && showSwatches && card.swatches && card.swatches.length) ? '<div class="dynasort-search__colorname" data-colorname>' + esc(card.swatches[0].name) + '</div>' : '';
    var priceCmp = (fld('compare_at') && card.compare_at_min != null) ? '<del>' + money(card.compare_at_min, currency, true) + '</del>' : '';
    var price = '<div class="dynasort-search__price" data-price' + (card.price_reveal ? ' hidden' : '') + '>' + priceCmp + '<ins>' + priceRange(card.price_min, card.price_max, currency) + '</ins>' + savingsHtml(card) + '</div>';
    // MAP-style price hiding (card.price_reveal from settings.price_reveal): the
    // whole price block (price, compare-at, savings) sits behind a reveal control.
    if (card.price_reveal) {
      price = '<button type="button" class="dynasort-search__price-reveal" data-price-reveal>' +
        esc(card.price_reveal.text || tt('reveal-price', 'Click to Reveal Price')) + '</button>' + price;
    }

    var inlineOk = fld('inline_add') && canHover && card.axis_count >= 1 && card.axis_count <= 2;
    var sizes = '';
    if (inlineOk) {
      var sel = (card.swatches && card.swatches.length) ? card.swatches[0].value : null;
      var btns = (card.variants || []).map(function (v) {
        // group/label are the server's rules-aware display values (option
        // display rules); raw option1/option2 are the pre-rules fallback.
        var label = (v.label != null) ? v.label : (card.axis_count === 2 ? v.option2 : v.option1);
        if (label == null) return '';
        var grp = (v.group != null) ? v.group : (v.option1 || '');
        var inColor = card.axis_count === 2 ? (grp === sel) : true;
        return '<button type="button" class="dynasort-search__size" data-variant="' + esc(v.id) + '" data-color="' + esc(grp) + '"' + (v.available ? '' : ' disabled') + (inColor ? '' : ' hidden') + '>' + esc(label) + '</button>';
      }).join('');
      if (btns) sizes = '<div class="dynasort-search__sizes" data-sizes>' + btns + '</div>';
    }

    var cls = 'dynasort-search__card' + (sizes ? ' dynasort-search__card--inline' : '');
    var vend = (showVendor && card.vendor) ? '<div class="dynasort-search__vendor">' + esc(card.vendor) + '</div>' : '';
    // Always-visible buy button (block setting): direct add for single-variant
    // products, a product-page link labeled Choose options for the rest.
    var buy = '';
    if (showButtons) {
      var single = (card.variants && card.variants.length === 1) ? card.variants[0] : null;
      if (single) {
        buy = '<button type="button" class="dynasort-search__buy" data-buy="' + esc(single.id) + '"' + (single.available ? '' : ' disabled') + '>' + esc(tt('add-to-cart', 'Add to cart')) + '</button>';
      } else {
        buy = '<a class="dynasort-search__buy dynasort-search__buy--link" href="' + esc(card.url) + '">' + esc(tt('choose-options', 'Choose options')) + '</a>';
      }
    }
    // Global rank across pagination, so CX click positions match the served list.
    var pos = page * perPage + cardIdx + 1;
    return '<div class="' + cls + '" data-card data-handle="' + esc(card.handle) + '" data-pid="' + esc(card.id) + '" data-pos="' + pos + '" data-visual-axis="' + card.visual_axis + '">' +
      '<div class="dynasort-search__media">' +
        '<a class="dynasort-search__media-link" href="' + esc(card.url) + '">' + img + '</a>' +
        (fld('badges') ? badgesHtml(card) : '') + sizes +
        '<div class="dynasort-search__added" data-added></div>' +
      '</div>' +
      vend +
      '<a class="dynasort-search__title" href="' + esc(card.url) + '">' + esc(card.title) + '</a>' +
      colorName + (fld('rating') ? ratingHtml(card) : '') + price + (showSwatches ? swatchesHtml(card) : '') + buy +
      '</div>';
  }

  function ensureImages(cardEl, card) {
    if (cardEl._dsImg) return Promise.resolve(cardEl._dsImg);
    if (!card.handle) { cardEl._dsImg = {}; cardEl._dsImgs = []; return Promise.resolve({}); }
    return fetch('/products/' + card.handle + '.js', { headers: { 'Accept': 'application/json' } })
      .then(function (r) { return r.json(); })
      .then(function (prod) {
        var ai = (card.visual_axis || 1) - 1, map = {};
        (prod.variants || []).forEach(function (v) { var val = v.options && v.options[ai]; if (val && !map[val] && v.featured_image && v.featured_image.src) map[val] = v.featured_image.src; });
        cardEl._dsImg = map;
        cardEl._dsImgs = prod.images || [];   // full gallery, for hover second image
        return map;
      }).catch(function () { cardEl._dsImg = {}; cardEl._dsImgs = []; return {}; });
  }

  function wireCard(cardEl, card) {
    var swatchWrap = cardEl.querySelector('[data-swatches]');
    var priceEl = cardEl.querySelector('[data-price]');
    var colorNameEl = cardEl.querySelector('[data-colorname]');
    var sizesEl = cardEl.querySelector('[data-sizes]');
    var mainImg = cardEl.querySelector('[data-main-img]');

    var buyBtn = cardEl.querySelector('[data-buy]');
    if (buyBtn) {
      buyBtn.addEventListener('click', function () { addToCart(buyBtn.getAttribute('data-buy'), cardEl); });
    }

    // image-style swatches: enrich dot backgrounds with real per-variant images
    if (swatchWrap && settings.fields && settings.fields.swatch_style === 'image') {
      ensureImages(cardEl, card).then(function (map) {
        Array.prototype.forEach.call(swatchWrap.querySelectorAll('[data-swatch]'), function (btn) {
          var s = card.swatches[parseInt(btn.getAttribute('data-swatch'), 10)];
          if (s && map[s.value]) btn.style.backgroundImage = 'url(' + map[s.value] + ')';
        });
      });
    }

    // Hover second image (settings.image.hover_second_image): swap to the
    // product's second gallery photo while the pointer is over the media area.
    // The gallery rides the same /products/{handle}.js fetch the swatches use;
    // the pre-hover src is captured at swap time so a swatch-selected image
    // restores correctly on leave.
    if (settings.image && settings.image.hover_second_image && canHover && mainImg) {
      var mediaEl = cardEl.querySelector('.dynasort-search__media');
      var hovering = false;
      if (mediaEl) {
        mediaEl.addEventListener('mouseenter', function () {
          hovering = true;
          ensureImages(cardEl, card).then(function () {
            var imgs = cardEl._dsImgs || [];
            if (!hovering || imgs.length < 2) return;
            if (cardEl._dsHoverBase == null) cardEl._dsHoverBase = mainImg.getAttribute('src');
            mainImg.src = imgs[1];
          });
        });
        mediaEl.addEventListener('mouseleave', function () {
          hovering = false;
          if (cardEl._dsHoverBase != null) { mainImg.src = cardEl._dsHoverBase; cardEl._dsHoverBase = null; }
        });
      }
    }

    if (swatchWrap) {
      swatchWrap.addEventListener('click', function (e) {
        var btn = e.target.closest('[data-swatch]'); if (!btn) return;
        var s = card.swatches[parseInt(btn.getAttribute('data-swatch'), 10)]; if (!s) return;
        Array.prototype.forEach.call(swatchWrap.querySelectorAll('[data-swatch]'), function (b) { b.setAttribute('aria-pressed', 'false'); });
        btn.setAttribute('aria-pressed', 'true');
        if (priceEl) {
          var cmp = (fld('compare_at') && s.compare_at_min != null) ? '<del>' + money(s.compare_at_min, currency, true) + '</del>' : '';
          priceEl.innerHTML = cmp + '<ins>' + priceRange(s.price_min, s.price_max, currency) + '</ins>' + savingsHtml(card);
        }
        if (colorNameEl) colorNameEl.textContent = s.name;
        if (sizesEl && card.axis_count === 2) {
          Array.prototype.forEach.call(sizesEl.querySelectorAll('[data-variant]'), function (sb) { if (sb.getAttribute('data-color') === s.value) sb.removeAttribute('hidden'); else sb.setAttribute('hidden', ''); });
        }
        if (mainImg) ensureImages(cardEl, card).then(function (map) { if (map[s.value]) mainImg.src = map[s.value]; });
      });
    }
    if (sizesEl) {
      sizesEl.addEventListener('click', function (e) {
        var sb = e.target.closest('[data-variant]'); if (!sb || sb.disabled) return;
        addToCart(sb.getAttribute('data-variant'), cardEl);
      });
    }
    var revealBtn = cardEl.querySelector('[data-price-reveal]');
    if (revealBtn) {
      revealBtn.addEventListener('click', function () {
        revealBtn.hidden = true;
        if (priceEl) priceEl.hidden = false;
      });
    }
    // CX: click-through to the PDP (any card link; the beacon outlives navigation).
    cardEl.addEventListener('click', function (e) {
      if (e.target.closest('a[href]')) { cxBeacon('click', cardEl); }
    });
  }

  // Theme cart UI refresh after an AJAX add. Themes have no shared contract for
  // this, so cover the common families: update known count nodes from /cart.js,
  // re-render Dawn-family's cart-icon-bubble section when present, and broadcast
  // the event names the major themes listen for. All paths are harmless no-ops
  // on themes that lack them.
  function refreshCartUi() {
    fetch('/cart.js', { headers: { 'Accept': 'application/json' } })
      .then(function (r) { return r.json(); })
      .then(function (cart) {
        var n = cart.item_count;
        ['[data-cart-count]', '.cart-count-bubble span:not(.visually-hidden)', '.cart-count', '#CartCount', '.cart-count-number'].forEach(function (sel) {
          document.querySelectorAll(sel).forEach(function (el) {
            el.textContent = n;
            if (el.hasAttribute('data-cart-count')) el.setAttribute('data-cart-count', n);
          });
        });
        if (document.getElementById('cart-icon-bubble')) {
          fetch(window.location.pathname + '?sections=cart-icon-bubble')
            .then(function (r) { return r.json(); })
            .then(function (sections) {
              if (!sections || !sections['cart-icon-bubble']) return;
              // Assign INNER html into the existing element (Dawn's own approach):
              // replacing the node itself swaps the inline bubble for the rendered
              // section's full-width wrapper and it falls out of the header.
              var doc = new DOMParser().parseFromString(sections['cart-icon-bubble'], 'text/html');
              var src = doc.getElementById('cart-icon-bubble') || doc.querySelector('.shopify-section') || doc.body;
              var cur = document.getElementById('cart-icon-bubble');
              if (src && cur) cur.innerHTML = src.innerHTML;
            }).catch(function () {});
        }
        document.dispatchEvent(new CustomEvent('cart:refresh'));
        document.dispatchEvent(new CustomEvent('cart:updated', { detail: { cart: cart } }));
        document.dispatchEvent(new CustomEvent('cart:change', { detail: { cart: cart } }));
        document.documentElement.dispatchEvent(new CustomEvent('cart:build'));
        // Horizon family (web-component themes): cart icon + drawer listen for the
        // standard storefront event and read an attached promise resolving to
        // {cart:{totalQuantity}} / {detail:{itemCount}}. We attach no sections HTML,
        // which makes Horizon's cart-items component fall back to re-rendering its
        // own section, so the drawer contents refresh too.
        try {
          var se = new Event('shopify:cart:lines-update', { bubbles: true, cancelable: true });
          se.promise = Promise.resolve({ cart: { totalQuantity: n }, detail: { itemCount: n } });
          document.dispatchEvent(se);
        } catch (err) {}
      }).catch(function () {});
  }

  function addToCart(variantId, cardEl) {
    if (!variantId) return;
    fetch('/cart/add.js', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ id: variantId, quantity: 1 }) })
      .then(function (r) { return r.json(); })
      .then(function () {
        var added = cardEl.querySelector('[data-added]');
        if (added) { added.textContent = addedLabel; added.classList.add('is-shown'); setTimeout(function () { added.classList.remove('is-shown'); }, 2200); }
        cxBeacon('inline_add', cardEl);
        refreshCartUi();
      }).catch(function () {});
  }

  function totalPages() { return Math.max(1, Math.ceil(products.length / perPage)); }

  function renderPage(p) {
    var tp = totalPages();
    page = Math.max(0, Math.min(p, tp - 1));
    var slice = products.slice(page * perPage, page * perPage + perPage);
    gridEl.innerHTML = slice.map(cardHtml).join('');
    Array.prototype.forEach.call(gridEl.querySelectorAll('[data-card]'), function (el, i) { wireCard(el, slice[i]); });
    if (tp > 1) {
      pagerEl.removeAttribute('hidden');
      prevBtn.disabled = page === 0;
      nextBtn.disabled = page >= tp - 1;
      pageInfoEl.textContent = tt('page-of', 'Page [x] of [y]').replace('[x]', page + 1).replace('[y]', tp);
    } else {
      pagerEl.setAttribute('hidden', '');
      prevBtn.disabled = true;
      nextBtn.disabled = true;
    }
  }

  function skeleton() {
    var n = Math.min(perPage, 8), cells = '';
    for (var i = 0; i < n; i++) cells += '<div class="dynasort-search__skeleton"></div>';
    gridEl.innerHTML = cells;
  }

  function render(data) {
    products = data.products || [];
    currency = data.currency || 'USD';
    serveId = data.serve_id || 0;
    // Apply admin presentation settings (columns + page size); fields used in cardHtml.
    if (data.settings && typeof data.settings === 'object') { settings = data.settings; }
    var L = (settings.layout && settings.layout.columns) || {};
    if (L.mobile)  root.style.setProperty('--ds-cols-mobile', L.mobile);
    if (L.tablet)  root.style.setProperty('--ds-cols-tablet', L.tablet);
    if (L.desktop) root.style.setProperty('--ds-cols-desktop', L.desktop);
    if (settings.layout && settings.layout.per_page) { perPage = parseInt(settings.layout.per_page, 10) || perPage; }
    // Swatch shape + size follow the settings like the recs block: square
    // swatches square the corners, image-style swatches render larger.
    var swSquare = settings.fields && settings.fields.swatch_shape === 'square';
    var swImage = settings.fields && settings.fields.swatch_style === 'image';
    root.style.setProperty('--ds-swatch-radius', swSquare ? '0.2em' : '50%');
    root.style.setProperty('--ds-swatch-size', swImage ? '2.6em' : '1.5em');
    // Filters render on EVERY response, including empty ones: a shopper who
    // filtered down to zero results needs the bar to un-filter.
    renderFilters(data);
    if (summaryEl) {
      var st = (products.length === 1 ? tt('result-for', '1 result for “[q]”') : tt('results-for', '[n] results for “[q]”'));
      summaryEl.textContent = st.replace('[n]', products.length).replace('[q]', query);
      summaryEl.removeAttribute('hidden');
    }
    if (!products.length) {
      gridEl.innerHTML = '';
      emptyEl.textContent = tt('no-results', 'No results for “[q]”.').replace('[q]', query);
      emptyEl.removeAttribute('hidden');
      pagerEl.setAttribute('hidden', '');
      return;
    }
    emptyEl.setAttribute('hidden', '');
    renderPage(0);
    hideNativePagination();
  }

  // The block pages over the ENTIRE ranked set client-side, so the theme's own
  // server-side pagination must not render beside it: its ?page=N links reload
  // the page and the block re-renders the same ranked list, which reads as
  // broken paging (and it shows even when the ranked set fits on one page).
  // Conservative: known pagination containers only, never anything inside the
  // block, and only once the block has actually rendered results.
  function hideNativePagination() {
    if (root.getAttribute('data-hide-native-pagination') !== 'true') return;
    var sels = [
      '.pagination', 'nav.pagination', '.pagination-wrapper', '.pagination__wrapper',
      'ul.pagination', 'nav[aria-label*="agination"]', '.facets-pagination', '.pagination-list'
    ];
    Array.prototype.forEach.call(document.querySelectorAll(sels.join(',')), function (el) {
      if (root.contains(el) || el.contains(root)) return;
      el.style.display = 'none';
      el.setAttribute('data-ds-hidden-pagination', '');
    });
  }

  if (prevBtn) prevBtn.addEventListener('click', function () { renderPage(page - 1); root.scrollIntoView({ behavior: 'smooth', block: 'start' }); });
  if (nextBtn) nextBtn.addEventListener('click', function () { renderPage(page + 1); root.scrollIntoView({ behavior: 'smooth', block: 'start' }); });

  if (!query) return; // no query → leave the section dark, let the theme handle it

  // Filter + sort passthrough. Theme filter links use dotted keys
  // (filter.v.price.gte) which cannot cross the App Proxy as KEYS (PHP mangles
  // dots and the proxy signature breaks), so all filter params are packed into
  // ONE `dsf` value. Repeated keys are comma-joined for the same reason.
  var sortEl = root.querySelector('[data-sort]');
  var filtersEl = root.querySelector('[data-filters]');
  // Per-group open state, keyed by group label. Tri-state: undefined = layout
  // default (bar closed, sidebar open), true/false = shopper's explicit choice,
  // preserved across the re-render every filter change triggers.
  var openGroups = {};
  // The sidebar PRESENTATION only exists at >=990px (the CSS grid lives inside
  // that media query); below it the bar presentation renders regardless of the
  // merchant setting. Every open/close decision must key off the presentation
  // actually on screen, not the setting, or a sidebar-configured store shows
  // every dropdown forced open on narrow windows.
  var sidebarMq = window.matchMedia('(min-width: 990px)');
  var lastFilterData = null;
  function sidebarActive() {
    return (settings.filters || {}).layout === 'sidebar' && sidebarMq.matches;
  }
  function onSidebarMqChange() {
    // Crossing the breakpoint swaps the presentation, so stale open state from
    // the other layout is wrong either way. Reset to the new layout's defaults
    // (bar: all closed, sidebar: all open) and re-render.
    openGroups = {};
    if (lastFilterData) renderFilters(lastFilterData);
  }
  if (sidebarMq.addEventListener) sidebarMq.addEventListener('change', onSidebarMqChange);
  else if (sidebarMq.addListener) sidebarMq.addListener(onSidebarMqChange);

  function filterParamsFromUrl() {
    var map = {};
    var usp = new URLSearchParams(window.location.search);
    usp.forEach(function (v, k) {
      if (k.indexOf('filter.') !== 0 || v === '') return;
      v.split(',').forEach(function (one) {
        if (one === '') return;
        if (!map[k]) map[k] = [];
        if (map[k].indexOf(one) < 0) map[k].push(one);
      });
    });
    return map;
  }
  function writeFilterParams(map) {
    var u = new URL(window.location.href);
    Array.from(u.searchParams.keys()).forEach(function (k) { if (k.indexOf('filter.') === 0) u.searchParams.delete(k); });
    Object.keys(map).forEach(function (k) { if (map[k] && map[k].length) u.searchParams.set(k, map[k].join(',')); });
    window.history.replaceState({}, '', u.toString());
  }
  function toggleFilterValue(param, value, on) {
    var map = filterParamsFromUrl();
    var list = map[param] || [];
    var i = list.indexOf(value);
    if (on && i < 0) list.push(value);
    if (!on && i >= 0) list.splice(i, 1);
    map[param] = list;
    writeFilterParams(map);
    fetchResults();
  }

  // Facet bar / sidebar. Groups arrive on the serve response (the merchant's
  // Search & Discovery filter config with live counts); which layout renders
  // and whether counts show are app-side settings (settings.filters). Applied
  // state lives in the URL's filter.* params, the same params the theme's own
  // faceted search uses, so dsf packing and shareable URLs come for free.
  function renderFilters(data) {
    if (!filtersEl) return;
    lastFilterData = data;
    var fs = (data && data.filters) || [];
    var fset = settings.filters || {};
    if (fset.enabled === false || !fs.length) {
      filtersEl.setAttribute('hidden', '');
      filtersEl.innerHTML = '';
      root.classList.remove('dynasort-search--sidebar');
      return;
    }
    // The class follows the SETTING (the CSS media query gates where the
    // sidebar grid applies); open-state defaults follow the live presentation.
    root.classList.toggle('dynasort-search--sidebar', fset.layout === 'sidebar');
    var sidebar = sidebarActive();
    var active = filterParamsFromUrl();
    var activeCount = Object.keys(active).reduce(function (n, k) { return n + active[k].length; }, 0);
    var showCounts = fset.show_counts !== false;
    var html = '<span class="dynasort-search__filters-label">' + esc(tt('filter', 'Filter')) + '</span>';
    fs.forEach(function (g) {
      var isOpen = (openGroups[g.label] !== undefined) ? openGroups[g.label] : sidebar;
      var panel = '';
      var gActive = 0;
      if (g.type === 'list') {
        panel = g.values.map(function (v) {
          var isOn = (active[v.param] || []).indexOf(v.value) >= 0;
          if (isOn) gActive++;
          return '<label class="dynasort-search__fvalue"><input type="checkbox" data-fparam="' + esc(v.param) + '" data-fvalue="' + esc(v.value) + '"' + (g.single ? ' data-fsingle="1"' : '') + (isOn ? ' checked' : '') + '><span>' + esc(v.label) + '</span>' + (showCounts ? '<span class="dynasort-search__fcount">(' + v.count + ')</span>' : '') + '</label>';
        }).join('');
      } else if (g.type === 'price') {
        var gte = (active[g.param_gte] || [])[0] || '';
        var lte = (active[g.param_lte] || [])[0] || '';
        gActive = (gte !== '' || lte !== '') ? 1 : 0;
        panel = '<div class="dynasort-search__fprice">' +
          '<input type="number" inputmode="decimal" min="0" max="' + g.max + '" placeholder="' + (g.min || 0) + '" value="' + esc(gte) + '" data-fprice-gte>' +
          '<span>–</span>' +
          '<input type="number" inputmode="decimal" min="0" max="' + g.max + '" placeholder="' + g.max + '" value="' + esc(lte) + '" data-fprice-lte>' +
          '<button type="button" class="dynasort-search__fapply" data-fprice-apply data-gte-param="' + esc(g.param_gte) + '" data-lte-param="' + esc(g.param_lte) + '">' + esc(tt('apply', 'Apply')) + '</button>' +
          '</div>';
      }
      if (!panel) return;
      html += '<div class="dynasort-search__fgroup' + (isOpen ? ' is-open' : '') + (gActive ? ' has-active' : '') + '" data-fgroup="' + esc(g.label) + '">' +
        '<button type="button" class="dynasort-search__fbtn" data-ftoggle>' + esc(g.label) +
        (gActive ? '<span class="dynasort-search__fcountbadge">' + gActive + '</span>' : '') +
        '<span class="dynasort-search__ficon">' + (isOpen ? '−' : '+') + '</span></button>' +
        '<div class="dynasort-search__fpanel"' + (isOpen ? '' : ' hidden') + '>' + panel + '</div>' +
        '</div>';
    });
    html += '<button type="button" class="dynasort-search__fclear" data-fclear' + (activeCount ? '' : ' hidden') + '>' + esc(tt('clear', 'Clear all')) + '</button>';
    filtersEl.innerHTML = html;
    filtersEl.removeAttribute('hidden');
  }

  if (filtersEl) {
    // Bar-layout dropdowns are exclusive: opening one closes the rest, and a
    // click outside the filter bar closes whichever is open. Sidebar groups
    // are independent collapsibles, so both behaviors skip that layout.
    function closeGroup(grp) {
      var panel = grp.querySelector('.dynasort-search__fpanel');
      if (!panel || panel.hasAttribute('hidden')) return;
      openGroups[grp.getAttribute('data-fgroup')] = false;
      panel.setAttribute('hidden', '');
      grp.classList.remove('is-open');
      var icon = grp.querySelector('.dynasort-search__ficon');
      if (icon) icon.textContent = '+';
    }
    function closeOtherGroups(except) {
      if (sidebarActive()) return;
      Array.prototype.forEach.call(filtersEl.querySelectorAll('[data-fgroup]'), function (g) {
        if (g !== except) closeGroup(g);
      });
    }
    document.addEventListener('click', function (e) {
      if (!filtersEl.contains(e.target)) closeOtherGroups(null);
    });
    filtersEl.addEventListener('click', function (e) {
      var tbtn = e.target.closest('[data-ftoggle]');
      if (tbtn) {
        var grp = tbtn.closest('[data-fgroup]');
        var panel = grp.querySelector('.dynasort-search__fpanel');
        var nowOpen = panel.hasAttribute('hidden');
        if (nowOpen) closeOtherGroups(grp);
        openGroups[grp.getAttribute('data-fgroup')] = nowOpen;
        if (nowOpen) panel.removeAttribute('hidden'); else panel.setAttribute('hidden', '');
        grp.classList.toggle('is-open', nowOpen);
        tbtn.querySelector('.dynasort-search__ficon').textContent = nowOpen ? '−' : '+';
        return;
      }
      if (e.target.closest('[data-fclear]')) { writeFilterParams({}); fetchResults(); return; }
      var ab = e.target.closest('[data-fprice-apply]');
      if (ab) {
        var panelEl = ab.closest('.dynasort-search__fpanel');
        var map = filterParamsFromUrl();
        var gv = panelEl.querySelector('[data-fprice-gte]').value.trim();
        var lv = panelEl.querySelector('[data-fprice-lte]').value.trim();
        map[ab.getAttribute('data-gte-param')] = gv !== '' ? [gv] : [];
        map[ab.getAttribute('data-lte-param')] = lv !== '' ? [lv] : [];
        writeFilterParams(map);
        fetchResults();
      }
    });
    filtersEl.addEventListener('change', function (e) {
      var cb = e.target.closest('input[data-fparam]');
      if (!cb) return;
      var param = cb.getAttribute('data-fparam');
      // Single-select (either/or) groups: radio semantics with checkbox
      // visuals. Checking one value replaces the group's selection; unchecking
      // the active one clears the group. Siblings uncheck in the DOM
      // immediately so the swap reads instantly, before the refetch confirms.
      if (cb.getAttribute('data-fsingle')) {
        Array.prototype.forEach.call(filtersEl.querySelectorAll('input[data-fparam="' + param.replace(/"/g, '\\"') + '"]'), function (other) {
          if (other !== cb) other.checked = false;
        });
        var map = filterParamsFromUrl();
        map[param] = cb.checked ? [cb.getAttribute('data-fvalue')] : [];
        writeFilterParams(map);
        fetchResults();
        return;
      }
      toggleFilterValue(param, cb.getAttribute('data-fvalue'), cb.checked);
    });
  }

  function currentFilterString() {
    var seen = {};
    var usp = new URLSearchParams(window.location.search);
    usp.forEach(function (v, k) {
      if (k.indexOf('filter.') !== 0 || v === '') return;
      seen[k] = seen[k] ? seen[k] + ',' + v : v;
    });
    return Object.keys(seen).sort().map(function (k) {
      return encodeURIComponent(k) + '=' + encodeURIComponent(seen[k]);
    }).join('&');
  }
  var currentSort = (new URLSearchParams(window.location.search).get('sort_by')) || '';
  if (currentSort !== 'price-ascending' && currentSort !== 'price-descending') currentSort = '';

  function fetchResults() {
    skeleton();
    var dsf = currentFilterString();
    var url = '/apps/dynasort/search?q=' + encodeURIComponent(query) + '&limit=250&wf=1&_=' + Date.now() +
      (dsSession() ? '&ds_session=' + encodeURIComponent(dsSession()) : '') +
      '&vw=' + (window.innerWidth || 0) +
      (dsf ? '&dsf=' + encodeURIComponent(dsf) : '') +
      (currentSort ? '&sort_by=' + currentSort : '');
    fetch(url, { headers: { 'Accept': 'application/json' }, cache: 'no-store' })
      .then(function (r) { return r.json(); })
      .then(function (data) {
        // Feature-dark responses (no active search recipe, unknown shop) must
        // not paint a "0 results" state over the theme's own results: hide the
        // whole block and leave the page to the theme, same as a fetch failure.
        if (data && data.error && !data.settings) { root.setAttribute('hidden', ''); return; }
        render(data);
        if (sortEl) { sortEl.value = currentSort; sortEl.removeAttribute('hidden'); }
      })
      .catch(function () { root.setAttribute('hidden', ''); });
  }

  if (sortEl) {
    sortEl.addEventListener('change', function () {
      currentSort = sortEl.value;
      var u = new URL(window.location.href);
      if (currentSort) { u.searchParams.set('sort_by', currentSort); } else { u.searchParams.delete('sort_by'); }
      window.history.replaceState({}, '', u.toString());
      fetchResults();
    });
  }

  root.removeAttribute('hidden');
  fetchResults();
  }

  function dsSearchScan() {
    Array.prototype.forEach.call(document.querySelectorAll('[data-dynasort-search]'), dsSearchInit);
  }
  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', dsSearchScan); }
  else { dsSearchScan(); }
})();
