{% comment %}
  SNIPPET version of the Dynasort predictive/live-search takeover (Chunk S2 #3).
  Use this when the section can't be placed globally (Horizon's header/footer are
  locked section groups). Put this file at snippets/dynasort-live-search.liquid and
  render it once from theme.liquid (just before </body>) so it loads on every page:

    {% render 'dynasort-live-search',
        input_selector: 'input[type="search"], input[name="q"]',
        suppress_selector: '' %}

  Params (all optional, sensible defaults):
    input_selector     CSS selector for the theme's search field(s)
    suppress_selector  CSS selector of the theme's native predictive dropdown to hide
    min_chars          minimum chars before querying (default 2)
    max_results        suggestions to show (default 6)
    debounce_ms        keystroke debounce (default 250)

  It attaches to the search input(s) and, on each debounced keystroke, calls the App
  Proxy in predictive mode and renders OUR recipe-reordered suggestions. Enter /
  submit still posts to /search → the results-page section takes over.
{% endcomment %}

{% liquid
  assign ds_input = block.settings.input_selector | default: 'input[type="search"], input[name="q"]'
  assign ds_suppress = block.settings.suppress_selector | default: ''
  assign ds_min = block.settings.min_chars | default: 2
  assign ds_limit = block.settings.max_results | default: 6
  assign ds_debounce = block.settings.debounce_ms | default: 250
  assign ds_vendor = block.settings.show_vendor | default: false
%}

<div
  data-dynasort-predict
  data-input-selector="{{ ds_input | escape }}"
  data-suppress-selector="{{ ds_suppress | escape }}"
  data-min-chars="{{ ds_min }}"
  data-limit="{{ ds_limit }}"
  data-debounce="{{ ds_debounce }}"
  data-show-vendor="{{ ds_vendor }}"
  data-t-suggestions="{{ 'predictive.suggestions' | t | escape }}"
  data-t-collections="{{ 'predictive.collections' | t | escape }}"
  data-t-pages="{{ 'predictive.pages' | t | escape }}"
  data-t-products="{{ 'predictive.products' | t | escape }}"
  data-t-no-suggestions="{{ 'predictive.no_suggestions' | t | escape }}"
  data-t-see-all="{{ 'predictive.see_all' | t | escape }}"
  data-t-see-all-for="{{ 'predictive.see_all_for' | t | escape }}"
  data-t-no-products="{{ 'predictive.no_products' | t | escape }}"
  hidden
></div>

<style>
  .dynasort-predict-panel {
    position: fixed; z-index: 9999; color: #1a1a1a;
    background: #ffffff;
    border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
    border-radius: var(--media-radius, 8px); box-shadow: 0 8px 28px rgba(0,0,0,0.14);
    overflow: hidden; font-size: 14px; max-height: 70vh; overflow-y: auto;
    container-type: inline-size;
  }
  .dynasort-predict-heading { padding: 0.6em 0.8em 0.15em; font-size: 0.78em; text-transform: uppercase; letter-spacing: 0.06em; color: color-mix(in srgb, currentColor 55%, transparent); }
  .dynasort-predict-panel mark { background: color-mix(in srgb, currentColor 14%, transparent); color: inherit; font-weight: 600; border-radius: 2px; }
  /* Collections region: positions come from the app's search settings
     (settings.predictive), stamped as data-desktop / data-mobile on the grid.
     Narrow panels (mobile inputs) stack; wide panels show true columns. The
     container query keys off the PANEL's width (it tracks the input), not the
     viewport; browsers without container-query support keep the stacked layout
     with collections on top. */
  .dynasort-predict-grid { display: flex; flex-direction: column; }
  .dynasort-predict-cols { border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
  @container (width < 440px) {
    .dynasort-predict-grid[data-mobile="hide"] .dynasort-predict-cols { display: none; }
    .dynasort-predict-grid[data-mobile="bottom"] .dynasort-predict-cols { order: 2; border-bottom: 0; border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
  }
  @container (width >= 440px) {
    .dynasort-predict-grid { flex-direction: row; }
    .dynasort-predict-grid .dynasort-predict-cols { flex: 0 0 168px; min-width: 0; border-bottom: 0; border-right: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
    .dynasort-predict-grid .dynasort-predict-prods { flex: 1 1 auto; min-width: 0; }
    .dynasort-predict-grid[data-desktop="right"] .dynasort-predict-cols { order: 2; border-right: 0; border-left: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
  }
  .dynasort-predict-panel[hidden] { display: none; }
  /* In-flow mode: inside a search modal (Horizon family) the panel joins the
     modal's own layout instead of floating, so its geometry can never fight
     the dialog's transforms, top layer, or overflow. */
  .dynasort-predict-panel--inflow { position: static; box-shadow: none; border: 0; border-radius: 0; max-height: none; }
  .dynasort-predict-item { display: flex; align-items: center; gap: 0.75em; padding: 0.55em 0.8em; text-decoration: none; color: inherit; }
  .dynasort-predict-item:hover, .dynasort-predict-item.is-active { background: color-mix(in srgb, currentColor 7%, transparent); }
  .dynasort-predict-thumb { width: 44px; height: 44px; flex: 0 0 auto; object-fit: cover; border-radius: 6px; background: color-mix(in srgb, currentColor 12%, transparent); }
  .dynasort-predict-meta { min-width: 0; }
  .dynasort-predict-title { display: block; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .dynasort-predict-price { font-size: 0.88em; color: color-mix(in srgb, currentColor 60%, transparent); }
  .dynasort-predict-price del { margin-right: 0.35em; }
  .dynasort-predict-vendor { display: block; font-size: 0.82em; color: color-mix(in srgb, currentColor 55%, transparent); }
  .dynasort-predict-all { display: block; padding: 0.6em 0.8em; text-align: center; font-size: 0.9em; text-decoration: none; color: inherit; border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
  .dynasort-predict-empty { padding: 0.8em; color: color-mix(in srgb, currentColor 60%, transparent); }
</style>

<script>
(function () {
  var cfg = document.currentScript.closest('[data-dynasort-predict]') || document.querySelector('[data-dynasort-predict]');
  if (!cfg || window.__dynasortPredictInit) return;
  window.__dynasortPredictInit = true;

  var inputSelector = cfg.getAttribute('data-input-selector') || 'input[type="search"], input[name="q"]';
  var suppressSelector = (cfg.getAttribute('data-suppress-selector') || '').trim();
  var minChars = parseInt(cfg.getAttribute('data-min-chars'), 10) || 2;
  var limit = parseInt(cfg.getAttribute('data-limit'), 10) || 6;
  var debounceMs = parseInt(cfg.getAttribute('data-debounce'), 10) || 250;
  var showVendor = cfg.getAttribute('data-show-vendor') === 'true';

  // Storefront-language strings, rendered per shopper locale by liquid into
  // data attributes (extension locales/). [q] is replaced at render time.
  function tt(k, fb) { var v = cfg.getAttribute('data-t-' + k); return (v != null && v !== '') ? v : fb; }
  // Headings: the merchant's custom text (app settings) wins, but while a
  // heading still holds the shipped English default, the shopper's storefront
  // language applies instead. An empty string keeps meaning "hide".
  function heading(custom, defEn, key) {
    if (typeof custom === 'string' && custom !== defEn) return custom;
    return tt(key, defEn);
  }

  var suppressStyle = null;
  if (suppressSelector) {
    suppressStyle = document.createElement('style');
    suppressStyle.textContent = suppressSelector + '{ display: none !important; }';
    document.head.appendChild(suppressStyle);
  }

  // Feature-dark kill switch: the proxy answers with an error field when search
  // is off for the store (no active search recipe, unknown shop). The block
  // must then vanish entirely for this page load: stop querying, hide the
  // panel, and give the theme its native predictive dropdown back.
  var dsDisabled = false;
  function dsShutOff() {
    dsDisabled = true;
    clearTimeout(debounceTimer);
    hide();
    if (suppressStyle && suppressStyle.parentNode) {
      suppressStyle.parentNode.removeChild(suppressStyle);
      suppressStyle = null;
    }
  }

  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]); }); }
  // Highlight the typed terms inside a suggestion label. Works on the
  // HTML-escaped text, with the terms escaped the same way first so both
  // sides match; regex metacharacters are then neutralized.
  function hi(text, q) {
    var t = esc(text);
    var terms = String(q || '').trim().split(/\s+/).filter(Boolean).map(function (w) { return esc(w).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); });
    if (!terms.length) return t;
    try { return t.replace(new RegExp('(' + terms.join('|') + ')', 'gi'), '<mark>$1</mark>'); } catch (e) { return t; }
  }
  // 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;
  }

  var panel = document.createElement('div');
  panel.className = 'dynasort-predict-panel';
  panel.setAttribute('hidden', '');
  document.body.appendChild(panel);

  var activeInput = null, items = [], cursor = -1, lastReq = 0, debounceTimer = null;

  // Native <dialog> search modals (Horizon family) render in the browser top
  // layer, which sits above any z-index on body, and their open animations
  // add transforms that hijack position:fixed. Inside an open dialog the
  // panel therefore joins the modal's own layout (in-flow, static), anchored
  // where the suppressed native results sit. On plain pages (Dawn dropdowns)
  // it stays a floating fixed panel on body. Returns true when in-flow.
  function ensurePlacement() {
    var dlg = activeInput && activeInput.closest ? activeInput.closest('dialog') : null;
    if (dlg && dlg.open) {
      var anchor = suppressSelector ? dlg.querySelector(suppressSelector) : null;
      var target = anchor ? anchor.parentNode : (activeInput.form || dlg);
      if (panel.parentNode !== target || (anchor && panel.previousElementSibling !== anchor)) {
        if (anchor) { anchor.parentNode.insertBefore(panel, anchor.nextSibling); }
        else { target.appendChild(panel); }
      }
      panel.classList.add('dynasort-predict-panel--inflow');
      return true;
    }
    panel.classList.remove('dynasort-predict-panel--inflow');
    if (panel.parentNode !== document.body) document.body.appendChild(panel);
    return false;
  }
  function place() {
    if (!activeInput || panel.classList.contains('dynasort-predict-panel--inflow')) return;
    var r = activeInput.getBoundingClientRect();
    panel.style.left = r.left + 'px';
    panel.style.top = (r.bottom + 4) + 'px';
    panel.style.width = Math.max(260, r.width) + 'px';
    // Transformed/filtered ancestors (dialog open animations) turn position:fixed
    // into ancestor-relative. Measure the drift and subtract it.
    var pr = panel.getBoundingClientRect();
    var dx = pr.left - r.left, dy = pr.top - (r.bottom + 4);
    if (dx || dy) {
      panel.style.left = (r.left - dx) + 'px';
      panel.style.top = (r.bottom + 4 - dy) + 'px';
    }
  }
  function hide() { panel.setAttribute('hidden', ''); cursor = -1; }
  function show() {
    var inflow = ensurePlacement();
    panel.removeAttribute('hidden');
    if (inflow) { panel.style.left = panel.style.top = panel.style.width = ''; }
    else { place(); }
  }

  function render(data, q) {
    items = data.products || [];
    // Suggestions column (query suggestions + collections + pages): presence,
    // counts, and headings are decided app-side (SearchPage
    // settings.predictive) and arrive with every response.
    var pset = (data.settings && data.settings.predictive) || {};
    var sugg = (pset.suggestions === false) ? [] : (data.suggestions || []);
    var cols = (pset.collections === false) ? [] : (data.collections || []);
    var pages = (pset.pages === false) ? [] : (data.pages || []);
    var hasSide = sugg.length || cols.length || pages.length;
    var cur = data.currency;
    var seeAllFor = esc(tt('see-all-for', 'See all results for “[q]”')).replace('[q]', esc(q));
    if (!items.length && !hasSide) {
      panel.innerHTML = '<div class="dynasort-predict-empty">' + esc(tt('no-suggestions', 'No suggestions for “[q]”.')).replace('[q]', esc(q)) + '</div>' +
        '<a class="dynasort-predict-all" href="/search?q=' + encodeURIComponent(q) + '&type=product">' + esc(tt('see-all', 'See all results')) + '</a>';
      show(); return;
    }
    var root = (window.Shopify && window.Shopify.routes && window.Shopify.routes.root) || '/';
    function section(h, itemsHtml) {
      if (!itemsHtml) return '';
      return (h ? '<div class="dynasort-predict-heading">' + esc(h) + '</div>' : '') + itemsHtml;
    }
    var prodsHtml = items.map(function (c, i) {
      var img = c.featured_image ? '<img class="dynasort-predict-thumb" src="' + esc(c.featured_image) + '" alt="" loading="lazy">' : '<span class="dynasort-predict-thumb"></span>';
      var cmp = (c.compare_at_min != null) ? '<del>' + money(c.compare_at_min, cur, true) + '</del>' : '';
      var vend = (showVendor && c.vendor) ? '<span class="dynasort-predict-vendor">' + esc(c.vendor) + '</span>' : '';
      // MAP-style hiding (card.price_reveal): the dropdown shows the reveal label
      // in place of the price; the PDP handles the actual reveal interaction.
      var priceHtml = c.price_reveal
        ? esc(c.price_reveal.text || 'Click to Reveal Price')
        : cmp + money(c.price_min, cur);
      return '<a class="dynasort-predict-item" href="' + esc(c.url) + '" data-idx="' + i + '">' + img +
        '<span class="dynasort-predict-meta"><span class="dynasort-predict-title">' + esc(c.title) + '</span>' +
        '<span class="dynasort-predict-price">' + priceHtml + '</span>' + vend + '</span></a>';
    }).join('') || '<div class="dynasort-predict-empty">' + esc(tt('no-products', 'No product matches.')) + '</div>';
    if (hasSide) {
      // Two-region layout: suggestions column + products. All column links
      // reuse .dynasort-predict-item so arrow-key navigation walks every list
      // in DOM order. data-desktop/data-mobile drive the position CSS.
      var suggHtml = sugg.map(function (s) {
        return '<a class="dynasort-predict-item" href="' + esc(root + 'search?q=' + encodeURIComponent(s.text) + '&type=product') + '">' +
          '<span class="dynasort-predict-meta"><span class="dynasort-predict-title">' + hi(s.text, q) + '</span></span></a>';
      }).join('');
      var colsHtml = cols.map(function (c) {
        var img = c.image ? '<img class="dynasort-predict-thumb" src="' + esc(c.image) + '" alt="" loading="lazy">' : '';
        return '<a class="dynasort-predict-item" href="' + esc(root + 'collections/' + c.handle) + '">' + img +
          '<span class="dynasort-predict-meta"><span class="dynasort-predict-title">' + hi(c.title, q) + '</span></span></a>';
      }).join('');
      var pagesHtml = pages.map(function (p) {
        return '<a class="dynasort-predict-item" href="' + esc(root + 'pages/' + p.handle) + '">' +
          '<span class="dynasort-predict-meta"><span class="dynasort-predict-title">' + hi(p.title, q) + '</span></span></a>';
      }).join('');
      var prodsHeading = heading(pset.products_heading, 'Products', 'products');
      panel.innerHTML = '<div class="dynasort-predict-grid" data-desktop="' + (pset.position_desktop === 'right' ? 'right' : 'left') + '" data-mobile="' + (pset.position_mobile === 'top' || pset.position_mobile === 'bottom' ? pset.position_mobile : 'hide') + '">' +
        '<div class="dynasort-predict-cols">' +
          section(heading(pset.suggestions_heading, 'Suggestions', 'suggestions'), suggHtml) +
          section(heading(pset.collections_heading, 'Collections', 'collections'), colsHtml) +
          section(heading(pset.pages_heading, 'Pages', 'pages'), pagesHtml) +
        '</div>' +
        '<div class="dynasort-predict-prods">' + (prodsHeading ? '<div class="dynasort-predict-heading">' + esc(prodsHeading) + '</div>' : '') + prodsHtml + '</div>' +
        '</div>' +
        '<a class="dynasort-predict-all" href="/search?q=' + encodeURIComponent(q) + '&type=product">' + seeAllFor + '</a>';
    } else {
      panel.innerHTML = prodsHtml +
        '<a class="dynasort-predict-all" href="/search?q=' + encodeURIComponent(q) + '&type=product">' + seeAllFor + '</a>';
    }
    cursor = -1;
    show();
  }

  function dsSession() {
    var m = document.cookie.match(/(?:^|;\s*)mmm_cookies=([^;]+)/);
    return m ? decodeURIComponent(m[1]) : '';
  }

  function query(q) {
    var reqId = ++lastReq;
    // ds_session lets an A/B-tested store serve this session's assigned variant
    // in the dropdown too (predictive is never serve-logged either way).
    // cl=6 announces this block can render the collections column; whether it
    // actually appears (and how) is the app-side settings' call.
    fetch('/apps/dynasort/search?mode=predictive&limit=' + limit + '&cl=6&q=' + encodeURIComponent(q) + '&_=' + Date.now() + (dsSession() ? '&ds_session=' + encodeURIComponent(dsSession()) : ''), { headers: { 'Accept': 'application/json' }, cache: 'no-store' })
      .then(function (r) { return r.json(); })
      .then(function (data) {
        // Dark states only (no settings key): no_matches also carries an error
        // field but includes settings, and must still render the empty state.
        if (data && data.error && !data.settings) { dsShutOff(); return; }
        if (reqId === lastReq && activeInput && activeInput.value.trim().length >= minChars) render(data, q);
      })
      .catch(function () {});
  }

  function onInput(e) {
    if (dsDisabled) return;
    activeInput = e.target;
    var q = activeInput.value.trim();
    clearTimeout(debounceTimer);
    if (q.length < minChars) { hide(); return; }
    debounceTimer = setTimeout(function () { query(q); }, debounceMs);
  }

  // VISIBLE items only: the mobile-hidden collections links stay in the DOM
  // (display:none via container query), and arrow keys must not land on them.
  function navItems() {
    return Array.prototype.filter.call(panel.querySelectorAll('.dynasort-predict-item'), function (el) { return el.getClientRects().length > 0; });
  }

  function moveCursor(d) {
    var links = navItems();
    if (!links.length) return;
    if (cursor >= 0 && links[cursor]) links[cursor].classList.remove('is-active');
    cursor = (cursor + d + links.length) % links.length;
    links[cursor].classList.add('is-active');
    links[cursor].scrollIntoView({ block: 'nearest' });
  }

  function onKey(e) {
    if (panel.hasAttribute('hidden')) return;
    // stopPropagation keeps the theme's own (suppressed) predictive component
    // from double-handling the same keys against its hidden results.
    if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); moveCursor(1); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); moveCursor(-1); }
    else if (e.key === 'Enter') {
      var links = navItems();
      if (cursor >= 0 && links[cursor]) { e.preventDefault(); e.stopPropagation(); window.location.href = links[cursor].getAttribute('href'); }
    } else if (e.key === 'Escape') { hide(); e.stopPropagation(); }
  }

  function attach(input) {
    if (input.__dsPredict) return;
    input.__dsPredict = true;
    input.setAttribute('autocomplete', 'off');
    input.addEventListener('input', onInput);
    input.addEventListener('focus', function (e) { activeInput = e.target; if (e.target.value.trim().length >= minChars) onInput(e); });
    input.addEventListener('keydown', onKey);
  }

  function scan() { Array.prototype.forEach.call(document.querySelectorAll(inputSelector), attach); }

  document.addEventListener('click', function (e) { if (!panel.contains(e.target) && e.target !== activeInput) hide(); });
  window.addEventListener('scroll', function () { if (!panel.hasAttribute('hidden')) place(); }, true);
  window.addEventListener('resize', function () { if (!panel.hasAttribute('hidden')) place(); });
  // dialog close doesn't bubble; capture-phase catches any search modal closing.
  document.addEventListener('close', function () { hide(); }, true);

  scan();
  if (window.MutationObserver) {
    var mo = new MutationObserver(function () { scan(); });
    mo.observe(document.body, { childList: true, subtree: true });
  }
})();
</script>

{% schema %}
{
  "name": "Dynasort Live Search",
  "target": "body",
  "settings": [
    { "type": "paragraph", "content": "Re-ranks the search suggestions dropdown using your active Dynasort search recipe. Selector values for every free Shopify theme: [Live Search theme selectors](https://docs.dynasort.io/search-re-ranking/live-search-selectors/)" },
    { "type": "text", "id": "input_selector", "label": "Search input selector", "default": "input[type=\"search\"], input[name=\"q\"]", "info": "The default matches nearly every theme. [Learn more](https://docs.dynasort.io/search-re-ranking/live-search-selectors/)" },
    { "type": "text", "id": "suppress_selector", "label": "Native dropdown selector to hide", "info": "Dawn family: #predictive-search-results. Horizon family: #search-modal .predictive-search-form__content. [All themes](https://docs.dynasort.io/search-re-ranking/live-search-selectors/)" },
    { "type": "checkbox", "id": "show_vendor", "label": "Show product vendor", "default": false, "info": "Adds the vendor under each product's price, like themes that show the brand in their own dropdown." },
    { "type": "range", "id": "min_chars", "label": "Minimum characters", "min": 1, "max": 5, "default": 2 },
    { "type": "range", "id": "max_results", "label": "Suggestions shown", "min": 3, "max": 10, "default": 6 },
    { "type": "range", "id": "debounce_ms", "label": "Keystroke debounce (ms)", "min": 100, "max": 600, "step": 50, "default": 250 },
    { "type": "paragraph", "content": "The suggestions column (query suggestions, collections, pages: on/off, position, headings) is configured in the Dynasort app under Search." }
  ]
}
{% endschema %}
