{% comment %}
  Dynasort Recommendations — PILOT theme section for dynasort.myshopify.com.

  Add this file to the dev store's theme as sections/dynasort-recommendations.liquid,
  then add the "Dynasort Recommendations" section to the product (or cart/home/
  collection) template in the theme editor and pick its placement.

  Calls the App Proxy (/apps/dynasort/recommend), which Shopify signs. The proxy
  returns { products:[card], settings, default_heading, multi_vendor, currency }; the
  whole presentation is driven by the per-placement Display settings in the app admin.
  Section settings only declare WHICH placement renders here + a fallback count.

  Prototype for the GA theme app extension block. See
  plans/recommendations-storefront-presentation-spec.md.

  Notes: sizing is in `em` off the inherited theme body (OS 2.0 roots are 62.5%, so
  rem would render tiny). Buttons are hard-reset because host themes style bare
  `button` globally (was bleeding the swatches). Variant-image swatches/swap are
  enriched lazily from /products/{handle}.js.
{% endcomment %}

<div
  class="dynasort-recs"
  id="ds-recs-{{ block.id }}"
  data-dynasort-recs
  data-placement="{{ block.settings.placement }}"
  data-limit="{{ block.settings.count }}"
  data-locale="{{ localization.language.iso_code | default: 'en' }}"
  data-t-preparing="{{ 'recs.preparing' | t | escape }}"
  {% if template contains 'product' and product %}
    data-product-id="{{ product.id }}"
    data-seed-vendor="{{ product.vendor | escape }}"
    data-seed-type="{{ product.type | escape }}"
    data-seed-title="{{ product.title | escape }}"
  {% endif %}
  {% if template contains 'collection' and collection %}data-collection-id="{{ collection.id }}"{% endif %}
>
  <h2 class="dynasort-recs__heading" data-dynasort-recs-heading hidden></h2>
  <div class="dynasort-recs__viewport" data-dynasort-recs-viewport>
    <div class="dynasort-recs__track" data-dynasort-recs-track></div>
    <div class="dynasort-recs__nav" data-dynasort-recs-nav hidden>
      <button type="button" class="dynasort-recs__arrow" data-dir="-1" aria-label="Previous">&#8249;</button>
      <button type="button" class="dynasort-recs__arrow" data-dir="1" aria-label="Next">&#8250;</button>
    </div>
  </div>
</div>

<style>
  @property --ds-surface { syntax: "<color>"; inherits: true; initial-value: #fff; }
  .dynasort-recs {
    --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 is registered via @property above so Dawn-family channel
       triplets in --color-background degrade to white, not transparent. */
    --ds-surface: var(--color-background, #fff);
    --ds-overlay-bg: rgba(255,255,255,0.8);
    --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-recs *, .dynasort-recs *::before, .dynasort-recs *::after { box-sizing: border-box; }
  .dynasort-recs[hidden] { display: none; }

  /* Hard button reset — host themes style bare `button` (min-width/padding/bg),
     which was bleeding the swatches and killing our background-size. `:where()`
     keeps the reset at ZERO-class specificity so our control rules below win. */
  .dynasort-recs :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; letter-spacing: inherit;
    min-width: 0; min-height: 0; width: auto; text-align: inherit; cursor: pointer;
  }

  .dynasort-recs__heading { margin: 0 0 0.8em; font-size: 1.5em; line-height: 1.2; }
  .dynasort-recs__viewport { position: relative; }

  .dynasort-recs--grid .dynasort-recs__track { display: grid; grid-template-columns: repeat(var(--ds-cols-mobile), minmax(0, 1fr)); gap: var(--ds-gap); }
  @media (min-width: 750px) { .dynasort-recs--grid .dynasort-recs__track { grid-template-columns: repeat(var(--ds-cols-tablet), minmax(0, 1fr)); } }
  @media (min-width: 990px) { .dynasort-recs--grid .dynasort-recs__track { grid-template-columns: repeat(var(--ds-cols-desktop), minmax(0, 1fr)); } }

  .dynasort-recs--carousel .dynasort-recs__track { display: flex; gap: var(--ds-gap); overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
  .dynasort-recs--carousel .dynasort-recs__track::-webkit-scrollbar { display: none; }
  .dynasort-recs--carousel .dynasort-recs__card { scroll-snap-align: start; flex: 0 0 var(--ds-card-basis, 70%); }

  .dynasort-recs__nav { display: none; }
  @media (hover: hover) and (pointer: fine) { .dynasort-recs--carousel .dynasort-recs__nav:not([hidden]) { display: block; } }
  .dynasort-recs__arrow { position: absolute; top: 35%; transform: translateY(-50%); width: 2.6em; height: 2.6em; border-radius: 50%; border: 1px solid var(--ds-border); background: var(--ds-surface); font-size: 1em; display: flex; align-items: center; justify-content: center; opacity: 0.95; z-index: 2; }
  .dynasort-recs__arrow[data-dir="-1"] { left: -0.6em; }
  .dynasort-recs__arrow[data-dir="1"] { right: -0.6em; }
  .dynasort-recs__arrow:disabled { opacity: 0.25; cursor: default; }

  .dynasort-recs__card { position: relative; display: flex; flex-direction: column; color: inherit; min-width: 0; }
  .dynasort-recs__media { position: relative; aspect-ratio: var(--ds-aspect); border-radius: var(--ds-radius); overflow: hidden; background: var(--ds-border); }
  .dynasort-recs__media-link { display: block; width: 100%; height: 100%; }
  .dynasort-recs__media img { width: 100%; height: 100%; object-fit: cover; display: block; }
  .dynasort-recs__preparing { padding: 1.4em; text-align: center; color: var(--ds-muted); border: 1px dashed var(--ds-border); border-radius: var(--ds-radius); font-size: 0.95em; }
  .dynasort-recs__badges { position: absolute; top: 0.5em; left: 0.5em; display: flex; gap: 0.3em; flex-wrap: wrap; z-index: 1; }
  .dynasort-recs__badge { font-size: 0.72em; padding: 0.15em 0.5em; border-radius: 0.25em; line-height: 1.5; font-weight: 500; }
  .dynasort-recs__vendor { font-size: 0.82em; color: var(--ds-muted); margin-top: 0.6em; }
  .dynasort-recs__title { display: block; font-size: 1em; margin: 0.2em 0; line-height: 1.3; text-decoration: none; color: inherit; }
  .dynasort-recs__colorname { font-size: 0.85em; color: var(--ds-muted); }
  .dynasort-recs__price { font-size: 0.98em; margin-top: 0.25em; }
  .dynasort-recs__price del { color: var(--ds-muted); margin-right: 0.4em; }
  .dynasort-recs__price ins { text-decoration: none; }
  .dynasort-recs__rating { font-size: 0.85em; color: var(--ds-muted); margin-top: 0.15em; letter-spacing: 0.05em; }

  /* Swatches — shape + size are CSS vars set from settings. Selected ring is INSET
     so it never bleeds past the swatch box (was overflowing the card edge). */
  .dynasort-recs__swatches { display: flex; gap: 0.4em; flex-wrap: wrap; margin-top: 0.6em; align-items: center; }
  .dynasort-recs__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-recs__swatch[aria-pressed="true"] { border: 2px solid currentColor; box-shadow: inset 0 0 0 2px var(--ds-surface); }
  .dynasort-recs__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-recs__swatch-more { font-size: 0.85em; color: var(--ds-muted); text-decoration: none; margin-left: 0.1em; }

  /* Inline add — floating overlay inset from the image edges (Vuori), no reflow.
     Panel bg + text/border colour are configurable. */
  .dynasort-recs__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-recs__card--inline:hover .dynasort-recs__sizes { transform: translateY(0); opacity: 1; pointer-events: auto; } }
  .dynasort-recs__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-recs__size:disabled { opacity: 0.45; text-decoration: line-through; cursor: default; }

  /* Add-to-cart confirmation — localized pill inset over the image bottom. Toggled
     by class (NOT the hidden attr, which display:flex would have overridden). */
  .dynasort-recs__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-recs__added.is-shown { transform: translateY(0); opacity: 1; }

  @media (prefers-reduced-motion: reduce) {
    .dynasort-recs--carousel .dynasort-recs__track { scroll-behavior: auto; }
    .dynasort-recs__sizes, .dynasort-recs__added { transition: none; }
  }
</style>

<script>
(function () {
  // Per-instance root via block.id: multiple recommendation blocks share a page
  // (e.g. home + custom placements), and any first-match selector would bind
  // every script copy to the FIRST block, leaving the rest dark and
  // double-fetching the first. The guard stops duplicate init of the same root.
  var root = document.getElementById('ds-recs-{{ block.id }}');
  if (!root || root.__dsInit) return;
  root.__dsInit = true;

  var track = root.querySelector('[data-dynasort-recs-track]');
  var headingEl = root.querySelector('[data-dynasort-recs-heading]');
  var navEl = root.querySelector('[data-dynasort-recs-nav]');
  var placement = root.getAttribute('data-placement') || 'product-page';
  var limit = root.getAttribute('data-limit') || 8;
  var locale = (root.getAttribute('data-locale') || 'en').toLowerCase();
  var canHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;

  var seed = { Vendor: root.getAttribute('data-seed-vendor') || '', ProductType: root.getAttribute('data-seed-type') || '', Title: root.getAttribute('data-seed-title') || '' };

  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); }

  function pickLocale(map, fallback) {
    map = map || {};
    var base = locale.split('-')[0];
    var raw = map[locale] || map[base] || map['en'] || fallback || '';
    return raw.replace(/\[(Vendor|ProductType|Title)\]/g, function (_, t) { return seed[t] || ''; }).trim();
  }
  function hexToRgba(hex, opacity) {
    hex = (hex || '#ffffff').replace('#', '');
    if (hex.length === 3) hex = hex.split('').map(function (c) { return c + c; }).join('');
    var r = parseInt(hex.substr(0, 2), 16), g = parseInt(hex.substr(2, 2), 16), b = parseInt(hex.substr(4, 2), 16);
    return 'rgba(' + r + ',' + g + ',' + b + ',' + (Math.max(0, Math.min(100, opacity)) / 100) + ')';
  }
  function vendorVisible(settings, multiVendor) { var v = settings.fields.vendor; return v === 'on' || (v === 'auto' && multiVendor); }

  function badgesHtml(card, settings) {
    var b = settings.fields.badges || {}, colors = settings.fields.badge_colors || {}, out = [];
    var labelMaps = (settings.labels && settings.labels.badges) || {};
    function add(key, builtIn, on) {
      if (!on) { return; }
      var c = colors[key] || {};
      var label = pickLocale((labelMaps[key] && labelMaps[key].locales) || {}, builtIn);
      out.push({ label: label, 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-recs__badges">' + out.map(function (x) {
      return '<span class="dynasort-recs__badge" style="background:' + x.bg + ';color:' + x.text + '">' + esc(x.label) + '</span>';
    }).join('') + '</div>';
  }
  function ratingHtml(card, settings) {
    if (!settings.fields.rating || !card.rating) return '';
    var v = Math.round(card.rating.value);
    return '<div class="dynasort-recs__rating">' + ('★★★★★'.slice(0, v) + '☆☆☆☆☆'.slice(0, 5 - v)) + ' (' + card.rating.count + ')</div>';
  }
  function swatchesHtml(card, settings) {
    if (!settings.fields.swatches || !card.swatches || card.swatches.length < 2) return '';
    var hide = settings.behavior.oos_mode === 'hide';
    var imageStyle = settings.fields.swatch_style === 'image';
    var list = card.swatches.filter(function (s) { return !(hide && !s.available); });
    if (list.length < 2) return '';
    var cap = 5, shown = list.slice(0, cap), overflow = list.length - shown.length;
    var dots = shown.map(function (s) {
      var origIdx = card.swatches.indexOf(s);
      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-recs__swatch' + (s.available ? '' : ' dynasort-recs__swatch--soldout');
      return '<button type="button" class="' + cls + '" style="' + bg + '" title="' + esc(s.name) + '" data-swatch="' + origIdx + '" aria-label="' + esc(s.name) + '" aria-pressed="' + (origIdx === 0 ? 'true' : 'false') + '"></button>';
    }).join('');
    var more = overflow > 0 ? '<a class="dynasort-recs__swatch-more" href="' + esc(card.url) + '">+' + overflow + '</a>' : '';
    return '<div class="dynasort-recs__swatches" data-swatches>' + dots + more + '</div>';
  }

  // Theme editor only: a merchant who just added the block sees a preparing
  // note instead of an invisible widget (the precompute may still be running,
  // or no recipe is bound to this placement yet). Shoppers never see it.
  function showPreparing() {
    if (!(window.Shopify && Shopify.designMode)) { root.setAttribute('hidden', ''); return; }
    root.removeAttribute('hidden');
    headingEl.setAttribute('hidden', '');
    track.innerHTML = '<div class="dynasort-recs__preparing">' + esc(root.getAttribute('data-t-preparing') || 'Dynasort is preparing recommendations. This takes a few minutes on first setup. If this stays empty, assign a recipe to this placement under Dynasort > Recommendations.') + '</div>';
  }

  function render(data) {
    var products = data.products || [];
    var settings = data.settings;
    if (!settings) { showPreparing(); return; }
    if (products.length < (settings.layout.min_to_render || 1)) { showPreparing(); return; }

    root.__addedLabel = pickLocale((settings.labels && settings.labels.added && settings.labels.added.locales) || {}, 'Added to cart');
    root.__serveId = data.serve_id || 0;

    var heading = pickLocale(settings.heading && settings.heading.locales, data.default_heading);
    if (heading) { headingEl.textContent = heading; headingEl.removeAttribute('hidden'); }

    var L = settings.layout, carousel = L.format === 'carousel';
    root.classList.remove('dynasort-recs--grid', 'dynasort-recs--carousel');
    root.classList.add(carousel ? 'dynasort-recs--carousel' : 'dynasort-recs--grid');
    var cols = L.columns || {};
    root.style.setProperty('--ds-cols-mobile', cols.mobile || 2);
    root.style.setProperty('--ds-cols-tablet', cols.tablet || 3);
    root.style.setProperty('--ds-cols-desktop', cols.desktop || 4);
    if (carousel) {
      var per = cols.desktop || 4, peek = (L.carousel && L.carousel.peek) ? 0.6 : 0;
      root.style.setProperty('--ds-card-basis', 'calc((100% - ' + (per - 1) + ' * var(--ds-gap)) / ' + (per + peek) + ')');
    }
    root.style.setProperty('--ds-aspect', settings.image.aspect_ratio === 'portrait' ? '4 / 5' : '1 / 1');
    // swatch shape + size; control radius tracks the swatch shape (square swatches
    // -> square buttons, round swatches -> rounded buttons).
    var imageStyle = settings.fields.swatch_style === 'image';
    var square = settings.fields.swatch_shape === 'square';
    root.style.setProperty('--ds-swatch-radius', square ? '0.2em' : '50%');
    root.style.setProperty('--ds-swatch-size', imageStyle ? '2.6em' : '1.5em');
    root.style.setProperty('--ds-control-radius', square ? '0' : '0.4em');
    // size-picker / added overlay bg + text colour
    root.style.setProperty('--ds-overlay-bg', hexToRgba(settings.behavior.add_overlay_color, settings.behavior.add_overlay_opacity));
    root.style.setProperty('--ds-overlay-text', settings.behavior.add_overlay_text || '#1a1a1a');

    var showVendor = vendorVisible(settings, data.multi_vendor);
    var showColorName = settings.fields.color_name;
    var showCompare = settings.fields.compare_at;
    var inlineAdd = settings.fields.inline_add && canHover;
    var currency = data.currency;

    track.innerHTML = products.map(function (card, cardIdx) {
      var img = card.featured_image ? '<img src="' + esc(card.featured_image) + '" alt="' + esc(card.title) + '" loading="lazy" data-main-img>' : '';
      var vendor = (showVendor && card.vendor) ? '<div class="dynasort-recs__vendor">' + esc(card.vendor) + '</div>' : '';
      var colorName = (showColorName && card.swatches && card.swatches.length) ? '<div class="dynasort-recs__colorname" data-colorname>' + esc(card.swatches[0].name) + '</div>' : '';
      var priceCmp = (showCompare && card.compare_at_min != null) ? '<del>' + money(card.compare_at_min, currency) + '</del>' : '';
      var price = '<div class="dynasort-recs__price" data-price>' + priceCmp + '<ins>' + priceRange(card.price_min, card.price_max, currency) + '</ins></div>';

      var inlineOk = inlineAdd && card.axis_count >= 1 && card.axis_count <= 2;
      var sizes = '';
      if (inlineOk) {
        var hideOos = settings.behavior.oos_mode === 'hide';
        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 '';
          if (hideOos && !v.available) 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-recs__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-recs__sizes" data-sizes>' + btns + '</div>';
      }

      var cardCls = 'dynasort-recs__card' + (sizes ? ' dynasort-recs__card--inline' : '');
      return '<div class="' + cardCls + '" data-card data-handle="' + esc(card.handle) + '" data-pid="' + esc(card.id) + '" data-pos="' + (cardIdx + 1) + '" data-visual-axis="' + card.visual_axis + '" data-swatch-style="' + (imageStyle ? 'image' : 'color') + '">' +
        '<div class="dynasort-recs__media">' +
          '<a class="dynasort-recs__media-link" href="' + esc(card.url) + '">' + img + '</a>' +
          badgesHtml(card, settings) + sizes +
          '<div class="dynasort-recs__added" data-added></div>' +
        '</div>' +
        vendor +
        '<a class="dynasort-recs__title" href="' + esc(card.url) + '">' + esc(card.title) + '</a>' +
        colorName + ratingHtml(card, settings) + price + swatchesHtml(card, settings) +
        '</div>';
    }).join('');

    Array.prototype.forEach.call(track.querySelectorAll('[data-card]'), function (cardEl, idx) { wireCard(cardEl, products[idx], settings, currency, imageStyle); });
    if (carousel) wireCarousel(L);
  }

  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 map = {}, ai = (card.visual_axis || 1) - 1;
        (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, settings, currency, imageStyle) {
    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 showCompare = settings.fields.compare_at;

    // image-style swatches: enrich dot backgrounds with real per-variant images
    if (imageStyle && swatchWrap) {
      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-recs__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 = (showCompare && 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 = root.__addedLabel || 'Added to cart'; added.classList.add('is-shown'); setTimeout(function () { added.classList.remove('is-shown'); }, 2200); }
        cxBeacon('inline_add', cardEl);
        refreshCartUi();
      }).catch(function () {});
  }

  function wireCarousel(L) {
    navEl.removeAttribute('hidden');
    var prev = navEl.querySelector('[data-dir="-1"]'), next = navEl.querySelector('[data-dir="1"]');
    function step(dir) {
      var card = track.querySelector('.dynasort-recs__card');
      var w = card ? card.getBoundingClientRect().width : track.clientWidth;
      var adv = (L.carousel && L.carousel.advance) || 1;
      track.scrollBy({ left: dir * (w + parseFloat(getComputedStyle(track).gap || 16)) * adv, behavior: 'smooth' });
    }
    navEl.addEventListener('click', function (e) { var a = e.target.closest('[data-dir]'); if (a) step(parseInt(a.getAttribute('data-dir'), 10)); });
    function sync() { prev.disabled = track.scrollLeft <= 2; next.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2; }
    track.addEventListener('scroll', sync); sync();
  }

  // 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.
  function dsSession() {
    var m = document.cookie.match(/(?:^|; )mmm_cookies=([^;]+)/);
    return m ? decodeURIComponent(m[1]) : '';
  }
  function cxBeacon(evt, cardEl) {
    var sid = root.__serveId;
    if (!sid || !cardEl) return;
    var url = '/apps/dynasort/recommend?service=cx&serve_id=' + encodeURIComponent(sid) + '&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 () {}); }
  }

  function buildUrl(extra) {
    var sess = dsSession();
    return '/apps/dynasort/recommend?placement=' + encodeURIComponent(placement) + '&limit=' + encodeURIComponent(limit) + '&_=' + Date.now() +
      (sess ? '&ds_session=' + encodeURIComponent(sess) : '') +
      '&vw=' + (window.innerWidth || 0) + (extra || '');
  }
  function fetchRecs(extra) {
    fetch(buildUrl(extra), { headers: { 'Accept': 'application/json' }, cache: 'no-store' })
      .then(function (r) { return r.json(); }).then(function (data) { render(data); })
      .catch(function () { showPreparing(); });
  }

  if (placement === 'cart') {
    fetch('/cart.js', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (cart) {
      var ids = (cart.items || []).map(function (i) { return i.product_id; });
      fetchRecs(ids.length ? '&product_ids=' + ids.join(',') : '');
    }).catch(function () { fetchRecs(''); });
  } else {
    var pid = root.getAttribute('data-product-id');
    var cid = root.getAttribute('data-collection-id');
    fetchRecs((pid ? '&product_id=' + pid : '') + (cid ? '&collection_id=' + cid : ''));
  }
})();
</script>

{% schema %}
{
  "target": "section",
  "name": "Dynasort Recommendations",
  "settings": [
    { "type": "paragraph", "content": "Card fields, layout, heading, swatches, and behavior are configured per placement in the Dynasort app (Recommendations page). This block only sets which placement renders here." },
    {
      "type": "select", "id": "placement", "label": "Placement", "default": "product-page",
      "options": [
        { "value": "product-page", "label": "Product page" },
        { "value": "cart", "label": "Cart" },
        { "value": "collection", "label": "Collection page" },
        { "value": "home", "label": "Home page" },
        { "value": "custom-1", "label": "Custom Placement 1" },
        { "value": "custom-2", "label": "Custom Placement 2" },
        { "value": "custom-3", "label": "Custom Placement 3" },
        { "value": "custom-4", "label": "Custom Placement 4" },
        { "value": "custom-5", "label": "Custom Placement 5" }
      ]
    },
    { "type": "range", "id": "count", "label": "Max products to request", "min": 2, "max": 20, "step": 1, "default": 8 }
  ]
}
{% endschema %}
