;(function($) {
    'use strict';

    var config = window.aieoRecommendationsConfig || {};
    var bundleConsumed = false;
    var rvConsumed = false;
    var legacyLoaded = {};

    // ---------------------------------------------------------------------
    // Carousel progress bar — a persistent track + thumb under each reco
    // carousel (the native scrollbar is an overlay that auto-hides). Thumb
    // width = visible/total cards; thumb position = scroll progress. The bar
    // is mobile-only via CSS (.aieo-reco-progress).
    // ---------------------------------------------------------------------
    function aieoRecoProgress(grid) {
        if (grid.__aieoProgress) { return; }
        grid.__aieoProgress = true;
        var bar = document.createElement('div');
        bar.className = 'aieo-reco-progress';
        var thumb = document.createElement('div');
        thumb.className = 'aieo-reco-progress-thumb';
        bar.appendChild(thumb);
        grid.parentNode.insertBefore(bar, grid.nextSibling);
        function update() {
            var sw = grid.scrollWidth, cw = grid.clientWidth;
            if (sw <= cw + 2) { bar.style.visibility = 'hidden'; return; }
            bar.style.visibility = '';
            var frac = cw / sw;
            thumb.style.width = (frac * 100) + '%';
            var maxScroll = sw - cw;
            var pos = maxScroll > 0 ? (grid.scrollLeft / maxScroll) : 0;
            thumb.style.left = (pos * (100 - frac * 100)) + '%';
        }
        grid.addEventListener('scroll', update, { passive: true });
        window.addEventListener('resize', update);
        update();
        setTimeout(update, 400);
        setTimeout(update, 1200);
    }
    function aieoScanProgress() {
        var grids = document.querySelectorAll('.aieo-recommendations .aieo-recommendations-grid');
        for (var i = 0; i < grids.length; i++) { aieoRecoProgress(grids[i]); }
    }
    document.addEventListener('aieo:reco:rendered', aieoScanProgress);
    if (document.readyState !== 'loading') { aieoScanProgress(); }
    else { document.addEventListener('DOMContentLoaded', aieoScanProgress); }
    (function () { var n = 0, iv = setInterval(function () { aieoScanProgress(); if (++n > 12) { clearInterval(iv); } }, 1000); })();

    // Section titles for the product-page rendering. Keyed by the
    // bundle-section name so the recently-viewed strip and the four
    // bundle sections all flow through the same renderer.
    var SECTION_TITLES = (window.aieoRecommendationsConfig && window.aieoRecommendationsConfig.sectionTitles) || {
        upsells:        'Up-sell Recommendations',
        crosssells:     'Cross-sell Recommendations',
        related:        'Related Products Recommendations',
        same_needs:     'Products for the Same Needs',
        recently_viewed:'Recently Viewed Products Recommendations'
    };

    // ---------------------------------------------------------------------
    // Shared bundle fetcher — prevents duplicate AJAX when lazy-recs AND
    // the DMM side-cart both want the same product's bundle at once.
    //
    // Architecture:
    //   - In-flight Promise dedup keyed by productId.
    //   - localStorage mirror ('aieo_bundle_last_v9', 15-min TTL, per-pid):
    //     warm-page loads skip the network entirely.
    //
    // Cache key bumped to _v2 because the bundle now ships DTO arrays
    // instead of pre-rendered HTML strings — old blobs would crash the
    // new renderer.
    // ---------------------------------------------------------------------
    if (!window.aieoBundleFetcher) {
        window.aieoBundleFetcher = (function () {
            var inflight = {};
            var LS_KEY   = 'aieo_bundle_last_v12';
            try { console.log('%c[AIEO recos] hydrator=lazy-recommendations-v7.js  build=descrating-2026-06-07  bundleKey=' + LS_KEY, 'color:#0a7d2c;font-weight:bold'); } catch (e) {}
            var TTL_MS   = 15 * 60 * 1000;

            function readCached(pid) {
                try {
                    var raw = window.localStorage.getItem(LS_KEY);
                    if (!raw) return null;
                    var blob = JSON.parse(raw);
                    if (!blob || !blob.ts || !blob.data) return null;
                    if ((Date.now() - blob.ts) > TTL_MS) return null;
                    if (blob.productId !== pid) return null;
                    return blob.data;
                } catch (e) { return null; }
            }
            function writeCached(pid, data) {
                try {
                    window.localStorage.setItem(LS_KEY, JSON.stringify({
                        productId: pid, data: data, ts: Date.now()
                    }));
                } catch (e) { /* quota / private mode — ignore */ }
            }
            function currentAjaxUrl() {
                return (window.aieoRecommendationsConfig && window.aieoRecommendationsConfig.ajaxUrl)
                    || (window.aieoSideCart && window.aieoSideCart.ajaxurl)
                    || '/wp-admin/admin-ajax.php';
            }
            function currentNonce() {
                return (window.aieoRecommendationsConfig && window.aieoRecommendationsConfig.nonce)
                    || (window.aieoSideCart && window.aieoSideCart.recommendationsNonce)
                    || '';
            }
            function fetch(productId) {
                productId = parseInt(productId, 10) || 0;
                var cached = readCached(productId);
                if (cached) { return $.Deferred().resolve(cached).promise(); }
                if (inflight[productId]) { return inflight[productId]; }
                var d = $.Deferred();
                inflight[productId] = d.promise();
                $.ajax({
                    url: currentAjaxUrl(),
                    type: 'POST',
                    data: {
                        action: 'aieo_get_product_bundle',
                        nonce:  currentNonce(),
                        product_id: productId
                    }
                }).done(function (resp) {
                    delete inflight[productId];
                    if (resp && resp.success && resp.data) {
                        writeCached(productId, resp.data);
                        d.resolve(resp.data);
                    } else {
                        d.resolve(null);
                    }
                }).fail(function () {
                    delete inflight[productId];
                    d.resolve(null);
                });
                return inflight[productId];
            }
            return { fetch: fetch };
        })();
    }

    // ---------------------------------------------------------------------
    // Shared card renderer. Expose under window.aieoCard so the side-cart
    // script can render the SAME DTO with its own scoped CSS skin.
    // ---------------------------------------------------------------------
    if (!window.aieoCard) {
        window.aieoCard = {
            // sizesHint is the `sizes` attribute the consumer wants browsers
            // to use when picking a srcset variant. Product-page cards get
            // ~320px; mini-cart thumbs get ~64–140px. Falls back to the
            // server-provided `sizes` from the DTO when no hint is passed.
            //
            // `surface` is one of "recos" | "side_cart" — drives the
            // `data-surface` attribute on the badge stack AND the
            // placement lookup. Defaults to "recos" since this file
            // is the recos consumer; the side-cart caller passes
            // "side_cart" explicitly.
            render: function (dto, sizesHint, surface) {
                if (!dto || !dto.id) return '';
                surface = surface || 'recos';
                var img = dto.image || {};
                var sizes = sizesHint || img.sizes || '';
                var imgTag = '';
                if (img.src) {
                    imgTag = '<img'
                        + ' src="'    + escapeAttr(img.src)    + '"'
                        + (img.srcset ? ' srcset="' + escapeAttr(img.srcset) + '"' : '')
                        + (sizes      ? ' sizes="'  + escapeAttr(sizes)      + '"' : '')
                        + (img.width  ? ' width="'  + img.width  + '"' : '')
                        + (img.height ? ' height="' + img.height + '"' : '')
                        + ' alt="'    + escapeAttr(img.alt || dto.name || '') + '"'
                        + ' loading="lazy"'
                        + '>';
                }
                var href = dto.permalink || '#';
                var classes = 'aieo-card';
                if (dto.on_sale)  { classes += ' is-on-sale'; }
                if (!dto.in_stock){ classes += ' is-out-of-stock'; }
                // Role badge takes the on-image slot when present.
                // The legacy on-image SALE ribbon (a big square that covered the
                // whole thumbnail) has been retired — SALE is now part of the
                // unified DMM badge stack rendered below the image, alongside
                // PRO / NEW / GIFT / monthly-offer.
                var hasRoleBadge = dto.role_badge && (dto.role_badge.icon || dto.role_badge.text);
                var imgOverlay = hasRoleBadge ? window.aieoCard.renderRoleBadge(dto.role_badge) : '';
                // DMM badge stack (PRO, NEW, BESTSELLER, FREE-SHIPPING, …).
                // Wrapped in try/catch so a malformed badge entry can never
                // crash the renderer — losing a badge is fine, losing the
                // whole reco rail is not.
                var badgeStack = '';
                try {
                    if (dto && dto.badges && dto.badges.length) {
                        // Per-surface dual size vars (desktop + mobile)
                        // emitted on the stack so the CSS @media query
                        // can swap the active --aieo-badge-fs at the
                        // 600px breakpoint.
                        var sz = (config.badgeSizes && config.badgeSizes[surface]) || null;
                        var stackStyle = '';
                        if (sz) {
                            if (sz.desktop) { stackStyle += '--aieo-badge-fs-d:' + (parseInt(sz.desktop, 10) || 11) + 'px;'; }
                            if (sz.mobile)  { stackStyle += '--aieo-badge-fs-m:' + (parseInt(sz.mobile, 10) || 10) + 'px;'; }
                        }
                        var stackInner = '';
                        for (var i = 0; i < dto.badges.length; i++) {
                            var b = dto.badges[i];
                            if (!b) continue;
                            var family   = b.f || '';
                            var label    = b.l || '';
                            var icon     = b.i || '';
                            var imageUrl = b.m || '';   // image override (m = media URL)
                            var bg       = b.b || '';   // DTO uses compact keys: b=bg, g=fg
                            var fg       = b.g || '';
                            var ord      = (b.o != null) ? b.o : 0;
                            var bStyle   = '--aieo-badge-bg:' + escapeAttr(bg) + ';'
                                         + '--aieo-badge-fg:' + escapeAttr(fg) + ';'
                                         + 'order:' + ord + ';';
                            if (imageUrl) {
                                // Image override — single <img> replaces
                                // icon + label. Badge gets has-icon-image
                                // so the CSS makes the image fill the
                                // badge box and clip to the active shape.
                                var altText = label || family;
                                stackInner += '<span class="aieo-badge aieo-badge--' + escapeAttr(family) + ' has-icon-image"'
                                           +    ' style="' + bStyle + '">'
                                           +    '<img class="aieo-badge__image" src="' + escapeAttr(imageUrl) + '"'
                                           +      ' alt="' + escapeAttr(altText) + '" loading="lazy" decoding="async">'
                                           +  '</span>';
                            } else {
                                stackInner += '<span class="aieo-badge aieo-badge--' + escapeAttr(family) + '"'
                                           +    ' style="' + bStyle + '">'
                                           +    (icon  ? '<span class="aieo-badge__icon">'  + escapeHtml(icon)  + '</span>' : '')
                                           +    (label ? '<span class="aieo-badge__label">' + escapeHtml(label) + '</span>' : '')
                                           +  '</span>';
                            }
                        }
                        if (stackInner) {
                            badgeStack = '<div class="aieo-badge-stack" data-surface="' + escapeAttr(surface) + '"'
                                       + (stackStyle ? ' style="' + stackStyle + '"' : '')
                                       + '>' + stackInner + '</div>';
                        }
                    }
                } catch (_e) {
                    badgeStack = '';
                }
                // Per-surface location decides which DOM slot the
                // badge stack lands in. For top_* locations the stack
                // sits INSIDE `.aieo-card-img-wrap` so CSS absolute
                // positioning anchors it over the image; for inline
                // (and below_favourite — unused in recos but tolerated)
                // it flows in normal document order at the top of
                // `.aieo-card-body`. The CSS handles all the actual
                // positioning + shape via body classes
                // (`aieo-bp-{surface}--{location}` and
                // `aieo-bs-{surface}--{shape}`); JS only picks the slot.
                var placements = (window.aieoRecommendationsConfig && window.aieoRecommendationsConfig.badgePlacements)
                    || (config && config.badgePlacements)
                    || (window.aieoBadgePlacements)
                    || {};
                var pl       = placements[surface] || {};
                var location = (typeof pl === 'string') ? pl : (pl.location || 'inline');
                var overImage = (location === 'top_left' || location === 'top_right');
                var stackInWrap = overImage ? badgeStack : '';
                var stackInBody = overImage ? ''         : badgeStack;
                return ''
                    + '<a class="' + classes + '"'
                    +   ' href="'  + escapeAttr(href) + '"'
                    +   ' data-product-id="' + dto.id + '"'
                    +   ' data-product-type="' + escapeAttr(dto.type || '') + '">'
                    +   '<div class="aieo-card-img-wrap">'
                    +     imgTag
                    +     imgOverlay
                    +     stackInWrap
                    +   '</div>'
                    +   '<div class="aieo-card-body">'
                    +     stackInBody
                    +     '<h3 class="aieo-card-name">' + escapeHtml(dto.name || '') + '</h3>'
                    +     ((surface !== 'side_cart' && dto.short_description) ? '<div class="aieo-card-desc">' + escapeHtml(dto.short_description) + '</div>' : '')
                    +     '<div class="aieo-card-price">' + (dto.price_html || '') + '</div>'
                    +     ((surface !== 'side_cart' && dto.rating_html) ? '<div class="aieo-card-rating">' + dto.rating_html + '</div>' : '')
                    +   '</div>'
                    + '</a>';
            },
            // Compact role-pricing nudge for anonymous shoppers — small
            // chip with a hover/tap tooltip carrying the longer message.
            // Returns '' when no badge applies. Click on the chip is
            // handled by a delegated listener (see below).
            renderRoleBadge: function (badge) {
                if (!badge || (!badge.icon && !badge.text)) return '';
                var tipText = (badge.alt || '').replace(/<[^>]+>/g, '').trim();
                // Inline-style positioning so the badge lands on the
                // image even before aieo-recommendations.css has caught
                // up. Tooltip drops below to avoid being clipped above.
                var wrapStyle = 'position:absolute;top:0;left:0;z-index:3;'
                              + 'display:inline-flex;align-items:center';
                var tipStyle  = 'position:absolute;left:0;top:calc(100% + 6px);'
                              + 'opacity:0;visibility:hidden;pointer-events:none;'
                              + 'width:240px;max-width:80vw;'
                              + 'background:#1a1a1a;color:#fff;padding:8px 10px;'
                              + 'border-radius:4px;font-size:11px;line-height:1.4;'
                              + 'text-align:left;text-transform:none;letter-spacing:0;z-index:100';
                return '<div class="aieo-card-role-badge"'
                    +   ' tabindex="0" role="button"'
                    +   ' style="' + wrapStyle + '"'
                    +   (tipText ? ' aria-label="' + escapeAttr(tipText) + '"' : '')
                    +   '>'
                    +   (badge.icon
                            ? '<span class="aieo-card-role-badge-icon">' + badge.icon + '</span>'
                            : '')
                    +   (badge.text
                            ? '<span class="aieo-card-role-badge-text">' + escapeHtml(badge.text) + '</span>'
                            : '')
                    +   (badge.alt
                            ? '<div class="aieo-card-role-badge-tip" style="' + tipStyle + '">' + badge.alt + '</div>'
                            : '')
                    + '</div>';
            }
        };
    }

    // Tap-to-open tooltip on touch / non-hover devices. Hover handles
    // desktop via CSS; this listener flips an `is-tap-open` class so the
    // tooltip stays visible after a tap. A click anywhere else closes
    // any open tooltip. Click on the chip itself shouldn't navigate the
    // surrounding card link, so swallow the event.
    $(document).off('click.aieoCardBadge').on('click.aieoCardBadge', '.aieo-card-role-badge', function (e) {
        e.preventDefault();
        e.stopPropagation();
        var $this = $(this);
        var open = $this.hasClass('is-tap-open');
        $('.aieo-card-role-badge').removeClass('is-tap-open');
        if (!open) { $this.addClass('is-tap-open'); }
    });
    $(document).off('click.aieoCardBadgeOutside').on('click.aieoCardBadgeOutside', function (e) {
        if ($(e.target).closest('.aieo-card-role-badge').length) return;
        $('.aieo-card-role-badge').removeClass('is-tap-open');
    });

    // -------------------------------------------------------------------
    // First-interaction trigger — same as the legacy loader's UX. Keeps
    // the perceived product-page-paint time as low as today.
    // -------------------------------------------------------------------
    function bindInteraction() {
        // Strong intent signals only — mousemove was firing on cursor
        // placement (Lighthouse browser open + initial pointer position)
        // and dragging the bundle-AJAX into the critical chain (~9KiB
        // POST at 2,280ms in the mobile audit). scroll / pointerdown /
        // touchstart / keydown are unambiguous user actions.
        var events = 'scroll keydown pointerdown touchstart';
        $(document).one(events, function(evt) {
            loadAllRecommendations(evt.type);
        });
    }

    // -------------------------------------------------------------------
    // Dispatcher — bundle mode is the only path now that endpoints ship
    // DTOs. Legacy per-type AJAX is kept as a defensive fallback when
    // the admin has explicitly disabled bundle mode.
    // -------------------------------------------------------------------
    function loadAllRecommendations(trigger) {
        if (config.bundleEnabled) {
            loadBundle(trigger);
            loadRecentlyViewed(trigger);
            return;
        }
        if (config.types && config.types.length) {
            config.types.forEach(function(type) { loadRecommendationLegacy(type, trigger); });
        }
    }

    // -------------------------------------------------------------------
    // Bundle fetch — ONE AJAX, replaces 3–4 recommendation calls (and in
    // 'full' mode, om-product-swatches' variation AJAX too).
    // Each section is now a DTO array, rendered client-side.
    // -------------------------------------------------------------------
    function loadBundle(trigger) {
        if (bundleConsumed) return;
        bundleConsumed = true;

        window.aieoBundleFetcher.fetch(config.productId || 0).done(function (d) {
            if (!d) {
                removePlaceholders(['upsells', 'crosssells', 'related', 'same_needs']);
                return;
            }
            mountSection('upsells',     d.upsells);
            mountSection('crosssells',  d.crosssells);
            mountSection('same_needs',  d.needs);
            mountSection('related',     d.related);

            if (d.variations && window.omProductSwatches
                && typeof window.omProductSwatches.consumeVariationData === 'function') {
                try {
                    window.omProductSwatches.consumeVariationData(config.productId, d.variations);
                } catch (err) { /* never let a swatch error break the page */ }
            }

            if (d.rv_hint) { reconcileRecentlyViewed(d.rv_hint); }
        });
    }

    // -------------------------------------------------------------------
    // Recently-viewed — localStorage-first. We only fire the AJAX when
    // the local copy is missing, stale, or covers a different ID set.
    // -------------------------------------------------------------------
    function loadRecentlyViewed(trigger) {
        if (rvConsumed) return;
        rvConsumed = true;

        var $ph = $('#' + getPlaceholderId('recently_viewed'));
        if (!$ph.length) return;

        var cached = readLocalRv();
        if (cached && Array.isArray(cached.products) && cached.ts
            && (Date.now() - cached.ts) < (config.rvLocalTtlMs || 1800000)) {
            mountSection('recently_viewed', cached.products);
            return;
        }
        fetchRecentlyViewed();
    }

    function fetchRecentlyViewed() {
        $.ajax({
            url: config.ajaxUrl,
            type: 'POST',
            data: {
                action: 'aieo_get_recently_viewed_html',
                nonce: config.nonce,
                product_id: config.productId || 0
            },
            success: function(resp) {
                if (!resp || !resp.success || !resp.data || !Array.isArray(resp.data.products) || !resp.data.products.length) {
                    $('#' + getPlaceholderId('recently_viewed')).remove();
                    return;
                }
                writeLocalRv({
                    products: resp.data.products,
                    ids:      Array.isArray(resp.data.ids) ? resp.data.ids : [],
                    ts:       Date.now()
                });
                mountSection('recently_viewed', resp.data.products);
            },
            error: function() {
                $('#' + getPlaceholderId('recently_viewed')).remove();
            }
        });
    }

    // If the bundle's rv_hint shows a different list from what we've
    // rendered from localStorage, force-refresh.
    function reconcileRecentlyViewed(serverIds) {
        var cached = readLocalRv();
        if (!cached) { return; }
        if (hashIds(cached.ids) !== hashIds(serverIds)) {
            fetchRecentlyViewed();
        }
    }

    // -------------------------------------------------------------------
    // Legacy per-type AJAX. Kept as a fallback only — the legacy server
    // endpoint still returns HTML; we render that as-is into the
    // placeholder. Bundle mode is the supported path going forward.
    // -------------------------------------------------------------------
    function loadRecommendationLegacy(type, trigger) {
        if (legacyLoaded[type]) return;
        legacyLoaded[type] = true;
        var placeholderId = getPlaceholderId(type);
        var $container = $('#' + placeholderId);
        if (!$container.length) return;
        $.ajax({
            url: config.ajaxUrl,
            type: 'POST',
            data: {
                action: 'aieo_get_recommendations',
                nonce: config.nonce,
                type: type,
                product_id: config.productId || 0
            },
            success: function(response) {
                if (response.success && response.data.html) {
                    var $rendered = $(response.data.html);
                    $container.replaceWith($rendered);
                    // Notify listeners — see CONTRACT.md.
                    try {
                        var root = $rendered.is('.aieo-recommendations, .aieo-related-products, .aieo-best-sellers')
                            ? $rendered[0]
                            : $rendered.find('.aieo-recommendations, .aieo-related-products, .aieo-best-sellers').get(0) || $rendered[0];
                        var detail = { section: type, root: root };
                        if (window.aieo && window.aieo.iapi && window.aieo.iapi.dispatch) {
                            window.aieo.iapi.dispatch('aieo:reco:rendered', detail);
                        } else {
                            document.body.dispatchEvent(new CustomEvent('aieo:reco:rendered', {
                                bubbles: true, detail: detail
                            }));
                        }
                    } catch (e) { /* old-IE; not in scope */ }
                } else {
                    $container.remove();
                }
            },
            error: function() { $container.remove(); }
        });
    }

    // -------------------------------------------------------------------
    // DOM mount — replace the placeholder with our own <section>. The
    // section's heading is live, but the card grid is INITIALLY empty
    // and the cards live in a sibling `<template>`. An
    // IntersectionObserver clones the template into the grid the first
    // time the section nears the viewport.
    //
    // Why: the bundle prefetch happens early (first user interaction or
    // idle) — before the user has scrolled to the recos. Inserting all
    // ~30–40 cards (5 surfaces × ~6 cards) into the live DOM at fetch
    // time pushes the audited DOM past Lighthouse's threshold even
    // though most cards are below the fold. Templating between fetch
    // and paint keeps Lighthouse happy without breaking the existing
    // single-call superset prefetch architecture.
    //
    // The `aieo:reco:rendered` event fires AFTER the IO clone so
    // downstream listeners (wishlist hearts, swatch decorators) see
    // the real cards, not an empty grid.
    // -------------------------------------------------------------------
    function mountSection(type, dtos) {
        var placeholderId = getPlaceholderId(type);
        if (!placeholderId) return;
        var $ph = $('#' + placeholderId);
        if (!$ph.length) return;
        if (!Array.isArray(dtos) || !dtos.length) {
            $ph.remove();
            return;
        }
        var title = SECTION_TITLES[type] || '';
        // Product-page sizes hint matches the WC `woocommerce_thumbnail`
        // viewport math — browsers fetch the right srcset variant.
        var sizesHint = '(max-width: 768px) 50vw, 320px';
        var cards = dtos.map(function (d) { return window.aieoCard.render(d, sizesHint); }).join('');

        // Build live shell: <section> + heading + EMPTY grid + sibling
        // <template> carrying the cards. Lighthouse counts the template
        // as 1 element, ignores its content.
        var html = ''
            + '<section class="aieo-recommendations" data-section="' + type + '">'
            +   (title ? '<h2 class="aieo-recommendations-title">' + escapeHtml(title) + '</h2>' : '')
            +   '<div class="aieo-recommendations-grid" data-aieo-reco-mount="' + type + '"></div>'
            +   '<template class="aieo-reco-template">' + cards + '</template>'
            + '</section>';
        var $section = $(html);
        $ph.replaceWith($section);

        // Per-mount IntersectionObserver — independent per surface so
        // each section hydrates only when scrolled toward. rootMargin:
        // 200px gives the clone a head-start before the section is
        // actually visible; keeps perceived UX seamless.
        var grid = $section[0].querySelector('[data-aieo-reco-mount]');
        var tpl  = $section[0].querySelector('template.aieo-reco-template');
        if (!grid || !tpl) { return; }

        function hydrate() {
            if (grid.__aieoHydrated) return;
            grid.__aieoHydrated = true;
            grid.appendChild(tpl.content.cloneNode(true));
            // Free the template — its job is done. Safe to remove
            // because the hydrate function is idempotent (returns
            // early on second call via the flag).
            if (tpl.parentNode) { tpl.parentNode.removeChild(tpl); }
            // Notify downstream listeners NOW — see runtime CONTRACT.md
            // → DOM CustomEvents. Wishlist hearts, swatches, IAPI.
            try {
                var detail = { section: type, root: $section[0] };
                if (window.aieo && window.aieo.iapi && window.aieo.iapi.dispatch) {
                    window.aieo.iapi.dispatch('aieo:reco:rendered', detail);
                } else {
                    document.body.dispatchEvent(new CustomEvent('aieo:reco:rendered', {
                        bubbles: true, detail: detail
                    }));
                }
            } catch (e) { /* old-IE; not in scope */ }
        }

        if ('IntersectionObserver' in window) {
            var io = new IntersectionObserver(function (entries) {
                for (var i = 0; i < entries.length; i++) {
                    if (entries[i].isIntersecting) { hydrate(); io.disconnect(); break; }
                }
            }, { rootMargin: '200px 0px' });
            io.observe(grid);
        } else {
            // No IO support — hydrate immediately. Functional fallback.
            hydrate();
        }
    }

    // -------------------------------------------------------------------
    // localStorage helpers (fail silently in private-mode browsers).
    // -------------------------------------------------------------------
    function readLocalRv() {
        try {
            var raw = window.localStorage.getItem(config.rvLocalKey || 'aieo_rv_cache_v4');
            return raw ? JSON.parse(raw) : null;
        } catch (err) { return null; }
    }
    function writeLocalRv(blob) {
        try { window.localStorage.setItem(config.rvLocalKey || 'aieo_rv_cache_v4', JSON.stringify(blob)); }
        catch (err) { /* quota / private mode — ignore */ }
    }
    function hashIds(list) {
        if (!Array.isArray(list) || !list.length) return '';
        var copy = list.slice();
        copy.sort(function(a, b) { return a - b; });
        return copy.join(',');
    }

    // -------------------------------------------------------------------
    // DOM helpers.
    // -------------------------------------------------------------------
    function removePlaceholders(types) {
        types.forEach(function(t) {
            var id = getPlaceholderId(t);
            if (id) { $('#' + id).remove(); }
        });
    }
    function getPlaceholderId(type) {
        switch (type) {
            case 'upsells':         return 'aieo-up-sells-placeholder';
            case 'crosssells':      return 'aieo-cross-sells-placeholder';
            case 'related':         return 'aieo-related-placeholder';
            case 'recently_viewed': return 'aieo-recently-viewed-placeholder';
            case 'same_needs':      return 'aieo-same-needs-placeholder';
            default:                return '';
        }
    }
    function escapeHtml(str) {
        return String(str)
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#039;');
    }
    function escapeAttr(str) {
        return String(str)
            .replace(/&/g, '&amp;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#039;');
    }

    $(bindInteraction);

})(jQuery);
