{% comment %}
  Dynasort search results (Chunk S2) — the storefront takeover of the theme's
  search RESULTS page grid. Pilot: hand-add this as sections/dynasort-search-results.liquid
  and point templates/search.json at it instead of the theme's main-search section.

  Model: relevance decides MEMBERSHIP, the recipe decides ORDER. The section reads
  the query server-side ({{ search.terms }}), calls the App Proxy
  (/apps/dynasort/search → proxy-search.php), and renders the recipe-ranked §9
  cards — Shopify's Storefront search supplies the matched set, we reorder + render.

  We OWN the grid + simple client-side pagination over the returned set (≤250),
  plus a sort select and a faceted filter bar/sidebar (groups = the merchant's
  Search & Discovery filter config via Storefront productFilters; layout and
  counts are app-side settings.filters). DEFERRED: collection-scoped search.
  Cards reuse the recommendation card contract (rec_card_from_product) so the
  markup/CSS mirror the recs widget; presentation settings come from the app
  admin (SearchPage.js) on every serve response.
{% endcomment %}

<div
  class="dynasort-search dynasort-search--grid"
  id="ds-search-{{ block.id }}"
  data-dynasort-search
  data-q="{{ search.terms | escape }}"
  data-per-page="{{ block.settings.per_page | default: 24 }}"
  data-t-filter="{{ 'search.filter' | t | escape }}"
  data-t-clear="{{ 'search.clear_all' | t | escape }}"
  data-t-apply="{{ 'search.apply' | t | escape }}"
  data-t-page-of="{{ 'search.page_of' | t | escape }}"
  data-t-results-for="{{ 'search.results_for' | t | escape }}"
  data-t-result-for="{{ 'search.result_for' | t | escape }}"
  data-t-no-results="{{ 'search.no_results' | t | escape }}"
  data-t-added="{{ 'search.added' | t | escape }}"
  style="--ds-cols-mobile: {{ block.settings.columns_mobile | default: 2 }}; --ds-cols-tablet: {{ block.settings.columns_tablet | default: 3 }}; --ds-cols-desktop: {{ block.settings.columns_desktop | default: 4 }};"
  hidden
>
  <div class="dynasort-search__toolbar">
    {% if block.settings.show_summary %}
      <p class="dynasort-search__summary" data-summary hidden></p>
    {% endif %}
    <select class="dynasort-search__sort" data-sort hidden aria-label="{{ 'sort.label' | t }}">
      <option value="">{{ 'sort.relevance' | t }}</option>
      <option value="price-ascending">{{ 'sort.price_ascending' | t }}</option>
      <option value="price-descending">{{ 'sort.price_descending' | t }}</option>
    </select>
  </div>
  <div class="dynasort-search__filters" data-filters hidden></div>
  <div class="dynasort-search__grid" data-grid aria-live="polite"></div>
  <div class="dynasort-search__pager" data-pager hidden>
    <button type="button" class="dynasort-search__page-btn" data-page-prev>&#8249; {{ 'pagination.previous' | t }}</button>
    <span class="dynasort-search__page-info" data-page-info></span>
    <button type="button" class="dynasort-search__page-btn" data-page-next>{{ 'pagination.next' | t }} &#8250;</button>
  </div>
  <p class="dynasort-search__empty" data-empty hidden></p>
</div>

<style>
  /* The hidden ATTRIBUTE loses to any author display rule (the pager's
     display:flex made setAttribute('hidden') a no-op, so the pager showed on
     one-page result sets with both buttons active). Blanket rule so every
     hidden-toggled element in the block actually hides. */
  .dynasort-search[hidden], .dynasort-search [hidden] { display: none !important; }
  /* Dawn-family themes define --color-background as a bare RGB channel
     triplet ("255,255,255"), which is not a valid color; an invalid var()
     computes to transparent, NOT the #fff fallback, leaving filter panels
     and controls see-through. Registering the token as <color> makes any
     unparseable value resolve to the initial white instead. */
  @property --ds-surface { syntax: "<color>"; inherits: true; initial-value: #fff; }
  .dynasort-search {
    --ds-gap: 1em;
    --ds-radius: var(--media-radius, var(--border-radius, 8px));
    --ds-max-width: var(--page-width, 120rem);
    --ds-muted: color-mix(in srgb, currentColor 58%, transparent);
    --ds-border: color-mix(in srgb, currentColor 16%, transparent);
    --ds-surface: var(--color-background, #fff);
    --ds-overlay-bg: rgba(255,255,255,0.85);
    --ds-overlay-text: #1a1a1a;
    --ds-cols-mobile: 2; --ds-cols-tablet: 3; --ds-cols-desktop: 4;
    --ds-aspect: 1 / 1; --ds-swatch-size: 1.5em; --ds-swatch-radius: 50%; --ds-control-radius: 0.4em;
    color: inherit; font-size: inherit;
    margin: 2rem auto; max-width: var(--ds-max-width); box-sizing: border-box;
  }
  .dynasort-search *, .dynasort-search *::before, .dynasort-search *::after { box-sizing: border-box; }
  .dynasort-search[hidden] { display: none; }
  .dynasort-search :where(button) {
    appearance: none; -webkit-appearance: none;
    margin: 0; padding: 0; border: 0; background: none; box-shadow: none;
    font: inherit; color: inherit; line-height: 1; min-width: 0; min-height: 0; width: auto; text-align: inherit; cursor: pointer;
  }
  .dynasort-search__toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1em; margin: 0 0 1em; flex-wrap: wrap; }
  .dynasort-search__summary { font-size: 0.95em; color: var(--ds-muted); margin: 0; }
  .dynasort-search__sort { font: inherit; font-size: 0.9em; color: inherit; background: var(--ds-surface); border: 1px solid var(--ds-border); border-radius: var(--ds-control-radius); padding: 0.45em 0.7em; }
  .dynasort-search__sort[hidden] { display: none; }
  .dynasort-search__grid { display: grid; grid-template-columns: repeat(var(--ds-cols-mobile), minmax(0, 1fr)); gap: var(--ds-gap); }
  @media (min-width: 750px) { .dynasort-search__grid { grid-template-columns: repeat(var(--ds-cols-tablet), minmax(0, 1fr)); } }
  @media (min-width: 990px) { .dynasort-search__grid { grid-template-columns: repeat(var(--ds-cols-desktop), minmax(0, 1fr)); } }

  .dynasort-search__card { position: relative; display: flex; flex-direction: column; color: inherit; min-width: 0; }
  .dynasort-search__media { position: relative; aspect-ratio: var(--ds-aspect); border-radius: var(--ds-radius); overflow: hidden; background: var(--ds-border); }
  .dynasort-search__media-link { display: block; width: 100%; height: 100%; }
  .dynasort-search__media img { width: 100%; height: 100%; object-fit: cover; display: block; }
  .dynasort-search__skeleton { aspect-ratio: var(--ds-aspect); border-radius: var(--ds-radius); background: var(--ds-border); opacity: 0.5; }
  .dynasort-search__badges { position: absolute; top: 0.5em; left: 0.5em; display: flex; gap: 0.3em; flex-wrap: wrap; z-index: 1; }
  .dynasort-search__badge { font-size: 0.72em; padding: 0.15em 0.5em; border-radius: 0.25em; line-height: 1.5; font-weight: 500; }
  .dynasort-search__title { display: block; font-size: 1em; margin: 0.4em 0 0.2em; line-height: 1.3; text-decoration: none; color: inherit; }
  .dynasort-search__colorname { font-size: 0.85em; color: var(--ds-muted); }
  .dynasort-search__price { font-size: 0.98em; margin-top: 0.25em; }
  .dynasort-search__price del { color: var(--ds-muted); margin-right: 0.4em; }
  .dynasort-search__price ins { text-decoration: none; }
  .dynasort-search__rating { font-size: 0.85em; color: var(--ds-muted); margin-top: 0.15em; letter-spacing: 0.05em; }
  .dynasort-search__swatches { display: flex; gap: 0.4em; flex-wrap: wrap; margin-top: 0.6em; align-items: center; }
  .dynasort-search__swatch { width: var(--ds-swatch-size); height: var(--ds-swatch-size); border-radius: var(--ds-swatch-radius); border: 1px solid var(--ds-border); background-size: cover; background-position: center; position: relative; flex: 0 0 auto; }
  .dynasort-search__swatch[aria-pressed="true"] { border: 2px solid currentColor; box-shadow: inset 0 0 0 2px var(--ds-surface); }
  .dynasort-search__swatch--soldout::after { content: ""; position: absolute; inset: 0; background: linear-gradient(to top left, transparent 45%, var(--ds-muted) 46%, var(--ds-muted) 54%, transparent 55%); border-radius: var(--ds-swatch-radius); }
  .dynasort-search__swatch-more { font-size: 0.85em; color: var(--ds-muted); text-decoration: none; margin-left: 0.1em; }

  .dynasort-search__sizes { position: absolute; left: 0.6em; right: 0.6em; bottom: 0.6em; display: flex; flex-wrap: wrap; gap: 0.35em; padding: 0.5em; border-radius: var(--ds-control-radius); background: var(--ds-overlay-bg); backdrop-filter: blur(2px); transform: translateY(calc(100% + 0.6em)); opacity: 0; pointer-events: none; transition: transform 0.15s ease, opacity 0.15s ease; }
  @media (hover: hover) and (pointer: fine) { .dynasort-search__card--inline:hover .dynasort-search__sizes { transform: translateY(0); opacity: 1; pointer-events: auto; } }
  .dynasort-search__size { font-size: 0.82em; padding: 0.3em 0.6em; border: 1px solid var(--ds-overlay-text); border-radius: var(--ds-control-radius); background: transparent; color: var(--ds-overlay-text); }
  .dynasort-search__size:disabled { opacity: 0.45; text-decoration: line-through; cursor: default; }
  .dynasort-search__added { position: absolute; left: 0.6em; right: 0.6em; bottom: 0.6em; padding: 0.5em; text-align: center; font-size: 0.85em; border-radius: var(--ds-control-radius); background: var(--ds-overlay-bg); color: var(--ds-overlay-text); backdrop-filter: blur(2px); transform: translateY(calc(100% + 0.6em)); opacity: 0; pointer-events: none; transition: transform 0.18s ease, opacity 0.18s ease; z-index: 3; }
  .dynasort-search__added.is-shown { transform: translateY(0); opacity: 1; }

  /* Filters: 'bar' layout = a horizontal row of collapsible groups above the
     grid (dropdown panels); 'sidebar' layout = a left column on wide screens.
     Facet groups come from the merchant's Search & Discovery filter config via
     the serve response; which layout renders is the app-side setting. */
  .dynasort-search__filters { display: flex; align-items: flex-start; gap: 0.5em; flex-wrap: wrap; margin: 0 0 1.2em; }
  .dynasort-search__filters-label { font-weight: 600; padding: 0.5em 0; margin-right: 0.4em; }
  .dynasort-search__fgroup { position: relative; }
  .dynasort-search__fbtn {
    display: inline-flex; align-items: center; gap: 0.5em; font-size: 0.92em; line-height: 1.4;
    padding: 0.5em 0.95em; border: 1px solid var(--ds-border); border-radius: 999px;
    background: var(--ds-surface); transition: border-color 0.12s ease, background-color 0.12s ease;
  }
  .dynasort-search__fbtn:hover { border-color: color-mix(in srgb, currentColor 45%, transparent); }
  .dynasort-search__fgroup.is-open .dynasort-search__fbtn { border-color: currentColor; }
  /* A group with applied values reads as selected: doubled border + weight
     (no color inversion: background:currentColor plus color:surface is
     circular on the same element, so it cannot be done theme-agnostically). */
  .dynasort-search__fgroup.has-active .dynasort-search__fbtn { border-color: currentColor; box-shadow: inset 0 0 0 1px currentColor; font-weight: 600; }
  .dynasort-search__fcountbadge { font-size: 0.82em; opacity: 0.75; }
  .dynasort-search__ficon { opacity: 0.7; }
  /* display:block + full-width flex rows: hardened so theme CSS (flexed
     containers, inline labels) can never flatten the value list into a row. */
  .dynasort-search__fpanel { position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; display: block; min-width: 230px; max-height: 320px; overflow-y: auto; background: var(--ds-surface); border: 1px solid var(--ds-border); border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.14); padding: 0.55em; }
  .dynasort-search__fvalue { display: flex !important; width: 100%; align-items: center; gap: 0.6em; padding: 0.45em 0.5em; margin: 0; font-size: 0.92em; cursor: pointer; border-radius: 6px; }
  .dynasort-search__fvalue:hover { background: color-mix(in srgb, currentColor 6%, transparent); }
  .dynasort-search__fvalue input { margin: 0; width: 1.05em; height: 1.05em; accent-color: currentColor; flex: 0 0 auto; }
  .dynasort-search__fvalue span:nth-child(2) { flex: 1 1 auto; min-width: 0; }
  .dynasort-search__fcount { color: var(--ds-muted); font-size: 0.88em; flex: 0 0 auto; }
  .dynasort-search__fprice { display: flex; align-items: center; gap: 0.4em; flex-wrap: wrap; padding: 0.3em; }
  .dynasort-search__fprice input { width: 5.5em; font: inherit; padding: 0.4em 0.55em; border: 1px solid var(--ds-border); border-radius: 8px; background: var(--ds-surface); color: inherit; }
  .dynasort-search__fapply { font-size: 0.88em; font-weight: 600; padding: 0.45em 0.95em; border: 1px solid currentColor; border-radius: 999px; transition: background-color 0.12s ease; }
  .dynasort-search__fapply:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
  .dynasort-search__fclear { font-size: 0.88em; color: var(--ds-muted); text-decoration: underline; text-underline-offset: 2px; padding: 0.5em 0.3em; }
  .dynasort-search__fclear:hover { color: inherit; }
  @media (max-width: 749px) {
    /* Mobile: groups stack as a full-width accordion, panels open in flow. */
    .dynasort-search__fgroup { width: 100%; }
    .dynasort-search__fbtn { width: 100%; justify-content: space-between; }
    .dynasort-search__fpanel { position: static; box-shadow: none; min-width: 0; width: 100%; }
  }
  @media (min-width: 990px) {
    .dynasort-search--sidebar { display: grid; grid-template-columns: 230px minmax(0, 1fr); grid-template-areas: "toolbar toolbar" "aside grid" "aside pager" "aside empty"; column-gap: 2em; }
    .dynasort-search--sidebar .dynasort-search__toolbar { grid-area: toolbar; }
    .dynasort-search--sidebar .dynasort-search__filters { grid-area: aside; display: block; margin: 0; align-self: start; }
    .dynasort-search--sidebar .dynasort-search__grid { grid-area: grid; }
    .dynasort-search--sidebar .dynasort-search__pager { grid-area: pager; }
    .dynasort-search--sidebar .dynasort-search__empty { grid-area: empty; }
    .dynasort-search--sidebar .dynasort-search__filters-label { display: block; margin: 0 0 0.5em; }
    .dynasort-search--sidebar .dynasort-search__fgroup { margin-bottom: 0.6em; padding-bottom: 0.2em; }
    /* Sidebar headers are flat full-width bands (reference: classic left-rail
       faceting), never pills. The is-open/has-active pill rules outrank the
       base override on specificity, so they are re-flattened explicitly. */
    .dynasort-search--sidebar .dynasort-search__fbtn { width: 100%; justify-content: space-between; border: 0; border-radius: 6px; padding: 0.5em 0.65em; font-weight: 600; background: color-mix(in srgb, currentColor 6%, transparent); }
    .dynasort-search--sidebar .dynasort-search__fgroup.is-open .dynasort-search__fbtn,
    .dynasort-search--sidebar .dynasort-search__fgroup.has-active .dynasort-search__fbtn { border: 0; box-shadow: none; }
    .dynasort-search--sidebar .dynasort-search__fpanel { position: static; box-shadow: none; border: 0; padding: 0.2em 0 0; min-width: 0; max-height: none; }
    .dynasort-search--sidebar .dynasort-search__fclear { display: block; padding: 0.4em 0; }
  }

  .dynasort-search__pager { display: flex; align-items: center; justify-content: center; gap: 1.2em; margin-top: 1.8em; }
  .dynasort-search__page-btn { font-size: 0.95em; padding: 0.5em 1em; border: 1px solid var(--ds-border); border-radius: var(--ds-control-radius); }
  .dynasort-search__page-btn:disabled { opacity: 0.35; cursor: default; }
  .dynasort-search__page-info { font-size: 0.9em; color: var(--ds-muted); }
  .dynasort-search__empty { font-size: 1em; color: var(--ds-muted); }

  @media (prefers-reduced-motion: reduce) { .dynasort-search__sizes, .dynasort-search__added { transition: none; } }
</style>

<script>
(function () {
  // Per-instance root via block.id + init-once guard (same multi-instance
  // hazard as the recommendations block; see its comment).
  var root = document.getElementById('ds-search-{{ block.id }}');
  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;

  // 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) {
    if (v == null) return '';
    var m = dsMarket();
    if (m && c && m.active !== c) { v = v * m.rate; c = m.active; }
    try { return new Intl.NumberFormat(undefined, { style: 'currency', currency: c || 'USD' }).format(v); } catch (e) { return '$' + Number(v).toFixed(2); }
  }
  function priceRange(min, max, c) { if (min == null) return ''; return (max != null && max > min) ? money(min, c) + '–' + 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);
    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>';
  }
  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) + '</del>' : '';
    var price = '<div class="dynasort-search__price" data-price>' + priceCmp + '<ins>' + priceRange(card.price_min, card.price_max, currency) + '</ins></div>';

    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' : '');
    // 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>' +
      '<a class="dynasort-search__title" href="' + esc(card.url) + '">' + esc(card.title) + '</a>' +
      colorName + (fld('rating') ? ratingHtml(card) : '') + price + (showSwatches ? swatchesHtml(card) : '') +
      '</div>';
  }

  function ensureImages(cardEl, card) {
    if (cardEl._dsImg) return Promise.resolve(cardEl._dsImg);
    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]');

    // 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) + '</del>' : '';
          priceEl.innerHTML = cmp + '<ins>' + priceRange(s.price_min, s.price_max, currency) + '</ins>';
        }
        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);
      });
    }
    // 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() {
    {% unless block.settings.hide_native_pagination %}return;{% endunless %}
    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 = {};

  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;
    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;
    }
    var sidebar = fset.layout === 'sidebar';
    root.classList.toggle('dynasort-search--sidebar', sidebar);
    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 (root.classList.contains('dynasort-search--sidebar')) 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) {
        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();
})();
</script>

{% schema %}
{
  "target": "section",
  "name": "Dynasort Search Results",
  "settings": [
    { "type": "paragraph", "content": "Replaces the theme's search results grid with Dynasort recipe-ranked results. Shopify still decides which products match; the active Search recipe decides their order. Results per page, column layout, and the filter bar (layout, counts, on/off) are controlled in Dynasort under Search. Filter groups come from Shopify's Search & Discovery app (Filters section)." },
    { "type": "checkbox", "id": "show_summary", "label": "Show results summary", "default": true },
    { "type": "checkbox", "id": "hide_native_pagination", "label": "Hide the theme's own pagination", "info": "Dynasort pages over the full ranked result set itself, so the theme's page links would reload the page without changing the results.", "default": true }
  ]
}
{% endschema %}
