/* global aieoSideCart, jQuery */
/**
 * AIEO DMM Side Cart — Rey-style mini-cart behaviour.
 *
 * - Open/close + scrim.
 * - Tabs (Shopping Bag / Recently Viewed).
 * - Optimistic qty updates with 500 ms debounce.
 * - Client-side free-shipping bar recompute from subtotal_raw.
 * - Recommendation columns + recently-viewed tab consume product DTOs
 *   (NOT pre-rendered HTML) and render via window.aieoCard.render() with
 *   compact CSS scoped under `.aieo-sc-root`.
 * - WC fragments integration — listens for added/removed/updated events
 *   from other widgets on the page.
 */
(function ($) {
    'use strict';

    var __ = ( window.wp && window.wp.i18n && window.wp.i18n.__ ) ? window.wp.i18n.__ : function (s) { return s; };
    var _x = ( window.wp && window.wp.i18n && window.wp.i18n._x ) ? window.wp.i18n._x : function (s) { return s; };
    var _n = ( window.wp && window.wp.i18n && window.wp.i18n._n ) ? window.wp.i18n._n : function (s, p, n) { return n === 1 ? s : p; };
    var _sprintf = ( window.wp && window.wp.i18n && window.wp.i18n.sprintf ) ? window.wp.i18n.sprintf : function (s) { return s; };

    if (typeof aieoSideCart === 'undefined') { return; }

    // Tiny escapers shared by every renderer in this file. Hoisted to
    // the IIFE scope (NOT inside `if (!window.aieoCard)`) because
    // strict-mode block-scoped function declarations don't leak out —
    // the quick-view harvester defined later would otherwise blow up
    // with `ReferenceError: escAttr is not defined`.
    function escAttr(s) {
        return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#039;');
    }
    function escHtml(s) {
        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;');
    }

    /* -------------------------------------------------------------------------
     * Shared bundle fetcher — mirror of the block in lazy-recommendations.js.
     * Guarded so whichever file runs first defines it; the later one uses it.
     * Cache key bumped to _v2 to match the DTO-shaped server payload.
     * ------------------------------------------------------------------------- */
    if (!window.aieoBundleFetcher) {
        window.aieoBundleFetcher = (function () {
            var inflight = {};
            var LS_KEY = 'aieo_bundle_last_v12';
            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) { /* 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 };
        })();
    }

    /* -------------------------------------------------------------------------
     * Card renderer — same fallback definition as lazy-recommendations.js.
     * Whichever file loads first defines window.aieoCard; the other reuses
     * it. The side-cart's compact skin selects via `.aieo-sc-root .aieo-card`
     * so the SAME markup looks completely different here vs. on a product
     * page (`.aieo-recommendations .aieo-card`).
     * ------------------------------------------------------------------------- */
    if (!window.aieoCard) {
        window.aieoCard = {
            // `surface` is one of "recos" | "side_cart" — drives the
            // `data-surface` attribute on the badge stack AND the
            // placement lookup. Defaults to "recos" since
            // lazy-recommendations.js is the typical caller; the
            // side-cart code below 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="'    + escAttr(img.src)    + '"'
                        + (img.srcset ? ' srcset="' + escAttr(img.srcset) + '"' : '')
                        + (sizes      ? ' sizes="'  + escAttr(sizes)      + '"' : '')
                        + (img.width  ? ' width="'  + img.width  + '"' : '')
                        + (img.height ? ' height="' + img.height + '"' : '')
                        + ' alt="'    + escAttr(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 stays in the on-image slot (top-left).
                // The legacy on-image SALE ribbon 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) : '';
                // Unified DMM badge stack. DTO uses compact keys —
                // f=family, l=label, i=icon, b=bg, g=fg, o=order.
                // Wrapped in try/catch so a malformed badge entry can
                // never crash the renderer.
                var badgeStack = '';
                try {
                    if (dto && dto.badges && dto.badges.length) {
                        // Dual size vars (desktop + mobile) on the
                        // stack so the CSS @media query swaps the
                        // active --aieo-badge-fs at 600px.
                        var sizes = (window.aieoSideCart && window.aieoSideCart.badgeSizes)
                            || (window.aieoRecommendationsConfig && window.aieoRecommendationsConfig.badgeSizes)
                            || {};
                        var sz = sizes[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
                            var bg       = b.b || '';
                            var fg       = b.g || '';
                            var ord      = (b.o != null) ? b.o : 0;
                            var bStyle   = '--aieo-badge-bg:' + escAttr(bg) + ';'
                                         + '--aieo-badge-fg:' + escAttr(fg) + ';'
                                         + 'order:' + ord + ';';
                            if (imageUrl) {
                                var altText = label || family;
                                stackInner += '<span class="aieo-badge aieo-badge--' + escAttr(family) + ' has-icon-image"'
                                           +    ' style="' + bStyle + '">'
                                           +    '<img class="aieo-badge__image" src="' + escAttr(imageUrl) + '"'
                                           +      ' alt="' + escAttr(altText) + '" loading="lazy" decoding="async">'
                                           +  '</span>';
                            } else {
                                stackInner += '<span class="aieo-badge aieo-badge--' + escAttr(family) + '"'
                                           +    ' style="' + bStyle + '">'
                                           +    (icon  ? '<span class="aieo-badge__icon">'  + escHtml(icon)  + '</span>' : '')
                                           +    (label ? '<span class="aieo-badge__label">' + escHtml(label) + '</span>' : '')
                                           +  '</span>';
                            }
                        }
                        if (stackInner) {
                            badgeStack = '<div class="aieo-badge-stack" data-surface="' + escAttr(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`; otherwise it
                // flows in `.aieo-card-body`. CSS handles all the
                // actual positioning + shape via body classes.
                var placements = (window.aieoSideCart && window.aieoSideCart.badgePlacements)
                    || (window.aieoRecommendationsConfig && window.aieoRecommendationsConfig.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="'  + escAttr(href) + '"'
                    +   ' data-product-id="' + dto.id + '"'
                    +   ' data-product-type="' + escAttr(dto.type || '') + '">'
                    +   '<div class="aieo-card-img-wrap">'
                    +     imgTag
                    +     imgOverlay
                    +     stackInWrap
                    +   '</div>'
                    +   '<div class="aieo-card-body">'
                    +     stackInBody
                    +     '<h3 class="aieo-card-name">' + escHtml(dto.name || '') + '</h3>'
                    +     ((surface !== 'side_cart' && dto.short_description) ? '<div class="aieo-card-desc">' + escHtml(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>';
            },
            renderRoleBadge: function (badge) {
                if (!badge || (!badge.icon && !badge.text)) return '';
                var tipText = (badge.alt || '').replace(/<[^>]+>/g, '').trim();
                // Inline `style` is a defensive fallback for stale-CSS
                // cases. Badge is position:absolute at the image's top-
                // left so it overlays the image where SALE used to sit;
                // tooltip drops *below* the badge so it isn't clipped
                // by the panel header above the card.
                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:220px;max-width:70vw;'
                              + '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="' + escAttr(tipText) + '"' : '')
                    +   '>'
                    +   (badge.icon
                            ? '<span class="aieo-card-role-badge-icon">' + badge.icon + '</span>'
                            : '')
                    +   (badge.text
                            ? '<span class="aieo-card-role-badge-text">' + escHtml(badge.text) + '</span>'
                            : '')
                    +   (badge.alt
                            ? '<div class="aieo-card-role-badge-tip" style="' + tipStyle + '">' + badge.alt + '</div>'
                            : '')
                    + '</div>';
            }
        };
    }

    // Tap-to-open tooltip on touch devices — hover handles desktop via CSS.
    // Same delegated listeners that lazy-recommendations.js installs;
    // both files run `.off()` first so whichever loads later doesn't
    // double-bind.
    $(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');
    });

    var $root, $panel, $trigger, $scrim;
    var state = {
        opened:      false,
        initialLoad: false,
        debounceHandles: {},
        // Current YOU MAY ALSO LIKE slide index — kept in sync by
        // slideMaylikeTo() so the prev/next arrows know where to go.
        maylikeIndex: 0,
        maylikeMax:   0,
        // Last product the shopper added to cart. Captured from the
        // `added_to_cart` jQuery event's `$button` argument. Used as the
        // PRIMARY seed for hydrateRecommendations — when the side cart
        // opens from an archive page there's no `postid-N` body class,
        // so without this the maylike carousel came up empty.
        lastAddedProductId: 0
    };

    // Per-context `sizes` hints — match each painter's actual CSS display
    // width so browsers pick a srcset variant that won't upscale-blur on
    // 2× displays. A single 64px hint (the prior implementation) made the
    // 140px-wide left-column thumbnails fetch the smallest available
    // variant and stretch it, which read as "extremely low quality".
    var SIZES_HINT = {
        recoList:       '140px', // .aieo-sc-reco-col .aieo-card-img-wrap (max-width:140px)
        maylike:         '80px', // .aieo-sc-maylike-slide .aieo-card-img-wrap
        recentlyViewed:  '64px'  // .aieo-sc-rv-list .aieo-card-img-wrap
    };

    // Capture-phase native click listener — attaches IMMEDIATELY (not on
    // DOMReady) and runs BEFORE any theme/plugin handler can stopPropagation
    // or swallow the event. Stores the clicked product id into
    // sessionStorage, which survives the full page reload that non-AJAX
    // add-to-cart flows (`?add-to-cart=N` links) trigger. Without this, the
    // maylike carousel came up empty whenever the shopper added from an
    // archive page with a theme that intercepts clicks or falls back to the
    // non-AJAX add flow — the `added_to_cart` jQuery event never fires in
    // that case, and the `$button` arg is unreachable.
    var LAST_ADDED_SESSION_KEY = 'aieo_last_added_pid';
    document.addEventListener('click', function (e) {
        var btn = e.target && e.target.closest
            ? e.target.closest('[data-product_id], .add_to_cart_button, .ajax_add_to_cart, .single_add_to_cart_button, .om-add-to-cart-button, .om-multi-select-add-to-cart')
            : null;
        if (!btn) return;
        var pidAttr = btn.getAttribute('data-product_id')
                   || btn.getAttribute('data-product-id')
                   || btn.getAttribute('value');
        var pid = parseInt(pidAttr, 10) || 0;
        if (pid <= 0) {
            // Inside a variation form? Pull the parent product id from the
            // enclosing .variations_form[data-product_id].
            var form = btn.closest && btn.closest('.variations_form, form.cart');
            if (form) {
                pid = parseInt(form.getAttribute('data-product_id'), 10) || 0;
            }
        }
        if (pid > 0) {
            state.lastAddedProductId = pid;
            try { window.sessionStorage.setItem(LAST_ADDED_SESSION_KEY, String(pid)); } catch (err) {}
        }
    }, true);

    $(document).ready(function () {
        $root    = $('#aieo-sc-root');
        if (!$root.length) { return; }
        $panel   = $root.find('.aieo-sc-panel');
        $trigger = $root.find('.aieo-sc-trigger');
        $scrim   = $root.find('.aieo-sc-scrim');

        // Rehydrate last-added pid from the previous page load if the
        // shopper just came back from a `?add-to-cart=N` redirect.
        try {
            var storedPid = parseInt(window.sessionStorage.getItem(LAST_ADDED_SESSION_KEY), 10) || 0;
            if (storedPid > 0) state.lastAddedProductId = storedPid;
        } catch (err) {}

        bindEvents();
        paintFreeShippingBar(parseFloat($root.data('threshold')) || 0, currentSubtotalRaw());
        // Phase-1 perf (2026-06-13): hydrateRecommendations() +
        // hydrateRecentlyViewed() used to run HERE at document.ready — two
        // admin-ajax round-trips (each a full WP boot) on EVERY page view,
        // for drawer content the shopper may never look at. They now run on
        // the first openCart() instead (see openCart), where the drawer
        // content actually becomes visible.
        refreshFavoriteCountBadge();
        restoreRecoCollapsedState();
    });

    // ------------------------------------------------------------------
    // Reco-column collapse toggle (Rey-style)
    //
    // Persists across the browser session via sessionStorage so the
    // shopper's preference holds while they browse but doesn't carry
    // forward to a fresh visit (default = sidebar visible).
    // ------------------------------------------------------------------
    var RECO_COLLAPSE_KEY = 'aieo_sc_reco_collapsed';

    function restoreRecoCollapsedState() {
        var collapsed = false;
        try { collapsed = window.sessionStorage.getItem(RECO_COLLAPSE_KEY) === '1'; }
        catch (e) { /* private mode etc — fall back to default open */ }
        applyRecoCollapsedState(collapsed);
    }
    function applyRecoCollapsedState(collapsed) {
        $root.toggleClass('is-reco-collapsed', !!collapsed);
        $root.find('[data-aieo-sc-reco-toggle]').attr('aria-expanded', collapsed ? 'false' : 'true');
    }
    function toggleRecoCollapsed() {
        var nowCollapsed = !$root.hasClass('is-reco-collapsed');
        applyRecoCollapsedState(nowCollapsed);
        try { window.sessionStorage.setItem(RECO_COLLAPSE_KEY, nowCollapsed ? '1' : '0'); }
        catch (e) { /* ignore */ }
    }

    // ------------------------------------------------------------------
    // Open / close
    //
    // The panel's default state is `[hidden]` (CSS sets `display: none`), so its
    // off-canvas bounding box doesn't sit in the document layout while closed —
    // any tooling that walks the DOM looking for elements past the viewport edge
    // (overflow auditors, Lighthouse "scrollable element" audits, custom debug
    // helpers) will skip it correctly. We coordinate display ↔ class toggling
    // so the slide-in transition still runs:
    //
    //   open  : drop `[hidden]` → force a reflow → add `.is-open` → CSS animates transform
    //   close : remove `.is-open` → wait for transitionend → set `[hidden]` again
    // ------------------------------------------------------------------
    function openCart() {
        if (state.opened) return;
        state.opened = true;

        // Cancel any pending close-after-transition since we're re-opening.
        if (closeCart._pending) {
            clearTimeout(closeCart._pending);
            closeCart._pending = null;
        }

        // Make the panel paintable BEFORE adding the class so the transition
        // has a starting frame to animate from. Old approach was the
        // canonical `void $panel[0].offsetWidth` reflow trick, which
        // forces a SYNCHRONOUS layout flush — that's fine on a tiny
        // element but the side-cart panel is the whole cart + recos
        // rail + maylike carousel, so flushing layout for it costs
        // ~32–70 ms per add-to-cart and trips Chrome's "Forced reflow"
        // warning. The double-rAF dance below gives the browser two
        // natural frames to lay out the unhidden panel without us
        // ever reading a layout property — same animation effect,
        // zero forced reflow.
        $panel.removeAttr('hidden');
        $panel.attr('aria-hidden', 'false');
        var rafTick = window.requestAnimationFrame || function (cb) { return setTimeout(cb, 16); };
        rafTick(function () {
            rafTick(function () {
                $root.addClass('is-open');
            });
        });
        if (!state.initialLoad) {
            state.initialLoad = true;
            fetchPanel();
        }
        // Phase-1 perf: recos + recently-viewed hydrate on the FIRST open
        // (moved out of document.ready — they cost one admin-ajax round-trip
        // each, which every page view paid for a closed drawer). Both
        // functions keep their own short-circuits (15-min localStorage
        // cache, in-flight guards), so this stays cheap on repeat opens and
        // cache hits paint synchronously.
        if (!state.lazyRailsHydrated) {
            state.lazyRailsHydrated = true;
            // Lazy-load the drawer-contents stylesheet (reco/RV/favorites/
            // carousel/quick-view styling, ~33KB) the first time the cart
            // opens — no page render needs it. Injected here, in parallel
            // with the hydration AJAX below, so the CSS (a cacheable static
            // file) almost always lands before the cards finish their
            // round-trip. Base shell CSS already styles the panel + cart.
            try {
                var ccss = (window.aieoSideCart && window.aieoSideCart.contentsCss) || '';
                if (ccss && !document.getElementById('aieo-side-cart-contents-css')) {
                    var link = document.createElement('link');
                    link.rel = 'stylesheet';
                    link.id  = 'aieo-side-cart-contents-css';
                    link.href = ccss;
                    document.head.appendChild(link);
                }
            } catch (e) {}
            hydrateRecommendations();
            hydrateRecentlyViewed();
        }
    }
    function closeCart() {
        if (!state.opened) return;
        state.opened = false;
        // Stamp the most recent user-driven close so the added_to_cart
        // listener can suppress auto-open during the next few seconds —
        // protects against WC blocks / pixel-tracker plugins that bridge
        // cart-refresh events to the legacy added_to_cart trigger.
        state.lastUserCloseAt = Date.now();
        // Reset the Quick View tab to its hidden default. QV is ephemeral —
        // it's only relevant during the user's current browse, not across
        // re-opens of the side cart.
        var $qvTab = $root.find('[data-aieo-sc-qv-tab]');
        if ($qvTab.length) {
            $qvTab.prop('hidden', true).removeClass('is-active').attr('aria-selected', 'false');
        }
        $root.find('[data-aieo-sc-body="quickview"]').removeAttr('data-aieo-sc-qv-pid');
        $root.removeClass('is-open');
        $panel.attr('aria-hidden', 'true');

        // After the slide-out animation finishes, take the panel back out of
        // layout so its bounding box no longer sits past the viewport edge.
        // Fallback timeout matches the CSS transition duration in case
        // transitionend doesn't fire (e.g., reduced-motion + display already
        // collapsed).
        var done = false;
        function finalizeClose(e) {
            if (done) return;
            if (e && e.target !== $panel[0]) return; // ignore inner transitions
            if (e && e.propertyName && e.propertyName !== 'transform') return;
            done = true;
            $panel.off('transitionend', finalizeClose);
            // Guard against re-open during the close animation.
            if (!state.opened) {
                $panel.attr('hidden', 'hidden');
            }
        }
        $panel.on('transitionend', finalizeClose);
        closeCart._pending = setTimeout(finalizeClose, 320); // 280ms transition + tiny buffer
    }

    // ------------------------------------------------------------------
    // Event bindings
    // ------------------------------------------------------------------
    function bindEvents() {
        // Trigger binding is on `document` (not $root) so the trigger
        // keeps working when the side-cart's floating shell becomes
        // the mobile bottom bar (the trigger is reparented out of
        // `.aieo-sc-root` into the bar slot — but jQuery delegation on
        // document still resolves the click via the selector). The
        // trigger ALSO acts as a TOGGLE: open if closed, close if
        // already open — important on mobile, where the drawer ends
        // above the bar so the trigger stays reachable while open.
        $(document).on('click', '.aieo-sc-trigger', function (e) {
            e.preventDefault();
            if (state.opened) { closeCart(); } else { openCart(); }
        });

        // Header icons → open the drawer on a SPECIFIC tab (ΚΑΛΑΘΙ → bag,
        // ΑΓΑΠΗΜΕΝΑ heart → favorites). Bound here (not in the theme) so it uses
        // the drawer's real $root + open/switch internals — robust to the
        // duplicate favorites tab / multiple `.aieo-sc-trigger` elements that a
        // blind theme-side querySelector().click() mis-targets. When the drawer
        // isn't on the page we DON'T preventDefault, so the icon's
        // <a href="/cart/"> / "/wishlist/" navigation stays as the fallback.
        function openToTab(tab) {
            var current = $root.find('.aieo-sc-tab.is-active').first().data('aieo-sc-tab');
            if (state.opened && current === tab) { closeCart(); return; } // same tab → toggle closed
            if (!state.opened) { openCart(); }
            switchToTab(tab);
        }
        $(document).on('click', '[data-roosterx-open-cart], [data-aieo-sc-open="bag"]', function (e) {
            if (!$root || !$root.length) { return; }
            e.preventDefault();
            openToTab('bag');
        });
        $(document).on('click', '[data-roosterx-open-favorites], [data-aieo-sc-open="favorites"]', function (e) {
            if (!$root || !$root.length) { return; }
            e.preventDefault();
            openToTab('favorites');
        });

        $root.on('click', '.aieo-sc-close', closeCart);
        $root.on('click', '.aieo-sc-scrim', closeCart);

        // Reco-column collapse toggle (chevron between the two cols)
        $root.on('click', '[data-aieo-sc-reco-toggle]', function (e) {
            e.preventDefault();
            e.stopPropagation();
            toggleRecoCollapsed();
        });

        // Tabs
        $root.on('click', '.aieo-sc-tab', function () {
            var tab = $(this).data('aieo-sc-tab');
            $root.find('.aieo-sc-tab').removeClass('is-active').attr('aria-selected', 'false');
            $(this).addClass('is-active').attr('aria-selected', 'true');
            $root.find('[data-aieo-sc-body]').attr('hidden', true);
            $root.find('[data-aieo-sc-body="' + tab + '"]').removeAttr('hidden');
            if (tab === 'viewed')    { hydrateRecentlyViewed(true); }
            if (tab === 'favorites') { hydrateFavorites(true); }
            // Quick View is an ephemeral tab — visible only while the
            // shopper is actively browsing a product in QV. As soon as
            // they switch to any other tab, hide it again + clear the
            // pid attribute so a stale QV body can't paint into a new
            // card open. The tab will re-appear next time
            // promoteQuickViewTab() is called.
            if (tab !== 'quickview') {
                var $qvTab = $root.find('[data-aieo-sc-qv-tab]');
                if ($qvTab.length && !$qvTab.prop('hidden')) {
                    $qvTab.prop('hidden', true).removeClass('is-active').attr('aria-selected', 'false');
                }
                $root.find('[data-aieo-sc-body="quickview"]').removeAttr('data-aieo-sc-qv-pid');
            }
        });

        // Heart toggle (favorite a product) — works on RV and Favorites
        // cards. Click swallowed so it doesn't navigate the surrounding
        // card link.
        $root.on('click', '[data-aieo-sc-fav-toggle]', function (e) {
            e.preventDefault();
            e.stopPropagation();
            var $btn = $(this);
            var pid = parseInt($btn.attr('data-aieo-sc-fav-toggle'), 10) || 0;
            if (pid <= 0) return;
            var nowFav = toggleFavorite(pid);
            $btn.toggleClass('is-favorited', nowFav);
            $btn.attr('aria-pressed', nowFav ? 'true' : 'false');
            // If we removed a favorite while viewing the Favorites tab,
            // re-render so the card disappears.
            var $favBody = $root.find('[data-aieo-sc-body="favorites"]');
            if (!nowFav && $favBody.length && !$favBody.prop('hidden')) {
                hydrateFavorites(true);
            }
        });

        // External wishlist updates — listen on BOTH event names so
        // we stay in sync whether the originating call came from
        // AIEO (the new path) or from PPE during the transition.
        // PR #4 (brand-shopping): also handles itemType !== 'product'
        // (currently 'brand'; future: 'product_cat', 'pa-need'). The
        // type-specific UI hooks (cookie sync, product hearts) are
        // gated to product; the Favorites re-render is universal.
        $(window).on('aieo_wishlist_updated.aieoSideCart om_ppe_wishlist_updated.aieoSideCart',
            function (e, itemId, add, itemType) {
                var iid = parseInt(itemId, 10) || 0;
                if (iid <= 0) return;
                var kind = itemType || 'product';

                if (kind === 'product') {
                    refreshFavoriteCountBadge();
                    // Update any heart toggles in the side cart for this product.
                    var $hearts = $root.find('[data-aieo-sc-fav-toggle="' + iid + '"]');
                    $hearts.toggleClass('is-favorited', !!add);
                    $hearts.attr('aria-pressed', add ? 'true' : 'false');
                }

                // Universal: if the Favorites tab is currently visible,
                // re-render so the new (or removed) item shows up at once.
                var $favBody = $root.find('[data-aieo-sc-body="favorites"]');
                if ($favBody.length && !$favBody.prop('hidden')) {
                    hydrateFavorites(true);
                }
            }
        );

        // Compact ADD TO BAG button on RV / Favorites / maylike cards.
        // Simple products go through the optimistic add helper. Variable
        // / grouped / external products open the Quick View tab inline
        // — keeps the shopper inside the side cart so they can pick a
        // variation without losing their place. (Previously these
        // navigated away to the product page, which the shopper read
        // as "the button doesn't add anything".)
        $root.on('click', '[data-aieo-sc-rv-add]', function (e) {
            e.preventDefault();
            e.stopPropagation();
            var $btn = $(this);
            var pid = parseInt($btn.attr('data-aieo-sc-rv-add'), 10) || 0;
            var type = $btn.attr('data-product-type') || '';
            var perma = $btn.attr('data-permalink') || '';
            if (pid <= 0) return;
            if (type === 'variable' || type === 'grouped' || type === 'external') {
                // Read the sibling .aieo-card seed inside the same list
                // item so the QV body is pre-painted with the correct
                // image / title / price even before the bundle fetch
                // completes.
                var $item = $btn.closest('.aieo-sc-reco-item, .aieo-sc-maylike-slide');
                var $card = $item.find('.aieo-card').first();
                var $img  = $card.find('img').first();
                // Origin — whichever surface the button lives on
                // (`sidecart_recently_viewed`, `sidecart_favorites`,
                // `sidecart_may_also_like`, `sidecart_product_needs`).
                // Persists through the QV → ADD TO BAG hop via
                // source_meta.origin_source so the add event keeps
                // a pointer back to the real triggering surface.
                openQuickViewForCard({
                    pid:       pid,
                    title:     $.trim($card.find('.aieo-card-name').first().text() || ''),
                    imgSrc:    $img.attr('src')    || '',
                    imgSrcset: $img.attr('srcset') || '',
                    imgAlt:    $img.attr('alt')    || '',
                    priceHtml: $card.find('.aieo-card-price').first().html() || '',
                    permalink: perma,
                    type:      type,
                    origin_source: $btn.attr('data-aieo-source') || '',
                });
                return;
            }
            sideCartAddProductOptimistic({
                productId: pid,
                $btn:      $btn,
                onError:   function () {
                    if (perma) { window.location = perma; }
                }
            });
        });

        // Free-gift picker — primary product dropdown. When a SIMPLE
        // product is picked, the pick AJAX returns immediately and we
        // refresh. When a VARIABLE product is picked, the server saves
        // the parent (variation_id=0), and we fetch the variation list
        // to render a second dropdown right below — the shopper picks
        // a variation, THAT triggers the real cart-add via a second
        // pick AJAX with both product_id + variation_id.
        $root.on('change', '.aieo-sc-gift-select', function () {
            var $sel    = $(this);
            var offerId = parseInt($sel.attr('data-offer-id'), 10) || 0;
            var pid     = parseInt($sel.val(), 10) || 0;
            var type    = $sel.find('option:selected').attr('data-product-type') || 'simple';
            if (!offerId || !pid) return;
            var $varBox = $sel.closest('.om-bg-gift-choice').find('.aieo-sc-gift-variation');

            $sel.prop('disabled', true);
            $varBox.empty();

            if (type === 'variable') {
                // Save the parent (no variation yet) so the cart drops
                // any stale gift line, then fetch + render variations.
                $.post(aieoSideCart.ajaxurl, {
                    action:     'aieo_dmm_brand_gift_pick',
                    nonce:      $root.data('nonce'),
                    offer_id:   offerId,
                    product_id: pid
                });
                $.post(aieoSideCart.ajaxurl, {
                    action:     'aieo_dmm_brand_gift_variations',
                    nonce:      $root.data('nonce'),
                    product_id: pid
                }).done(function (resp) {
                    $sel.prop('disabled', false);
                    if (!resp || !resp.success) return;
                    var variations = (resp.data && resp.data.variations) || [];
                    if (!variations.length) {
                        var msgNone = (window.aieoSideCart && aieoSideCart.i18n && aieoSideCart.i18n.noVariationsInStock) || __('No in-stock variations available.', 'ai-eshop-optimizer');
                        $varBox.html('<small style="color:#9a3412;">' + msgNone + '</small>');
                        return;
                    }
                    var pickLabel = (window.aieoSideCart && aieoSideCart.i18n && aieoSideCart.i18n.pickVariation) || __('— Pick variation —', 'ai-eshop-optimizer');
                    var html = '<select class="aieo-sc-gift-variation-select" data-offer-id="' + offerId + '" data-product-id="' + pid + '">';
                    html += '<option value="0">' + pickLabel + '</option>';
                    variations.forEach(function (v) {
                        html += '<option value="' + v.variation_id + '">' + v.label + '</option>';
                    });
                    html += '</select>';
                    $varBox.html(html);
                });
                return;
            }

            // Simple product — single hop, refresh cart.
            $.post(aieoSideCart.ajaxurl, {
                action:     'aieo_dmm_brand_gift_pick',
                nonce:      $root.data('nonce'),
                offer_id:   offerId,
                product_id: pid
            }).done(function (resp) {
                $sel.prop('disabled', false);
                if (resp && resp.success) { fetchPanel(); }
            }).fail(function () { $sel.prop('disabled', false); });
        });

        // Second dropdown — the variation picker, only present after
        // a variable parent product was selected above.
        $root.on('change', '.aieo-sc-gift-variation-select', function () {
            var $sel    = $(this);
            var offerId = parseInt($sel.attr('data-offer-id'), 10) || 0;
            var pid     = parseInt($sel.attr('data-product-id'), 10) || 0;
            var vid     = parseInt($sel.val(), 10) || 0;
            if (!offerId || !pid || !vid) return;
            $sel.prop('disabled', true);
            $.post(aieoSideCart.ajaxurl, {
                action:       'aieo_dmm_brand_gift_pick',
                nonce:        $root.data('nonce'),
                offer_id:     offerId,
                product_id:   pid,
                variation_id: vid
            }).done(function (resp) {
                $sel.prop('disabled', false);
                if (resp && resp.success) { fetchPanel(); }
            }).fail(function () { $sel.prop('disabled', false); });
        });

        // Qty buttons — OPTIMISTIC update, then debounced persist.
        // CRITICAL: capture the qty input's CURRENT value BEFORE we
        // overwrite it with nextQty, and seed `data-aieo-last-qty` so
        // applyOptimisticQtyChange can derive the correct unit price
        // (lineTotal / oldQty). Without this seed, the first click on
        // an item that started at qty>1 would compute unit = full line
        // total / 1, making every subsequent line total wildly wrong
        // (e.g., 6 × 6.90 displaying as 207€ because cached unit was
        // 34.50€ instead of 6.90€).
        $root.on('click', '.aieo-qty-btn', function () {
            var $btn = $(this);
            var $item = $btn.closest('.mini_cart_item');
            var $input = $item.find('.aieo-qty-input');
            var delta = parseInt($btn.attr('data-aieo-qty-delta'), 10) || 0;
            var oldQty = Math.max(1, parseInt($input.val(), 10) || 1);
            if ($item.data('aieo-last-qty') == null) {
                $item.data('aieo-last-qty', oldQty);
            }
            var nextQty = Math.max(0, oldQty + delta);
            $input.val(nextQty);
            applyOptimisticQtyChange($item, nextQty);
            schedulePersist($item.attr('data-aieo-key'), nextQty);
        });
        $root.on('change', '.aieo-qty-input', function () {
            var $input = $(this);
            var $item = $input.closest('.mini_cart_item');
            // Same seed: `data-aieo-last-qty` carries the previous value
            // so the unit-price derivation is correct on first edit.
            var prev = $item.data('aieo-last-qty');
            if (prev == null) {
                // We don't know the prev value — best guess is the data
                // attribute holding the original render value. Fall back
                // to nextQty (which makes unit = lineTotal/nextQty —
                // correct in the steady state where lineTotal already
                // matches the typed value).
                $item.data('aieo-last-qty', Math.max(1, parseInt($input.val(), 10) || 1));
            }
            var q = Math.max(0, parseInt($input.val(), 10) || 0);
            $input.val(q);
            applyOptimisticQtyChange($item, q);
            schedulePersist($item.attr('data-aieo-key'), q);
        });

        // Remove — beat WC's global delegated handler with stopImmediatePropagation.
        $(document).on('click.aieoSideCart', '.aieo-sc-root [data-aieo-remove]', function (e) {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            var $item = $(this).closest('.mini_cart_item');
            var key = $item.attr('data-aieo-key');
            $item.css({opacity: 0.4, pointerEvents: 'none'});
            // Bump the in-flight refcount so the cross-component
            // listeners (`wc_fragments_refreshed`, `removed_from_cart`,
            // `updated_wc_div`) treat the imminent updates as our own
            // and don't loop us back into another refresh round.
            state.selfAjaxInFlight = (state.selfAjaxInFlight || 0) + 1;
            $.post(aieoSideCart.ajaxurl, {
                action: 'aieo_side_cart_remove',
                nonce:  $root.data('nonce'),
                key:    key
            }).done(function (resp) {
                if (!resp || !resp.success) { $item.css({opacity: 1, pointerEvents: ''}); return; }
                $root.find('[data-aieo-sc-content]').html(resp.data.content || '');
                updateCountAndSubtotal(resp.data);
                paintFreeShippingBar(parseFloat($root.data('threshold')) || 0, parseFloat(resp.data.subtotal_raw) || 0);

                // Tier 2C — emit the canonical removed_from_cart event so
                // theme widgets, GA4, IAPI, etc. sync. We pass `null`
                // fragments on purpose: WC core's updateFragments
                // (add-to-cart.js) is guarded by `if (fragments)` and
                // short-circuits, skipping the .fadeTo+blockUI animation
                // that would pin an overlay over the cart-count badge
                // for 400 ms (intercepts subsequent clicks). The actual
                // fragment DOM work + sessionStorage update is done by
                // applyFragmentsInline AFTER the trigger so its writes
                // win over WC cart-fragments.js's set_cart_hash call.
                // Our own removed_from_cart listener self-suppresses
                // via selfAjaxInFlight.
                $(document.body).trigger('removed_from_cart', [
                    null,
                    resp.data.cart_hash || '',
                    null
                ]);
                applyFragmentsInline(resp.data);

                // Push the fresh cart state into the WC blocks redux
                // store so the /cart and /checkout React trees re-render
                // their line items, totals and SHIPPING RATES. Without
                // this call, the block checkout keeps showing the
                // pre-remove rate list — including the "all carriers
                // free" set after the cart drops below the threshold,
                // which makes the TRS carriers appear to vanish.
                if (typeof window.aieoForceCartRefresh === 'function') {
                    window.aieoForceCartRefresh();
                }
                var bc = document.body && document.body.classList;
                if (bc && bc.contains('woocommerce-checkout')) {
                    $(document.body).trigger('update_checkout');
                }
            }).always(function () {
                setTimeout(function () {
                    state.selfAjaxInFlight = Math.max(0, (state.selfAjaxInFlight || 0) - 1);
                }, 2000);
            });
            return false;
        });

        // YOU MAY ALSO LIKE carousel — dot click advances the track. The
        // track has gap:16px between full-width slides, so we translate by
        // (slideWidth + gap) * index instead of a flat -100% (which would
        // leave a 16px sliver of the previous slide visible). Measuring the
        // first slide's outerWidth handles any future width tweak.
        $root.on('click', '.aieo-sc-maylike-dot', function () {
            var $btn = $(this);
            var i = parseInt($btn.attr('data-i'), 10) || 0;
            slideMaylikeTo(i);
        });
        // Prev/next arrows — desktop-friendly because the dots are too
        // small to target with a cursor. CSS hides them under 600px so
        // the dots remain the primary touch target on mobile.
        $root.on('click', '[data-aieo-sc-maylike-prev]', function () {
            slideMaylikeTo(state.maylikeIndex - 1);
        });
        $root.on('click', '[data-aieo-sc-maylike-next]', function () {
            slideMaylikeTo(state.maylikeIndex + 1);
        });

        // Quick view trigger (Discover it) — opens the side-cart panel and
        // populates its quick-view body from the clicked product card's
        // own DOM. No AJAX round-trip for the basic display: the image,
        // title, price, and add-to-cart button are all already on the
        // archive page. Bound at document level so it works no matter
        // where the trigger button is rendered (some themes wrap product
        // cards in custom containers).
        $(document).on('click.aieoSideCartQv', '.aieo-quick-view-trigger', function (e) {
            e.preventDefault();
            e.stopPropagation();
            openQuickView($(this));
        });

        // Quick view add-to-cart — own button + own server endpoint.
        // Reliable across cart/checkout/home pages because it uses the
        // standard admin-ajax URL that the side cart already loads with.
        // Variation-aware: reads the selected variation_id off the
        // quick-view body (set by swatch clicks below).
        $root.on('click', '[data-aieo-sc-qv-add]', function (e) {
            e.preventDefault();
            e.stopPropagation();
            var $btn = $(this);
            if ($btn.hasClass('is-loading')) return;
            var pid = parseInt($btn.attr('data-aieo-sc-qv-add'), 10) || 0;
            if (pid <= 0) return;

            var $qvBody = $root.find('[data-aieo-sc-body="quickview"]');
            var variationId = parseInt($qvBody.attr('data-aieo-sc-qv-variation'), 10) || 0;
            var variation = {};
            $qvBody.find('[data-aieo-sc-qv-attr]').each(function () {
                var $row = $(this);
                var name = $row.attr('data-aieo-sc-qv-attr');
                var value = $row.attr('data-aieo-sc-qv-attr-value') || '';
                if (name && value) { variation[name] = value; }
            });

            sideCartAddProductOptimistic({
                productId:   pid,
                variationId: variationId,
                variation:   variation,
                $btn:        $btn,
                onError: function (resp) {
                    if (resp && resp.data && resp.data.product_url) {
                        window.location = resp.data.product_url;
                    }
                }
            });
        });

        // Variation select change — same job as the swatch click did:
        // record the chosen value on the row and resolve the matching
        // variation_id. <select> rather than chips because variant
        // lists can be long (10+ shades) and dropdowns scale better.
        $root.on('change', '[data-aieo-sc-qv-select]', function () {
            var $sel = $(this);
            var $row = $sel.closest('[data-aieo-sc-qv-attr]');
            $row.attr('data-aieo-sc-qv-attr-value', $sel.val() || '');
            recomputeQuickViewVariation();
        });

        // Coupon toggle + submit. Track open state on the toggle itself
        // (`.is-open`) so the CSS can prefix it with `×` when expanded —
        // matches Rey's "× Have a Coupon?" treatment.
        $root.on('click', '[data-aieo-sc-coupon-toggle]', function () {
            var $form = $root.find('[data-aieo-sc-coupon-form]');
            var willOpen = !$form.is(':visible');
            $form.toggle();
            $(this).toggleClass('is-open', willOpen);
        });
        $root.on('submit', '[data-aieo-sc-coupon-form]', function (e) {
            e.preventDefault();
            var code = $(this).find('input[name=coupon_code]').val();
            $.post(aieoSideCart.ajaxurl, {
                action: 'aieo_side_cart_apply_coupon',
                nonce:  $root.data('nonce'),
                coupon_code: code
            }).done(function (resp) {
                if (resp && resp.success) {
                    $root.find('[data-aieo-sc-content]').html(resp.data.content || '');
                    updateCountAndSubtotal(resp.data);
                    paintFreeShippingBar(parseFloat($root.data('threshold')) || 0, parseFloat(resp.data.subtotal_raw) || 0);
                    // Tier 2C — coupon application changes totals; mirror
                    // them into WC fragments so external mini-cart widgets
                    // sync without a get_refreshed_fragments roundtrip.
                    applyFragmentsInline(resp.data);
                }
            });
        });

        // WC fragments — listen for external cart-state events. `added_to_cart`
        // gets its own branch so the openOnAdd setting can pop the panel open
        // when a shopper adds something from outside the side cart (product
        // page button, archive quick-add, etc).
        $(document.body).on('added_to_cart', function () {
            state.initialLoad = false;
            // If the shopper added from the quick-view body, swap them
            // back to the SHOPPING BAG tab so they see what they just
            // added — matches Sephora's pattern.
            var $qv = $root.find('[data-aieo-sc-body="quickview"]');
            if ($qv.length && !$qv.prop('hidden')) {
                switchToTab('bag');
            }
            // Rate-limit auto-open: WC blocks (and some 3rd-party
            // pixels/trackers like PYS) bridge their internal "cart
            // refreshed" events to the legacy `added_to_cart` event for
            // backwards compatibility. Without a rate-limit here, the
            // panel re-opens on every refresh, making it impossible for
            // the shopper to close it. Honour the most recent USER close
            // by suppressing auto-open for 5 seconds after.
            var now = Date.now();
            var lastClose = state.lastUserCloseAt || 0;
            var withinUserCloseWindow = (now - lastClose) < 5000;
            if (aieoSideCart.openOnAdd && !withinUserCloseWindow) {
                // openCart() triggers a fresh fetchPanel() the first time it
                // opens — flipping initialLoad to false above guarantees that
                // happens even if the panel had been opened earlier.
                openCart();
                return;
            }
            if (state.opened) { fetchPanel(); }
            else { refreshCountBadgeOnly(); }
        });
        // Cart-state events. `wc_fragments_refreshed` is included so the
        // side cart picks up cart changes initiated elsewhere (cart page
        // qty/shipping/coupon, mini-cart in another tab, etc.) AND so
        // downstream listeners — the cart page's update_checkout / order
        // review trigger — keep working. The risk is recursion: our own
        // refreshCountBadgeOnly()/fetchPanel() AJAX makes WC invalidate
        // fragments, which fires wc_fragments_refreshed, which would
        // re-enter this handler. We break that with a refcount guard:
        // while we have an in-flight or recently-completed self-initiated
        // AJAX, ignore wc_fragments_refreshed (the work it would do is
        // already underway). User-initiated events (added_to_cart,
        // removed_from_cart, updated_wc_div) always pass through.
        $(document.body).on('removed_from_cart updated_wc_div wc_fragments_refreshed', function () {
            // Guard against ALL three event types when our own AJAX is in
            // flight or recently completed — on /cart, WC fires
            // `updated_wc_div` AFTER every cart-fragments refresh too, and
            // gating only on `wc_fragments_refreshed` left a loop where
            // updated_wc_div re-entered fetchPanel → AJAX → fragments
            // refresh → updated_wc_div → loop.
            if ((state.selfAjaxInFlight || 0) > 0) { return; }
            state.initialLoad = false;
            if (state.opened) { fetchPanel(); }
            else { refreshCountBadgeOnly(); }
        });
    }

    // ------------------------------------------------------------------
    // Fast-path quantity update
    // ------------------------------------------------------------------
    function applyOptimisticQtyChange($item, nextQty) {
        var unit = $item.data('aieo-unit-price');
        if (unit == null) {
            var priceText = $item.find('.aieo-line-total, .woocommerce-mini-cart-price').first().text() || '0';
            var oldQty = parseInt($item.data('aieo-last-qty'), 10) || 1;
            unit = parseFloat(priceText.replace(/[^0-9.,-]/g, '').replace(',', '.')) / oldQty;
            $item.data('aieo-unit-price', unit);
        }
        $item.data('aieo-last-qty', nextQty);
        var newTotal = unit * nextQty;
        $item.find('.aieo-line-total, .woocommerce-mini-cart-price').first().text(formatMoney(newTotal));

        // Sum every line. Lazily seed `aieo-unit-price` on items the
        // shopper hasn't touched yet — without this, untouched lines
        // contribute 0 to the subtotal because the data attr is only
        // set inside the qty-change branch above. Symptom of the
        // omission: subtotal equals just the changed line's total
        // regardless of what else is in the cart.
        var subtotal = 0;
        $root.find('.mini_cart_item').each(function () {
            var $it = $(this);
            if ($it.hasClass('is-gift')) return;
            var q  = parseInt($it.find('.aieo-qty-input').val(), 10) || 0;
            var up = $it.data('aieo-unit-price');
            if (up == null) {
                var lineTxt = $it.find('.aieo-line-total, .woocommerce-mini-cart-price').first().text() || '0';
                var lineTotal = parseFloat(lineTxt.replace(/[^0-9.,-]/g, '').replace(',', '.')) || 0;
                up = q > 0 ? lineTotal / q : 0;
                $it.data('aieo-unit-price', up);
            }
            subtotal += up * q;
        });
        $root.find('[data-aieo-sc-subtotal]').text(formatMoney(subtotal));
        paintFreeShippingBar(parseFloat($root.data('threshold')) || 0, subtotal);
    }

    function schedulePersist(key, qty) {
        var prev = state.debounceHandles[key];
        if (prev) { clearTimeout(prev); }
        state.debounceHandles[key] = setTimeout(function () {
            delete state.debounceHandles[key];
            trackedPost({
                action: 'aieo_side_cart_update_qty',
                nonce:  $root.data('nonce'),
                key:    key,
                qty:    qty
            }).done(function (resp) {
                if (!resp || !resp.success) { return; }
                updateCountAndSubtotal(resp.data);
                paintFreeShippingBar(parseFloat($root.data('threshold')) || 0, parseFloat(resp.data.subtotal_raw) || 0);
                if (resp.data.gifts_changed && resp.data.content) {
                    $root.find('[data-aieo-sc-content]').html(resp.data.content);
                }
                // Tier 2C — apply fragments inline so any header mini-cart
                // count badge / off-side-cart fragment reflects the new
                // qty without a parallel get_refreshed_fragments AJAX.
                // No canonical WC event fires for qty changes, so just
                // apply directly + prime cart-fragments storage.
                applyFragmentsInline(resp.data);
            });
        }, aieoSideCart.debounce || 500);
    }

    // Lightweight ping that only refreshes the trigger count + subtotal
    // badge — used when a cart-state event fires but the panel is closed
    // and we don't need to repaint its contents. Also picks up the
    // server's `cart_ids` so the in-cart label stays accurate even
    // when the cart was mutated outside the side cart.
    /**
     * Wrap a $.post call so it bumps state.selfAjaxInFlight while the
     * AJAX (and a small grace window after) is pending. The fragments-
     * refreshed listener uses this counter to skip recursive triggers
     * caused by our own server-side cart writes.
     *
     * On the /checkout page, also fire `update_checkout` after the AJAX
     * completes — WC's checkout.js refreshes the order review (totals,
     * shipping methods, free-shipping eligibility) on that event but does
     * NOT listen for cart-fragments events, so without this trigger the
     * order review stays stale after a side-cart qty/add/remove.
     */
    function trackedPost(args) {
        state.selfAjaxInFlight = (state.selfAjaxInFlight || 0) + 1;
        var jqxhr = $.post(aieoSideCart.ajaxurl, args);
        jqxhr.always(function () {
            // Grace window — covers the full feedback cycle our cart write
            // triggers on /cart:
            //   1. our AJAX returns        (~100 ms)
            //   2. cart hash cookie flips
            //   3. WC cart-fragments JS detects + fires get_refreshed_fragments (~200-500 ms later)
            //   4. get_refreshed_fragments completes + fires wc_fragments_refreshed AND
            //      cart.js fires updated_wc_div on /cart specifically
            // Total round-trip: 600-1000 ms in practice. 2000 ms is enough
            // headroom on a slow page without making user-triggered events
            // feel laggy (a real user click after this still works because
            // the listener checks at event-fire time, not at click).
            setTimeout(function () {
                state.selfAjaxInFlight = Math.max(0, (state.selfAjaxInFlight || 0) - 1);
            }, 2000);
        });
        jqxhr.done(function () {
            // Cross-component refresh — push fresh cart data into the
            // WC blocks store via aieoForceCartRefresh() (defined in
            // aieo-checkout-block-extensions.js). It does an apiFetch
            // of /wc/store/v1/cart and dispatches receiveCart() so the
            // React Cart and Checkout blocks re-render immediately.
            // No-op when wc/store/cart isn't registered (e.g., /shop or
            // /product pages).
            //
            // Also fire the legacy `update_checkout` event for any
            // shortcode-based checkout that might still be in play.
            // Legacy `wc_fragment_refresh` is fired for any header
            // mini-cart widget that hasn't been migrated to blocks.
            //
            // The selfAjaxInFlight guard (2s) prevents the recursive
            // loop these triggers would otherwise create.
            if (typeof document === 'undefined' || !document.body) { return; }

            if (typeof window.aieoForceCartRefresh === 'function') {
                window.aieoForceCartRefresh();
            }

            var bc = document.body.classList;
            if (bc.contains('woocommerce-checkout')) {
                // legacy shortcode checkout (if anyone still has one)
                $(document.body).trigger('update_checkout');
            }
            // Note: previously fired `wc_fragment_refresh` on /cart so
            // header mini-cart widgets refreshed via WC's
            // get_refreshed_fragments AJAX. Each side-cart success now
            // calls applyFragmentsInline(), which already replaces every
            // fragment selector and primes WC's sessionStorage — the
            // separate AJAX would just duplicate that work (~70 ms).
        });
        return jqxhr;
    }

    function refreshCountBadgeOnly() {
        trackedPost({
            action: 'aieo_side_cart_get',
            nonce:  $root.data('nonce')
        }).done(function (resp) {
            if (resp && resp.success) { updateCountAndSubtotal(resp.data); }
        });
    }

    // Expose a global handle so external scripts (e.g.
    // aieo-brand-gifts.js for the single-product picker / reward
    // cards) can request a side-cart panel refresh after they
    // modify state.
    window.aieoSideCartFetch = function () { fetchPanel(); };

    function fetchPanel() {
        trackedPost({
            action: 'aieo_side_cart_get',
            nonce:  $root.data('nonce')
        }).done(function (resp) {
            if (!resp || !resp.success) return;
            $root.find('[data-aieo-sc-content]').html(resp.data.content || '');
            // Free-gift offer banners — server sends pre-rendered HTML
            // for every active offer (or empty when none qualify yet).
            if (typeof resp.data.gifts_html !== 'undefined') {
                $root.find('[data-aieo-sc-brand-gifts]').html(resp.data.gifts_html);
            }
            // Reward cards (A1) below cart items.
            if (typeof resp.data.rewards_html !== 'undefined') {
                $root.find('[data-aieo-sc-rewards]').html(resp.data.rewards_html);
            }
            updateCountAndSubtotal(resp.data);
            paintFreeShippingBar(parseFloat($root.data('threshold')) || 0, parseFloat(resp.data.subtotal_raw) || 0);
        });
    }

    // Cache the cart-wide {variation_id: qty} map served by every
    // side-cart response. The swatch widgets on the page read from
    // window.aieoCartVariations as their hot path — no separate AJAX
    // needed. Triggering `om_cart_data_refreshed` lets the swatch
    // client re-paint its in-cart indicators immediately.
    function consumeCartVariations(data) {
        if (!data) { return; }
        var changed = false;
        try {
            if (typeof data.cart_variations !== 'undefined') {
                window.aieoCartVariations = data.cart_variations || {};
                changed = true;
            }
            // Parent {product_id: total_qty} map — drives PDP add-to-cart
            // `+N` badge sync (FF clicker subscribes to aieo:cart:updated).
            // Same swatches-style live repaint pattern.
            if (typeof data.cart_qty_by_product !== 'undefined') {
                window.aieoCartQtyByProduct = data.cart_qty_by_product || {};
                changed = true;
            }
            if (changed) {
                // Migrated event — matches the shape aieo-iapi-bootstrap.js
                // dispatches (CustomEvent on document.body with detail
                // { fragments, cart_hash, ... }). Single source of truth
                // for IAPI consumers + legacy jQuery widgets.
                try {
                    document.body.dispatchEvent(new CustomEvent('aieo:cart:updated', {
                        bubbles: true,
                        detail: {
                            fragments:           data.fragments || {},
                            cart_hash:           data.cart_hash || '',
                            cart_variations:     window.aieoCartVariations || {},
                            cart_qty_by_product: window.aieoCartQtyByProduct || {}
                        }
                    }));
                } catch (e) {}
                // Legacy jQuery event — still emitted because aieo-swatches.js
                // hasn't migrated off `om_cart_data_refreshed` yet.
                $(document.body).trigger('om_cart_data_refreshed');
            }
        } catch (e) { /* never let this break the cart UI */ }
    }

    // Tier 2C 2026-05-07 — apply WC fragments + cart_hash from a side-cart
    // mutation response so the JS doesn't need a parallel
    // wc-ajax:get_refreshed_fragments roundtrip. Replaces each fragment
    // selector and primes WC's sessionStorage so cart-fragments.js sees
    // them as fresh on next focus / visibility change. Safe to call
    // when fragments is missing / empty (no-op).
    function applyFragmentsInline(data) {
        if (!data) { return; }
        var fragments = data.fragments;
        var hasFragments = fragments
            && typeof fragments === 'object'
            && !Array.isArray(fragments)
            && Object.keys(fragments).length > 0;
        if (hasFragments) {
            Object.keys(fragments).forEach(function (sel) {
                try { $(sel).replaceWith(fragments[sel]); } catch (e) {}
            });
        }
        // Persist to WC's cart-fragments storage so cart-fragments.js's
        // boot-time hydration (and storage-event check across tabs)
        // skips its own fragment-refresh AJAX — the storage hash will
        // match the cookie hash WC sets server-side after a cart write.
        var p = (typeof window !== 'undefined') ? window.wc_cart_fragments_params : null;
        if (p && data.cart_hash) {
            try {
                if (hasFragments) {
                    sessionStorage.setItem(p.fragment_name, JSON.stringify(fragments));
                }
                sessionStorage.setItem(p.cart_hash_key, String(data.cart_hash));
                localStorage.setItem(p.cart_hash_key, String(data.cart_hash));
                sessionStorage.setItem('wc_cart_created', (new Date()).getTime());
            } catch (e) {}
        }
    }

    function updateCountAndSubtotal(data) {
        consumeCartVariations(data);
        if (data.count != null) {
            $root.find('.aieo-sc-trigger-count').text(data.count);
            $root.find('[data-aieo-sc-count]').text(data.count);
        }
        if (data.subtotal) {
            $root.find('[data-aieo-sc-subtotal]').html(data.subtotal);
        }
        // Server response also carries the latest cart product IDs —
        // refresh the local mirror so RV/Favorites cards know which
        // products are already in the bag.
        if (data.cart_ids && data.cart_ids.length !== undefined) {
            updateCartContents(data.cart_ids);
        }
    }

    // ------------------------------------------------------------------
    // "Already in bag" tracking — keeps `aieoSideCart.cartContents` in
    // sync with the server's authoritative cart_ids list. Re-paints
    // the RV + Favorites bodies if either is currently visible so the
    // ADD TO BAG / IN BAG label flips immediately.
    // ------------------------------------------------------------------
    function updateCartContents(ids) {
        var nextIds = (ids || []).map(function (x) { return parseInt(x, 10) || 0; }).filter(Boolean);
        var prevIds = (aieoSideCart.cartContents || []).map(function (x) { return parseInt(x, 10) || 0; });
        // Cheap equality check — same length AND same sorted contents.
        if (nextIds.length === prevIds.length) {
            var a = nextIds.slice().sort(function (x, y) { return x - y; });
            var b = prevIds.slice().sort(function (x, y) { return x - y; });
            var same = true;
            for (var i = 0; i < a.length; i++) { if (a[i] !== b[i]) { same = false; break; } }
            if (same) { return; } // nothing to do
        }
        aieoSideCart.cartContents = nextIds;
        repaintInCartLabels();
    }
    function isProductInCart(pid) {
        var ids = aieoSideCart.cartContents || [];
        return ids.indexOf(parseInt(pid, 10) || 0) !== -1;
    }
    // Walk every RV/Favorites/maylike ATC button and flip its label +
    // state based on the current cart contents. Each context has its
    // own "default" label setting (RV/Fav → rvAtc, maylike → addToBag),
    // so we read the appropriate one when restoring the non-in-cart
    // state. Cheaper than re-rendering the whole grid/carousel.
    function repaintInCartLabels() {
        var inCartLabel = (aieoSideCart.i18n && aieoSideCart.i18n.inCart)   || __('✓ IN BAG', 'ai-eshop-optimizer');
        var rvLabel     = (aieoSideCart.i18n && aieoSideCart.i18n.rvAtc)    || __('ADD TO BAG', 'ai-eshop-optimizer');
        var maylikeLabel= (aieoSideCart.i18n && aieoSideCart.i18n.addToBag) || __('ADD TO BAG', 'ai-eshop-optimizer');
        $root.find('[data-aieo-sc-rv-add]').each(function () {
            var $btn = $(this);
            var pid = parseInt($btn.attr('data-aieo-sc-rv-add'), 10) || 0;
            if (pid <= 0) return;
            var inCart = isProductInCart(pid);
            $btn.toggleClass('is-in-cart', inCart);
            // Don't touch a button mid-loading state.
            if ($btn.hasClass('is-loading')) return;
            var defaultLabel = $btn.hasClass('aieo-sc-maylike-add') ? maylikeLabel : rvLabel;
            $btn.text(inCart ? inCartLabel : defaultLabel);
        });
    }

    // ------------------------------------------------------------------
    // Free-shipping bar (client-side, driven by subtotal_raw)
    // ------------------------------------------------------------------
    function paintFreeShippingBar(threshold, current) {
        if (!threshold || threshold <= 0) {
            $root.find('[data-aieo-sc-fs]').hide();
            return;
        }
        $root.find('[data-aieo-sc-fs]').show();
        var remaining = Math.max(0, threshold - current);
        var qualified = remaining <= 0;
        var pct = Math.min(100, (current / threshold) * 100);
        $root.find('[data-aieo-sc-fs-fill]').css('width', pct + '%');
        var tpl = qualified
            ? ($root.data('msg-success') || '')
            : ((current > 0 ? $root.data('msg-progress') : $root.data('msg-initial')) || '');
        var html = tpl
            .replace(/\{amount\}/g,    formatMoney(threshold))
            .replace(/\{remaining\}/g, formatMoney(remaining));
        $root.find('[data-aieo-sc-fs-msg]').html(html);
    }

    // ------------------------------------------------------------------
    // Recommendations (left sidebar + bottom carousel)
    // Pull DTO arrays from the bundle endpoint OR the localStorage
    // mirror; render via window.aieoCard.render(). Same DTOs the
    // product-page consumer uses, with a different CSS scope.
    // ------------------------------------------------------------------
    function hydrateRecommendations() {
        var $rightList   = $root.find('[data-aieo-sc-reco-list]');
        var $bottomTrack = $root.find('[data-aieo-sc-maylike-track]');
        if (!$rightList.length && !$bottomTrack.length) return;

        var seedPid = readSeedProductId();
        if (!seedPid) {
            // Nothing to seed off — hide the (empty) reco sidebar so we don't
            // show an empty "You might like.." pane.
            if ($rightList.length) { paintRecoList($rightList, []); }
            return;
        }

        if (window.aieoBundleFetcher) {
            window.aieoBundleFetcher.fetch(seedPid).done(function (data) {
                if (!data) return;
                // Record the seed on the panel root so card clicks can
                // carry it as source_pid attribution context.
                $root.attr('data-aieo-seed-pid', String(seedPid));
                paintRecoList($rightList,   pickArr(data.upsells, data.related));
                var ml = pickMaylikeSource(data);
                paintMaylike($bottomTrack, ml.dtos, ml.source);
            });
            return;
        }
        // Defensive fallback if bundle fetcher hasn't loaded yet.
        $.post(aieoSideCart.ajaxurl, {
            action: 'aieo_get_product_bundle',
            nonce:  bundleNonce(),
            product_id: seedPid
        }).done(function (resp) {
            if (!resp || !resp.success || !resp.data) return;
            $root.attr('data-aieo-seed-pid', String(seedPid));
            paintRecoList($rightList,   pickArr(resp.data.upsells, resp.data.related));
            var ml = pickMaylikeSource(resp.data);
            paintMaylike($bottomTrack, ml.dtos, ml.source);
        });
    }

    function paintRecoList($list, dtos) {
        if (!$list.length) return;
        // Hide the whole left recommendations sidebar (+ its collapse toggle) when
        // there's nothing to show, so it doesn't render as an empty pane with only
        // the "You might like.." title. is-reco-empty collapses the grid column to
        // 0 and hides the toggle (mirrors is-reco-collapsed, minus the toggle).
        if (!Array.isArray(dtos) || !dtos.length) {
            $list.empty();
            $root.addClass('is-reco-empty');
            return;
        }
        $root.removeClass('is-reco-empty');
        $list.empty();
        dtos.slice(0, 6).forEach(function (dto) {
            $list.append('<li class="aieo-sc-reco-item">' + window.aieoCard.render(dto, SIZES_HINT.recoList, 'side_cart') + '</li>');
        });
    }

    function paintMaylike($track, dtos, sourceKey) {
        if (!$track.length || !Array.isArray(dtos) || !dtos.length) return;
        sourceKey = sourceKey || 'sidecart_may_also_like';
        var slice = dtos.slice(0, 4);
        var addLabel    = (aieoSideCart.i18n && aieoSideCart.i18n.addToBag) || __('ADD TO BAG', 'ai-eshop-optimizer');
        var inCartLabel = (aieoSideCart.i18n && aieoSideCart.i18n.inCart)   || __('✓ IN BAG', 'ai-eshop-optimizer');
        $track.empty();
        slice.forEach(function (dto) {
            var pid = parseInt(dto.id, 10) || 0;
            var inCart = pid > 0 && isProductInCart(pid);
            var seedPid = parseInt($root.attr('data-aieo-seed-pid'), 10) || 0;
            $track.append(
                '<div class="aieo-sc-maylike-slide">' +
                    window.aieoCard.render(dto, SIZES_HINT.maylike, 'side_cart') +
                    '<button type="button"'
                        + ' class="aieo-sc-maylike-add aieo-sc-rv-add' + (inCart ? ' is-in-cart' : '') + '"'
                        + ' data-aieo-sc-rv-add="' + pid + '"'
                        + ' data-product-type="' + escAttr(dto.type || '') + '"'
                        + ' data-permalink="' + escAttr(dto.permalink || '') + '"'
                        + ' data-aieo-source="' + escAttr(sourceKey) + '"'
                        + ' data-aieo-source-pid="' + seedPid + '">'
                        + escHtml(inCart ? inCartLabel : addLabel) +
                    '</button>' +
                '</div>'
            );
        });
        renderDots(slice.length);
    }

    /**
     * Pick the "may also like" dataset to display, preserving which
     * array it came from so attribution can distinguish product-needs
     * adds from generic crosssells/related. Needs rank first because
     * they're the most specific recommendation class.
     */
    function pickMaylikeSource(data) {
        if (Array.isArray(data.needs) && data.needs.length) {
            return { dtos: data.needs, source: 'sidecart_product_needs' };
        }
        if (Array.isArray(data.crosssells) && data.crosssells.length) {
            return { dtos: data.crosssells, source: 'sidecart_may_also_like' };
        }
        if (Array.isArray(data.related) && data.related.length) {
            return { dtos: data.related, source: 'sidecart_may_also_like' };
        }
        return { dtos: [], source: 'sidecart_may_also_like' };
    }

    function renderDots(n) {
        var $dots = $root.find('[data-aieo-sc-maylike-dots]').empty();
        for (var i = 0; i < n; i++) {
            $dots.append('<button type="button" class="aieo-sc-maylike-dot' + (i === 0 ? ' is-active' : '') + '" data-i="' + i + '"></button>');
        }
        // Reset the track to the first slide whenever we re-render.
        slideMaylikeTo(0);
    }

    /**
     * Translate the YOU MAY ALSO LIKE track to a given slide index and
     * mark the matching dot as active. Pixel-based offset includes the
     * inter-slide gap; percent-based would leave an unsightly seam.
     * Also keeps the prev/next arrow disabled state in sync with bounds.
     */
    function slideMaylikeTo(index) {
        var $track = $root.find('[data-aieo-sc-maylike-track]');
        if (!$track.length) return;
        var $slides = $track.children('.aieo-sc-maylike-slide');
        if (!$slides.length) return;
        var max = $slides.length - 1;
        if (index < 0)   { index = 0; }
        if (index > max) { index = max; }
        var slideWidth = $slides.first().outerWidth() || 0;
        var gap = parseFloat($track.css('gap')) || parseFloat($track.css('column-gap')) || 0;
        var offset = (slideWidth + gap) * index;
        $track.css('transform', 'translateX(-' + offset + 'px)');
        $root.find('.aieo-sc-maylike-dot').removeClass('is-active');
        $root.find('.aieo-sc-maylike-dot[data-i="' + index + '"]').addClass('is-active');
        state.maylikeIndex = index;
        state.maylikeMax   = max;
        $root.find('[data-aieo-sc-maylike-prev]').prop('disabled', index <= 0);
        $root.find('[data-aieo-sc-maylike-next]').prop('disabled', index >= max);
    }

    // Pick the first non-empty DTO array from the candidates.
    function pickArr() {
        for (var i = 0; i < arguments.length; i++) {
            var a = arguments[i];
            if (Array.isArray(a) && a.length) return a;
        }
        return [];
    }

    // ------------------------------------------------------------------
    // Recently-viewed tab — DTO-shaped now. localStorage-first, with
    // the cookie-driven endpoint as the fallback. Renders via the same
    // compact card markup as the left sidebar.
    // ------------------------------------------------------------------
    function hydrateRecentlyViewed(force) {
        var $body = $root.find('[data-aieo-sc-body="viewed"]');
        if (!$body.length) return;

        if (!force) {
            var cached = readLocalRv();
            if (cached && Array.isArray(cached.products) && cached.products.length) {
                paintRecentlyViewed($body, cached.products);
                $root.find('[data-aieo-sc-rv-count]').text((cached.ids || cached.products).length);
                return;
            }
        }

        $.post(aieoSideCart.ajaxurl, {
            action: 'aieo_get_recently_viewed_html',
            nonce:  bundleNonce()
        }).done(function (resp) {
            if (resp && resp.success && resp.data && Array.isArray(resp.data.products) && resp.data.products.length) {
                paintRecentlyViewed($body, resp.data.products);
                $root.find('[data-aieo-sc-rv-count]').text((resp.data.ids || resp.data.products).length);
                writeLocalRv({
                    products: resp.data.products,
                    ids:      Array.isArray(resp.data.ids) ? resp.data.ids : [],
                    ts:       Date.now()
                });
            } else {
                var emptyMsg = (aieoSideCart.i18n && aieoSideCart.i18n.rvEmpty) || __('No recently viewed products.', 'ai-eshop-optimizer');
                $body.html('<p class="aieo-sc-rv-empty"></p>');
                $body.find('.aieo-sc-rv-empty').text(emptyMsg);
                $root.find('[data-aieo-sc-rv-count]').text('0');
            }
        });
    }

    function paintRecentlyViewed($body, dtos) {
        $body.html(renderProductGrid(dtos, 'aieo-sc-rv-list'));
    }

    // Shared grid renderer for the RV and Favorites tabs. Each card
    // gets a heart toggle (top-right) + ADD TO BAG action below the
    // existing card. Layout shifts to a 2-column grid on narrow
    // viewports via CSS — `aieo-sc-product-grid` is the hook.
    function renderProductGrid(dtos, listClass) {
        var atcLabel    = (aieoSideCart.i18n && aieoSideCart.i18n.rvAtc)  || __('ADD TO BAG', 'ai-eshop-optimizer');
        var inCartLabel = (aieoSideCart.i18n && aieoSideCart.i18n.inCart) || __('✓ IN BAG', 'ai-eshop-optimizer');
        // Source key per list — see ATTRIBUTION-SCHEMA-BRIEF.md §2.
        var sourceKey = (listClass.indexOf('aieo-sc-fav-list') !== -1)
            ? 'sidecart_favorites'
            : 'sidecart_recently_viewed';
        var html = '<ul class="aieo-sc-reco-list aieo-sc-product-grid ' + listClass + '">';
        dtos.forEach(function (dto) {
            var pid = parseInt(dto.id, 10) || 0;
            var fav = pid > 0 && isFavorite(pid);
            var inCart = pid > 0 && isProductInCart(pid);
            var type = dto.type || '';
            var perma = dto.permalink || '';
            html += '<li class="aieo-sc-reco-item">'
                  +   '<button type="button"'
                  +     ' class="aieo-sc-fav-toggle' + (fav ? ' is-favorited' : '') + '"'
                  +     ' data-aieo-sc-fav-toggle="' + pid + '"'
                  +     ' aria-pressed="' + (fav ? 'true' : 'false') + '"'
                  +     ' aria-label="' + escAttr(__('Favorite', 'ai-eshop-optimizer')) + '">'
                  +     '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">'
                  +       '<path d="M12 21s-7.5-4.7-9.5-9.4C1.1 8.3 3 5 6.2 5c1.9 0 3.4 1 4.5 2.7C11.7 6 13.2 5 15.1 5c3.2 0 5.1 3.3 3.7 6.6C18.5 16.3 12 21 12 21z"'
                  +         ' fill="' + (fav ? 'currentColor' : 'none') + '"'
                  +         ' stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>'
                  +     '</svg>'
                  +   '</button>'
                  +   window.aieoCard.render(dto, SIZES_HINT.recentlyViewed, 'side_cart')
                  +   '<button type="button"'
                  +     ' class="aieo-sc-rv-add' + (inCart ? ' is-in-cart' : '') + '"'
                  +     ' data-aieo-sc-rv-add="' + pid + '"'
                  +     ' data-product-type="' + escAttr(type) + '"'
                  +     ' data-permalink="' + escAttr(perma) + '"'
                  +     ' data-aieo-source="' + sourceKey + '">'
                  +     escHtml(inCart ? inCartLabel : atcLabel)
                  +   '</button>'
                  + '</li>';
        });
        html += '</ul>';
        return html;
    }

    // Pull DTOs for the viewer's favorites and paint them.
    //
    // PR #4 (brand-shopping): Favorites is now multi-type with subtabs —
    // Products / Articles / Brands / Categories / Needs. Each subtab
    // is fetched lazily on first activation; counts update via badge
    // pills next to the labels. The Products tab is the default.
    //
    // Force=true wipes the cached payload + re-fetches every section.
    // Display order: Products → Brands → Categories → Wishes (Επιθυμίες) →
    // Posts. The shopper-centric ordering puts the most-tactile entry
    // (their saved products) first, then their brand affinities, then
    // narrower discovery surfaces. Posts last because reading content
    // is a less common follow-up than re-buying.
    //
    // Label note (2026-05-04): pa-need's customer-visible label switched
    // Ανάγκες → Επιθυμίες — softer, aspirational, off-the-needy tone for
    // a beauty / fragrance audience. The taxonomy SLUG `pa_need` and
    // URL `/need/<slug>/` are unchanged — only the display label moves.
    var FAV_SUBTABS = [
        { key: 'product',     i18n: 'favSubProducts',   labelDefault: 'Προϊόντα'   },
        { key: 'brand',       i18n: 'favSubBrands',     labelDefault: 'Brands'     },
        { key: 'product_cat', i18n: 'favSubCategories', labelDefault: 'Κατηγορίες' },
        { key: 'pa-need',     i18n: 'favSubNeeds',      labelDefault: 'Επιθυμίες'  },
        { key: 'post',        i18n: 'favSubArticles',   labelDefault: 'Άρθρα'      }
    ].filter(function (s) {
        // Show a favourites sub-tab only for wishlist types the shop manager
        // enabled in the Wishlist-display settings. null/undefined = all on.
        // Filtering here also skips each disabled type's fetch, since
        // fetchFavSubtab() is only reached via tabs present in the shell.
        var en = aieoSideCart && aieoSideCart.favEnabledTypes;
        return !en || en.indexOf(s.key) !== -1;
    });
    var favCache = { product: null, post: null, brand: null, product_cat: null, 'pa-need': null };
    // Default sub-tab must still exist after filtering, else switchFavSubtab()
    // would try to activate a removed tab.
    var favActiveSubtab = (FAV_SUBTABS[0] && FAV_SUBTABS[0].key) || 'product';

    function favSubtabLabel(key) {
        var def = FAV_SUBTABS.find(function (s) { return s.key === key; });
        if (!def) return key;
        var i18n = aieoSideCart.i18n || {};
        return i18n[def.i18n] || def.labelDefault;
    }

    function hydrateFavorites(force) {
        var $body = $root.find('[data-aieo-sc-body="favorites"]');
        if (!$body.length) return;
        if (force) {
            // Wipe per-type cache so every subtab refreshes from the
            // server on next activation.
            FAV_SUBTABS.forEach(function (s) { favCache[s.key] = null; });
        }
        // Render the shell first (subtabs + an empty body). Per-tab
        // content fetches fire as the user activates each.
        $body.html(renderFavShell());
        // Bind subtab clicks (delegated within $body for simplicity).
        $body.off('click.aieoFavSub').on('click.aieoFavSub', '[data-fav-subtab]', function () {
            var key = $(this).attr('data-fav-subtab');
            switchFavSubtab(key);
        });
        // Activate the default tab (Products) — which kicks off its fetch.
        switchFavSubtab(favActiveSubtab);
        refreshFavoriteCountBadge();
    }

    function renderFavShell() {
        var html = '<div class="aieo-sc-fav-subtabs" role="tablist">';
        FAV_SUBTABS.forEach(function (s) {
            var label = favSubtabLabel(s.key);
            html += '<button type="button" role="tab" data-fav-subtab="' + escAttr(s.key) + '"'
                 +    ' class="aieo-sc-fav-subtab" aria-selected="false">'
                 +    escHtml(label)
                 +    '<span class="aieo-sc-fav-subtab__badge" data-fav-badge="' + escAttr(s.key) + '" hidden>0</span>'
                 +  '</button>';
        });
        html += '</div>';
        html += '<div class="aieo-sc-fav-subtab-body" data-fav-subtab-body><p class="aieo-sc-rv-empty">…</p></div>';
        return html;
    }

    function switchFavSubtab(key) {
        favActiveSubtab = key;
        var $body = $root.find('[data-aieo-sc-body="favorites"]');
        $body.find('.aieo-sc-fav-subtab').each(function () {
            var match = $(this).attr('data-fav-subtab') === key;
            $(this).toggleClass('is-active', match).attr('aria-selected', match ? 'true' : 'false');
        });
        var $section = $body.find('[data-fav-subtab-body]');
        var cached = favCache[key];
        if (cached !== null) {
            $section.html(renderFavSubtabBody(key, cached));
            return;
        }
        $section.html('<p class="aieo-sc-rv-empty">…</p>');
        fetchFavSubtab(key, function (items) {
            favCache[key] = items;
            updateFavBadge(key, items.length);
            // Only paint if the user hasn't already switched away from
            // this subtab during the round-trip.
            if (favActiveSubtab === key) {
                $section.html(renderFavSubtabBody(key, items));
            }
        });
    }

    function updateFavBadge(key, count) {
        var $badge = $root.find('[data-fav-badge="' + key + '"]');
        if (!$badge.length) return;
        $badge.text(count);
        if (count > 0) { $badge.removeAttr('hidden'); }
        else           { $badge.attr('hidden', true); }
    }

    function fetchFavSubtab(key, callback) {
        if (key === 'product') {
            var ids = readFavorites();
            if (!ids.length) { callback([]); return; }
            $.post(aieoSideCart.ajaxurl, {
                action: 'aieo_get_products_dtos',
                nonce:  bundleNonce(),
                ids:    ids.join(',')
            }).done(function (resp) {
                callback(resp && resp.success && resp.data && Array.isArray(resp.data.products) ? resp.data.products : []);
            }).fail(function () { callback([]); });
            return;
        }
        // brand / post / product_cat / pa-need → generic followed_items endpoint.
        $.post(aieoSideCart.ajaxurl, {
            action:    'aieo_get_followed_items',
            nonce:     window.aieoWishlist ? window.aieoWishlist.nonce : '',
            item_type: key
        }).done(function (resp) {
            callback(resp && resp.success && resp.data && Array.isArray(resp.data.items) ? resp.data.items : []);
        }).fail(function () { callback([]); });
    }

    function renderFavSubtabBody(key, items) {
        if (!items || !items.length) {
            var emptyMsg = (aieoSideCart.i18n && aieoSideCart.i18n.favEmpty) || __('No favorites yet.', 'ai-eshop-optimizer');
            return '<p class="aieo-sc-rv-empty">' + escHtml(emptyMsg) + '</p>';
        }
        if (key === 'product') {
            return renderProductGrid(items, 'aieo-sc-fav-list');
        }
        // brand / post / product_cat / pa-need — same compact list shape.
        var labelProducts = (aieoSideCart.i18n && aieoSideCart.i18n.favBrandProducts) || 'products';
        var html = '<ul class="aieo-sc-fav-brand-list">';
        items.forEach(function (it) {
            var name  = String(it.name || '');
            var url   = String(it.permalink || '');
            var logo  = String(it.logo_url || '');
            var count = parseInt(it.count, 10) || 0;
            var meta  = '';
            if (key === 'brand' && count > 0) {
                meta = count + ' ' + escHtml(labelProducts);
            } else if (key === 'post' && it.excerpt) {
                meta = escHtml(String(it.excerpt));
            } else if ((key === 'product_cat' || key === 'pa-need') && count > 0) {
                meta = count + ' ' + escHtml(labelProducts);
            }
            html += '<li class="aieo-sc-fav-brand">'
                 +    '<a class="aieo-sc-fav-brand__link" href="' + escAttr(url) + '">'
                 +      '<span class="aieo-sc-fav-brand__logo">'
                 +        ( logo ? '<img loading="lazy" src="' + escAttr(logo) + '" alt="' + escAttr(name) + '">' : escHtml(name.charAt(0)) )
                 +      '</span>'
                 +      '<span class="aieo-sc-fav-brand__body">'
                 +        '<span class="aieo-sc-fav-brand__name">' + escHtml(name) + '</span>'
                 +        ( meta ? '<span class="aieo-sc-fav-brand__meta">' + meta + '</span>' : '' )
                 +      '</span>'
                 +    '</a>'
                 +  '</li>';
        });
        html += '</ul>';
        return html;
    }

    // ------------------------------------------------------------------
    // Helpers
    // ------------------------------------------------------------------
    function currentSubtotalRaw() {
        var txt = $root.find('[data-aieo-sc-subtotal]').text() || '0';
        return parseFloat(txt.replace(/[^0-9.,-]/g, '').replace(',', '.')) || 0;
    }
    function formatMoney(n) {
        var sym = $root.data('currency-symbol') || '';
        var v = Math.max(0, n).toFixed(2);
        return sym + v;
    }

    /**
     * Pick a product_id to seed recommendations off, in priority order:
     *   1. body.postid-N         — current product page.
     *   2. First non-gift cart item — most directly relevant signal on cart/
     *      checkout/home pages, and works as long as the cart is non-empty.
     *   3. localStorage rv cache — last-viewed product across sessions.
     * Returns 0 when none of the above produce a positive integer.
     */
    function readSeedProductId() {
        var m = document.body.className.match(/postid-(\d+)/);
        if (m) {
            var pid = parseInt(m[1], 10);
            if (pid > 0) return pid;
        }
        var $cartPid = $root.find('[data-aieo-remove][data-product_id]').first();
        if ($cartPid.length) {
            var cartPid = parseInt($cartPid.attr('data-product_id'), 10);
            if (cartPid > 0) return cartPid;
        }
        try {
            var raw = window.localStorage.getItem('aieo_rv_cache_v4');
            if (raw) {
                var blob = JSON.parse(raw);
                if (blob && Array.isArray(blob.ids) && blob.ids.length) {
                    var rvPid = parseInt(blob.ids[0], 10);
                    if (rvPid > 0) return rvPid;
                }
                // No `ids` but we have DTOs? fall back to first product's id.
                if (blob && Array.isArray(blob.products) && blob.products.length && blob.products[0].id) {
                    return parseInt(blob.products[0].id, 10) || 0;
                }
            }
        } catch (e) { /* ignore */ }
        return 0;
    }

    function readLocalRv() {
        try {
            var raw = window.localStorage.getItem('aieo_rv_cache_v4');
            return raw ? JSON.parse(raw) : null;
        } catch (e) { return null; }
    }
    function writeLocalRv(blob) {
        try { window.localStorage.setItem('aieo_rv_cache_v4', JSON.stringify(blob)); } catch (e) { /* ignore */ }
    }

    // ------------------------------------------------------------------
    // Quick view (Discover it)
    //
    // Strategy:
    //   1. INSTANT — harvest image / title / price from the archive
    //      product card's own DOM. No round-trip; the panel slides open
    //      with usable content immediately.
    //   2. STYLED — render our own add-to-cart button in the panel
    //      (matches the CHECKOUT pill); for simple products it AJAX-adds
    //      via WC, for variable it links to the product page.
    //   3. AUGMENT — fire `aieo_get_product_bundle` for the seed and
    //      paint the short description (and any future enriched fields)
    //      when the response arrives. The bundle is cached server-side
    //      and in localStorage so this is usually free.
    //   4. PERSIST — promote the hidden Quick View tab so the shopper
    //      can bounce between SHOPPING BAG / RECENTLY VIEWED / QUICK
    //      VIEW within the same session.
    // ------------------------------------------------------------------
    function openQuickView($trigger) {
        if (!$root || !$root.length) return;
        var $card = $trigger.closest('li.product, .product-grid-item, article.product, .product');
        if (!$card.length) { $card = $trigger.parent(); }
        if (!$card.length) return;

        var pid = parseInt($trigger.attr('data-aieo-quick-view'), 10) || 0;
        var $img = $card.find('img').first();
        var $titleEl  = $card.find('.woocommerce-loop-product__title, h2.woocommerce-loop-product__title, h2, h3').first();
        var $priceEl  = $card.find('.price').first();

        // Detect product type by class on the archive card or the WC
        // button — drives whether our action is an AJAX add (simple)
        // or a link to the product page (variable / grouped / external).
        var type = 'simple';
        if ($card.hasClass('product-type-variable') || $card.find('.product_type_variable').length) { type = 'variable'; }
        else if ($card.hasClass('product-type-grouped')  || $card.find('.product_type_grouped').length)  { type = 'grouped'; }
        else if ($card.hasClass('product-type-external') || $card.find('.product_type_external').length) { type = 'external'; }

        // Origin — archive pages are the primary surface for the
        // WC-style "eye icon" QV trigger. If we were called from a
        // single-product page (very rare — theme-level integration),
        // mark that instead.
        var origin = (document.body && document.body.classList.contains('single-product'))
            ? 'single_product'
            : 'archive';

        openQuickViewForCard({
            pid:       pid,
            title:     $.trim($titleEl.text() || ''),
            imgSrc:    $img.attr('src')    || '',
            imgSrcset: $img.attr('srcset') || '',
            imgAlt:    $img.attr('alt')    || '',
            priceHtml: $priceEl.length ? $priceEl.html() : '',
            permalink: $card.find('a.woocommerce-LoopProduct-link, a[href*="/product/"]').first().attr('href')
                        || $card.find('a').first().attr('href')
                        || '',
            type:      type,
            origin_source: origin,
        });
    }

    /**
     * Render the Quick View body from a plain-object seed. Shared by
     * archive-card triggers (`openQuickView($trigger)`) and in-panel
     * RV / Favorites / maylike cards that need QV to open inline
     * instead of navigating to the product page.
     *
     * Seed shape:
     *   { pid, title, imgSrc, imgSrcset, imgAlt, priceHtml, permalink, type }
     */
    function openQuickViewForCard(seed) {
        if (!$root || !$root.length) return;
        var pid = parseInt(seed.pid, 10) || 0;
        if (pid <= 0) return;

        var canAjaxAdd = seed.type !== 'variable'
                      && seed.type !== 'grouped'
                      && seed.type !== 'external';

        var actionsHtml = renderQuickViewAction(pid, seed.permalink || '', canAjaxAdd, seed.origin_source || '');

        var html = ''
            + '<div class="aieo-sc-qv">'
            +   '<div class="aieo-sc-qv-image">'
            +     (seed.imgSrc
                    ? '<img src="' + escAttr(seed.imgSrc) + '"'
                        + (seed.imgSrcset ? ' srcset="' + escAttr(seed.imgSrcset) + '"' : '')
                        + ' sizes="(min-width: 601px) 480px, 90vw"'
                        + ' alt="'    + escAttr(seed.imgAlt || '') + '">'
                    : '')
            +   '</div>'
            +   '<h2 class="aieo-sc-qv-title">'
            +     (seed.permalink
                    ? '<a href="' + escAttr(seed.permalink) + '">' + escHtml(seed.title || '') + '</a>'
                    : escHtml(seed.title || ''))
            +   '</h2>'
            +   '<div class="aieo-sc-qv-price">' + (seed.priceHtml || '') + '</div>'
            +   '<div class="aieo-sc-qv-description" data-aieo-sc-qv-description>'
            +     '<!-- async: filled when the bundle endpoint responds -->'
            +   '</div>'
            +   '<div class="aieo-sc-qv-actions">' + actionsHtml + '</div>'
            +   (seed.permalink
                    ? '<a class="aieo-sc-qv-view-product" href="' + escAttr(seed.permalink) + '">'
                        + escHtml((aieoSideCart.i18n && aieoSideCart.i18n.qvViewProduct) || __('View product details', 'ai-eshop-optimizer'))
                        + '</a>'
                    : '')
            + '</div>';

        var $body = $root.find('[data-aieo-sc-body="quickview"]');
        $body.html(html);
        $body.attr('data-aieo-sc-qv-pid', pid);
        // Persist origin on the QV body so upgradeQuickViewCta (called
        // after variation swatches render) can rebuild the CTA with the
        // same origin_source in source_meta.
        $body.attr('data-aieo-sc-qv-origin', seed.origin_source || '');

        promoteQuickViewTab(seed.title || '');
        showQuickViewBody();
        openCart();

        // Async — the bundle endpoint backfills the short description
        // + variation swatches (the swatches upgrade a "View product
        // details" link into a real ADD TO BAG button).
        if (pid > 0 && window.aieoBundleFetcher) {
            window.aieoBundleFetcher.fetch(pid).done(function (data) {
                if (!data || !data.seed) return;
                augmentQuickView($body, pid, data);
            });
        }
    }

    // Build the in-panel CTA. Simple products get our own styled add-to-
    // cart button that posts to WC's `add_to_cart` AJAX endpoint and
    // triggers the standard `added_to_cart` event so the rest of the
    // side-cart machinery (count badge, panel auto-open, switch-to-bag)
    // takes over for free. Variable / grouped / external products get a
    // visually identical button that links to the product page where the
    // shopper can pick options.
    function renderQuickViewAction(pid, permalink, canAjaxAdd, originSource) {
        var label = (aieoSideCart.i18n && aieoSideCart.i18n.addToBag) || __('ADD TO BAG', 'ai-eshop-optimizer');
        // Origin → origin_source in source_meta so a
        // `sidecart_quickview` add can still be split by the surface
        // that opened the Quick View (archive / favorites / RV /
        // maylike / product_needs). Analytics queries can extract
        // with `JSON_EXTRACT(source_meta, '$.origin_source')`.
        var metaAttr = '';
        if (originSource) {
            metaAttr = ' data-aieo-source-meta="'
                + escAttr('{"origin_source":"' + originSource + '"}')
                + '"';
        }
        if (canAjaxAdd) {
            return '<button type="button" class="aieo-sc-btn aieo-sc-btn-checkout aieo-sc-qv-cta"'
                +   ' data-aieo-sc-qv-add="' + pid + '"'
                +   ' data-aieo-source="sidecart_quickview"'
                +   metaAttr + '>'
                +   escHtml(label)
                + '</button>';
        }
        var href = permalink || '#';
        var optionsLabel = (aieoSideCart.i18n && aieoSideCart.i18n.qvViewProduct) || __('View product details', 'ai-eshop-optimizer');
        return '<a class="aieo-sc-btn aieo-sc-btn-checkout aieo-sc-qv-cta"'
            +   ' href="' + escAttr(href) + '"'
            +   ' data-aieo-source="sidecart_quickview"'
            +   metaAttr + '>'
            +   escHtml(optionsLabel)
            + '</a>';
    }

    // Show the quick-view tab. The label stays the static "QUICK VIEW"
    // string emitted by PHP — we don't shove the product name in there
    // because the panel already shows it inside the body, and a long
    // Greek product name would force the tab bar to wrap.
    function promoteQuickViewTab(/* title — intentionally ignored */) {
        var $tab = $root.find('[data-aieo-sc-qv-tab]');
        if (!$tab.length) return;
        $tab.prop('hidden', false);
    }

    // Augment the quick view body with the bundle's seed data — short
    // description and (for variable products) attribute swatches that
    // map to a specific variation_id on click.
    function augmentQuickView($body, pid, data) {
        // Guard against late responses for a different product than the
        // one currently being shown.
        var currentPid = parseInt($body.attr('data-aieo-sc-qv-pid'), 10) || 0;
        if (currentPid !== pid) return;
        var seed = data.seed || {};
        if (seed.short_description) {
            $body.find('[data-aieo-sc-qv-description]').html(seed.short_description);
        }
        if (Array.isArray(data.variations) && data.variations.length) {
            renderQuickViewSwatches($body, data.variations, data.attribute_labels || {}, data.attribute_taxonomy_labels || {});
            // Variable products initially render a "View product details"
            // link as the CTA (since there's no AJAX path without a
            // variation choice). Now that swatches exist we can swap in
            // the real add-to-cart button — server endpoint will reject
            // the request if no variation is fully selected, redirecting
            // the shopper to the product page instead.
            upgradeQuickViewCta($body, pid);
        }
    }

    function upgradeQuickViewCta($body, pid) {
        var $actions = $body.find('.aieo-sc-qv-actions');
        if (!$actions.length) return;
        // renderQuickViewAction handles both the canAjaxAdd=true path
        // (the variation-swatch upgrade we want here) and the origin
        // source_meta attribute — no need to duplicate the string
        // template inline.
        var origin = $body.attr('data-aieo-sc-qv-origin') || '';
        $actions.html(renderQuickViewAction(pid, '', true, origin));
    }

    // Render attribute-swatch rows from a WC `get_available_variations()`-
    // shaped payload. For each attribute (color, size, …), emit a row
    // of clickable chips. After rendering, recomputeQuickViewVariation()
    // resolves whatever's selected to a concrete variation_id and
    // updates the price/image. No selection → CTA stays usable but
    // server-side validation will bounce the shopper to the product
    // page if they try to add without picking everything.
    function renderQuickViewSwatches($body, variations, attrLabels, attrTaxLabels) {
        attrLabels    = attrLabels    || {};
        attrTaxLabels = attrTaxLabels || {};
        // Cache variations on the body so swatch clicks can re-resolve
        // without re-fetching the bundle.
        $body.data('aieo-sc-qv-variations', variations);

        // Build attribute → set(value) map from variation.attributes.
        var attrSets = {};
        variations.forEach(function (v) {
            if (!v.attributes) return;
            Object.keys(v.attributes).forEach(function (k) {
                var val = v.attributes[k];
                if (val === '' || val === null || typeof val === 'undefined') return;
                if (!attrSets[k]) { attrSets[k] = []; }
                if (attrSets[k].indexOf(val) === -1) { attrSets[k].push(val); }
            });
        });

        if (!Object.keys(attrSets).length) return;

        // Helper: look up the human-readable label for a slug from
        // the server-provided attribute_labels map. Falls back to the
        // slug itself if the lookup misses (e.g. older cached blob
        // without the labels map).
        function labelFor(attrKey, slug) {
            var map = attrLabels[attrKey];
            if (map && Object.prototype.hasOwnProperty.call(map, slug) && map[slug]) {
                return map[slug];
            }
            return slug;
        }

        var $host = $body.find('[data-aieo-sc-qv-swatches]');
        if (!$host.length) {
            // Mount point doesn't exist yet — inject one before the
            // actions block so swatches sit between description and CTA.
            $body.find('.aieo-sc-qv-actions').before('<div class="aieo-sc-qv-swatches" data-aieo-sc-qv-swatches></div>');
            $host = $body.find('[data-aieo-sc-qv-swatches]');
        }
        $host.empty();

        Object.keys(attrSets).forEach(function (attr) {
            var values = attrSets[attr];
            // Prefer the human-readable attribute label from the bundle's
            // `attribute_taxonomy_labels` map (e.g. "Χρώμα"). Fall back to
            // the slug-derived approximation ("kragion chroma" from
            // `attribute_pa_kragion_chroma`) for older cached blobs that
            // don't carry the new map.
            var label = attrTaxLabels[attr]
                || attr.replace(/^attribute_(pa_)?/, '').replace(/[-_]/g, ' ');
            /* translators: %s: attribute label, e.g. "Color" */
            var placeholder = _sprintf(__('Choose %s', 'ai-eshop-optimizer'), label);
            var rowHtml = '<div class="aieo-sc-qv-attr-row" data-aieo-sc-qv-attr="' + escAttr(attr) + '" data-aieo-sc-qv-attr-value="">'
                + '<div class="aieo-sc-qv-attr-label">' + escHtml(label) + '</div>'
                + '<select class="aieo-sc-qv-select" data-aieo-sc-qv-select="' + escAttr(attr) + '">'
                + '<option value="">' + escHtml(placeholder) + '</option>';
            values.forEach(function (v) {
                // option value stays the raw slug (WC needs it for the
                // variation match); display text uses the human label
                // resolved from the server's attribute_labels map.
                rowHtml += '<option value="' + escAttr(v) + '">' + escHtml(labelFor(attr, v)) + '</option>';
            });
            rowHtml += '</select></div>';
            $host.append(rowHtml);
        });
    }

    // Walk the attribute rows, build an attribute_<name> => value map,
    // find the variation in the cached payload whose attributes match,
    // and store its variation_id on the body. If the shopper hasn't
    // picked every attribute yet we leave variation_id at 0 — the CTA
    // remains clickable but the server returns "Please choose options
    // first" which redirects to the product page.
    function recomputeQuickViewVariation() {
        var $body = $root.find('[data-aieo-sc-body="quickview"]');
        var variations = $body.data('aieo-sc-qv-variations');
        if (!Array.isArray(variations) || !variations.length) return;

        var picked = {};
        var allPicked = true;
        $body.find('[data-aieo-sc-qv-attr]').each(function () {
            var $row = $(this);
            var name = $row.attr('data-aieo-sc-qv-attr');
            var value = $row.attr('data-aieo-sc-qv-attr-value') || '';
            if (!value) { allPicked = false; }
            picked[name] = value;
        });

        var match = null;
        if (allPicked) {
            for (var i = 0; i < variations.length; i++) {
                var v = variations[i];
                var attrs = v.attributes || {};
                var ok = true;
                for (var k in picked) {
                    // WC's variation attribute value can be "" meaning
                    // "any value of this attribute" — accept those too.
                    if (attrs[k] !== '' && attrs[k] !== picked[k]) { ok = false; break; }
                }
                if (ok) { match = v; break; }
            }
        }

        $body.attr('data-aieo-sc-qv-variation', match ? (match.variation_id || 0) : 0);

        // Reflect price + image of the matched variation if WC sent
        // those fields in the payload.
        if (match) {
            if (match.price_html) {
                $body.find('.aieo-sc-qv-price').html(match.price_html);
            }
            if (match.image && match.image.src) {
                var $img = $body.find('.aieo-sc-qv-image img');
                if ($img.length) {
                    $img.attr('src', match.image.src);
                    if (match.image.srcset) { $img.attr('srcset', match.image.srcset); }
                    if (match.image.alt)    { $img.attr('alt',    match.image.alt); }
                }
            }
        }
    }

    // Hide every panel body, then show the quick-view body. Mark the
    // quick-view tab as active so the visible tab indicator matches the
    // visible body.
    function showQuickViewBody() {
        $root.find('.aieo-sc-tab').removeClass('is-active').attr('aria-selected', 'false');
        $root.find('.aieo-sc-tab[data-aieo-sc-tab="quickview"]').addClass('is-active').attr('aria-selected', 'true');
        $root.find('[data-aieo-sc-body]').prop('hidden', true);
        $root.find('[data-aieo-sc-body="quickview"]').prop('hidden', false);
    }

    // Programmatic version of clicking a tab — used after add-to-cart
    // from quick view to return the shopper to SHOPPING BAG.
    function switchToTab(tab) {
        var $tab = $root.find('.aieo-sc-tab[data-aieo-sc-tab="' + tab + '"]');
        if ($tab.length) {
            $tab.trigger('click');
        }
    }

    // Resolve the WC `?wc-ajax=add_to_cart` endpoint URL. WC exposes a
    // global `wc_add_to_cart_params.wc_ajax_url` on archive/product
    // pages (with the `%%endpoint%%` placeholder), but the side cart
    // also opens on home/cart/checkout pages where that global isn't
    // localized. Fall back to the home-relative path WC uses by default.
    function wcAjaxAddToCartUrl() {
        if (window.wc_add_to_cart_params && window.wc_add_to_cart_params.wc_ajax_url) {
            return window.wc_add_to_cart_params.wc_ajax_url.replace('%%endpoint%%', 'add_to_cart');
        }
        // Defensive fallback — works on every WC install.
        return '/?wc-ajax=add_to_cart';
    }

    // ------------------------------------------------------------------
    // Optimistic add-to-cart (shared by quick view + RV + Favorites)
    //
    // Strategy:
    //   1. Bump the trigger count badge IMMEDIATELY (one count up). The
    //      shopper sees instant feedback even before WC has finished
    //      processing the cart write.
    //   2. Switch to the SHOPPING BAG tab right away so the panel
    //      doesn't sit on the trigger element.
    //   3. POST to the lightweight server endpoint (count + subtotal
    //      only — no rendered cart-row HTML).
    //   4. Reconcile the count on success (true count from WC), kick a
    //      single fetchPanel() so the bag rows refresh in the
    //      background without blocking the visible UX.
    //   5. Fire the `added_to_cart` event so other widgets sync.
    // ------------------------------------------------------------------
    function sideCartAddProductOptimistic(opts) {
        var productId   = parseInt(opts.productId, 10) || 0;
        var variationId = parseInt(opts.variationId, 10) || 0;
        var variation   = opts.variation || {};
        var $btn        = opts.$btn || $();
        var onError     = opts.onError || $.noop;

        if (productId <= 0) return;
        if ($btn.hasClass('is-loading')) return;

        var originalText = $btn.text();
        $btn.addClass('is-loading').prop('disabled', true);

        // Optimistic count bump on the trigger badge — reverted in
        // .fail() if the request errors. The bag-tab count badge will
        // be replaced by the authoritative number on success.
        var $countEls = $root.find('.aieo-sc-trigger-count, [data-aieo-sc-count]');
        var prevCount = parseInt($countEls.first().text(), 10) || 0;
        $countEls.text(prevCount + 1);

        // Switch immediately so the shopper sees the bag panel populating.
        switchToTab('bag');

        // Attribution payload — read source metadata straight off the
        // button that triggered this add (data-aieo-source,
        // data-aieo-source-pid, data-aieo-source-meta). Server writes
        // a wp_aieo_addcart_events row with these + the product info.
        var attr = aieoReadBtnAttribution($btn);

        $.ajax({
            url: aieoSideCart.ajaxurl,
            type: 'POST',
            data: {
                action:       'aieo_side_cart_quick_view_add',
                nonce:        $root.data('nonce'),
                product_id:   productId,
                variation_id: variationId,
                variation:    variation,
                quantity:     1,
                aieo_event_id:    attr.event_id,
                aieo_source:      attr.source,
                aieo_source_pid:  attr.source_pid,
                aieo_source_meta: attr.source_meta
            }
        }).done(function (resp) {
            $btn.removeClass('is-loading').prop('disabled', false).text(originalText);
            if (!resp || !resp.success) {
                $countEls.text(prevCount); // revert optimistic bump
                onError(resp);
                return;
            }
            updateCountAndSubtotal(resp.data);
            paintFreeShippingBar(parseFloat($root.data('threshold')) || 0, parseFloat(resp.data.subtotal_raw) || 0);
            // GA4 / GTM dataLayer push — runs alongside the server log
            // so analytics stays consistent with our own events table.
            aieoPushGa4AddToCart({
                productId:   productId,
                variationId: variationId,
                quantity:    1,
                unitPrice:   parseFloat(resp.data.subtotal_raw) && prevCount > 0 ? null : null, // server already has it
                attr:        attr,
                $btn:        $btn
            });
            // Pull the actual cart-row HTML asynchronously so the bag
            // tab shows the new line. The visible UX (count + tab
            // switch) is already done — this is just the slow tail.
            fetchPanel();
            // Tier 2C — fire added_to_cart with `null` fragments so WC
            // core's updateFragments short-circuits (its `if (fragments)`
            // guard) and skips the .fadeTo+blockUI animation that would
            // pin a 400 ms overlay on the cart-count badge — the overlay
            // intercepted subsequent rapid-click adds, making the side
            // cart appear "not to open" on every other click. Real
            // fragment work + sessionStorage update is done by
            // applyFragmentsInline AFTER, so its writes win over WC
            // cart-fragments.js's set_cart_hash call.
            $(document.body).trigger('added_to_cart', [
                null,
                resp.data.cart_hash || '',
                $btn
            ]);
            applyFragmentsInline(resp.data);
        }).fail(function () {
            $btn.removeClass('is-loading').prop('disabled', false).text(originalText);
            $countEls.text(prevCount);
        });
    }

    // ------------------------------------------------------------------
    // Attribution — thin client
    //
    // Every ADD TO BAG button carries three data attributes:
    //   data-aieo-source      (required — see ATTRIBUTION-SCHEMA-BRIEF.md §2)
    //   data-aieo-source-pid  (optional — seed product for recs)
    //   data-aieo-source-meta (optional — JSON string of contextual metadata)
    //
    // Readers produce a lightweight payload we tack onto the add-to-
    // cart server call + a GA4 dataLayer push. No polling, no
    // observers on page load — one event per click.
    // ------------------------------------------------------------------
    function aieoReadBtnAttribution($btn) {
        var event_id = aieoUuidV4();
        var out = {
            event_id:    event_id,
            source:      'sidecart_quickview',  // safe default
            source_pid:  0,
            source_meta: ''
        };
        if (!$btn || !$btn.length) { return out; }
        var src = $btn.attr('data-aieo-source');
        if (src) { out.source = String(src); }
        var pid = parseInt($btn.attr('data-aieo-source-pid'), 10);
        if (!isNaN(pid) && pid > 0) { out.source_pid = pid; }
        var meta = $btn.attr('data-aieo-source-meta');
        if (meta) { out.source_meta = String(meta); }
        return out;
    }
    function aieoUuidV4() {
        // RFC 4122 v4 — crypto-backed when available, Math.random fallback.
        if (window.crypto && window.crypto.randomUUID) {
            try { return window.crypto.randomUUID(); } catch (e) { /* fall through */ }
        }
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
            var r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
            return v.toString(16);
        });
    }

    // GA4 dataLayer push for `add_to_cart`. Follows the standard e-
    // commerce event shape so GTM's default GA4 configuration picks
    // it up with zero extra mapping. Falls back to a direct gtag()
    // call when only gtag is present. No-op when neither is loaded.
    function aieoPushGa4AddToCart(opts) {
        // Honour the Pixels & Conversions GA4 source selector — stay silent
        // when the operator defers GA to PixelYourSite / Site Kit (so the
        // side-cart never double-fires add_to_cart alongside PYS/GTM).
        if (window.aieoSideCart && aieoSideCart.ga4_enabled === false) { return; }
        var productId   = parseInt(opts.productId, 10)   || 0;
        var variationId = parseInt(opts.variationId, 10) || 0;
        var quantity    = parseInt(opts.quantity, 10)    || 1;
        var attr        = opts.attr || {};
        var $btn        = opts.$btn || $();

        if (productId <= 0) return;

        // Pull item_name from the closest card for a richer event.
        var $card = $btn.closest('.aieo-sc-reco-item, .aieo-sc-maylike-slide, .aieo-sc-qv');
        var itemName = '';
        if ($card.length) {
            itemName = $.trim($card.find('.aieo-card-name, .aieo-sc-qv-title').first().text());
        }

        var listLabelMap = (window.aieoSideCart && aieoSideCart.i18n && aieoSideCart.i18n.recoSourceLabels) || {
            sidecart_recently_viewed: __('Recently viewed (side cart)', 'ai-eshop-optimizer'),
            sidecart_might_like:      __('You might like', 'ai-eshop-optimizer'),
            sidecart_may_also_like:   __('You may also like', 'ai-eshop-optimizer'),
            sidecart_favorites:       __('Favorites', 'ai-eshop-optimizer'),
            sidecart_quickview:       __('Quick view', 'ai-eshop-optimizer')
        };

        var item = {
            item_id:        String(productId),
            item_name:      itemName || undefined,
            item_variant:   variationId ? String(variationId) : undefined,
            quantity:       quantity,
            item_list_id:   attr.source || 'unknown',
            item_list_name: listLabelMap[attr.source] || attr.source || 'unknown',
            aieo_event_id:  attr.event_id,
            aieo_source:    attr.source,
            aieo_source_pid: attr.source_pid || undefined
        };

        var payload = {
            event: 'add_to_cart',
            ecommerce: {
                currency: aieoSideCart && aieoSideCart.currency ? aieoSideCart.currency : undefined,
                items: [item]
            }
        };

        // dataLayer (GTM + GA4 standard)
        if (window.dataLayer && typeof window.dataLayer.push === 'function') {
            try { window.dataLayer.push(payload); } catch (e) { /* never break */ }
        }
        // gtag direct (when GTM isn't present)
        if (typeof window.gtag === 'function') {
            try {
                window.gtag('event', 'add_to_cart', {
                    currency:       payload.ecommerce.currency,
                    items:          payload.ecommerce.items,
                    item_list_id:   item.item_list_id,
                    item_list_name: item.item_list_name
                });
            } catch (e) { /* never break */ }
        }
    }

    // ------------------------------------------------------------------
    // Favorites — backed by the AIEO wishlist (Phase-1 of the migration
    // out of om-product-page-essentials). Source of truth is the cookie
    // `woocommerce_items_in_cart_merchant_wishlist` — same name as PPE
    // for cross-plugin sync during the transition. The server side
    // persists to wp_aieo_wishlist via aieo_wishlist_toggle (and PPE
    // continues to write to its own wp_om_ppe_wishlist for archive
    // hearts until Phase 2 retires the PPE module).
    //
    // The custom event `om_ppe_wishlist_updated` is still fired/listened
    // to so we stay in lockstep with any PPE-rendered hearts on the
    // page. We also fire `aieo_wishlist_updated` for the new namespace.
    // ------------------------------------------------------------------
    var FAV_COOKIE = (window.aieoWishlist && window.aieoWishlist.cookie)
        || 'woocommerce_items_in_cart_merchant_wishlist';
    var FAV_COOKIE_DAYS = 30;
    function _scGetCookie(name) {
        var nameEQ = name + '=';
        var ca = document.cookie ? document.cookie.split(';') : [];
        for (var i = 0; i < ca.length; i++) {
            var c = ca[i].replace(/^\s+/, '');
            if (c.indexOf(nameEQ) === 0) {
                try { return decodeURIComponent(c.substring(nameEQ.length)); }
                catch (e) { return c.substring(nameEQ.length); }
            }
        }
        return '';
    }
    function _scSetCookie(name, value, days) {
        var d = new Date();
        d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
        document.cookie = name + '=' + encodeURIComponent(value) +
            ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax';
    }
    function readFavorites() {
        var raw = _scGetCookie(FAV_COOKIE);
        if (!raw) return [];
        return raw.split(',').map(function (x) { return parseInt(x, 10) || 0; }).filter(Boolean);
    }
    function writeFavorites(ids) {
        _scSetCookie(FAV_COOKIE, ids.join(','), FAV_COOKIE_DAYS);
    }
    function isFavorite(pid) {
        return readFavorites().indexOf(parseInt(pid, 10) || 0) !== -1;
    }
    function toggleFavorite(pid) {
        pid = parseInt(pid, 10) || 0;
        if (pid <= 0) return false;
        var list = readFavorites();
        var idx = list.indexOf(pid);
        var add = (idx === -1);
        if (add) { list.unshift(pid); }
        else { list.splice(idx, 1); }
        writeFavorites(list);
        refreshFavoriteCountBadge();
        // Phase-2 sync: AIEO is the single server-side owner now. The
        // PPE-compatibility shim (includes/class-aieo-wishlist-compat.php)
        // routes any legacy `om_ppe_wishlist_toggle` POSTs that come
        // from other consumers (archive hearts, theme widgets) to the
        // same endpoint, so a single call keeps all tables in sync.
        syncFavoriteWithAieo(pid, add);
        // Broadcast both event names so other wishlist consumers
        // (PPE archive hearts if still active, future AIEO consumers)
        // update in lockstep.
        try {
            $(window).trigger('aieo_wishlist_updated',  [pid, add, 'product']);
            $(window).trigger('om_ppe_wishlist_updated', [pid, add, 'product']);
        } catch (e) { /* ignore */ }
        return add;
    }
    function syncFavoriteWithAieo(pid, add) {
        if (typeof window.aieoWishlist === 'undefined') return;
        var data = {
            action:          'aieo_wishlist_toggle',
            nonce:           window.aieoWishlist.nonce,
            item_id:         pid,
            item_type:       'product',
            product_id:      pid,
            wishlist_action: add ? 'add' : 'remove',
            source_type:     'side_cart',
            source_id:       0,
            source_name:     ''
        };
        _scBeacon(window.aieoWishlist.ajaxUrl, data);
    }
    function _scBeacon(url, data) {
        try {
            if (navigator.sendBeacon) {
                var params = new URLSearchParams();
                for (var k in data) {
                    if (Object.prototype.hasOwnProperty.call(data, k)) {
                        params.append(k, data[k]);
                    }
                }
                var blob = new Blob([params.toString()], { type: 'application/x-www-form-urlencoded' });
                navigator.sendBeacon(url, blob);
                return;
            }
        } catch (e) { /* fall through to AJAX */ }
        $.ajax({ type: 'POST', url: url, data: data, async: true });
    }
    function refreshFavoriteCountBadge() {
        $root.find('[data-aieo-sc-fav-count]').text(readFavorites().length);
    }

    function bundleNonce() {
        if (aieoSideCart && aieoSideCart.recommendationsNonce) {
            return aieoSideCart.recommendationsNonce;
        }
        return (window.aieoRecommendationsConfig && window.aieoRecommendationsConfig.nonce) || '';
    }

})(jQuery);

/**
 * Single-product page → AJAX add-to-cart bridge.
 *
 * WooCommerce's standard AJAX add-to-cart only covers archive [Add to cart] buttons.
 * The single-product `<form class="cart">` does a regular POST that reloads the page,
 * which means:
 *   - the `added_to_cart` jQuery event never fires
 *   - the side-cart's auto-open listener (line ~630) can't react
 *   - the user pays a full page navigation just to add one item
 *
 * This bridge intercepts the form submit and runs the same `woocommerce_ajax_add_to_cart`
 * action that archive buttons use. On success it fires the canonical event so every
 * downstream listener (side cart, fragments refresh, mini-cart count, GA4, IAPI store
 * — Stage 2 — etc.) catches it without further plumbing.
 *
 * Variable products that aren't yet "found" by WC's variation script fall through to a
 * normal submit so the user sees the standard "Please select…" notice.
 */
(function ($) {
    if (!$ || !document.body || !document.body.classList.contains('single-product')) {
        return;
    }

    $(document).on('submit.aieoSpAjax', 'form.cart', function (ev) {
        var $form = $(this);
        if ($form.data('aieoBridgeBound')) { return; }

        var $btn = $form.find('.single_add_to_cart_button, button[type="submit"]').first();
        if (!$btn.length || $btn.is('[disabled]') || $btn.hasClass('disabled')) {
            return;
        }

        // Variable product without a found variation → let WC's own variation
        // script raise the inline notice rather than sending a half-formed AJAX.
        var hasVariationInput = $form.find('input[name="variation_id"]').length > 0;
        var variationId       = hasVariationInput ? parseInt($form.find('input[name="variation_id"]').val() || '0', 10) : 0;
        if (hasVariationInput && variationId <= 0) {
            return;
        }

        ev.preventDefault();
        $form.data('aieoBridgeBound', true);
        $btn.addClass('loading is-loading').prop('disabled', true);

        var fd = new FormData(this);

        // WC's `add_to_cart` AJAX handler expects `product_id` to be the
        // VARIATION post id for variable products (it then derives the
        // parent + attribute array via `wc_get_product()->get_type() ===
        // 'variation'`). The variations form ships `product_id=parent` +
        // `variation_id=child` as separate hidden inputs, so when a variation
        // is selected we override `product_id` with the variation id.
        // Without this WC reads the parent id and tries to add the variable
        // product directly, which fails — the symptom the shopper sees as
        // "click the button, nothing happens" on single-select variable
        // products. (Multi-select goes through AIEO's own batch endpoint.)
        if (variationId > 0) {
            fd.set('product_id', String(variationId));
        }
        // For simple products the form's submitter button carries
        // `name="add-to-cart"`, which `new FormData(form)` does NOT include
        // automatically (only the submitter button is included on a real
        // native submit, not when JS intercepts). Pull from the button
        // explicitly when no product_id is otherwise set.
        if (!fd.has('product_id')) {
            var pid = $form.find('input[name="add-to-cart"]').val()
                   || $form.find('button[name="add-to-cart"]').val()
                   || $form.data('product_id');
            if (pid) { fd.append('product_id', pid); }
        }
        if (!fd.has('quantity')) {
            fd.append('quantity', $form.find('[name="quantity"]').val() || 1);
        }

        // Use WC's wc-ajax endpoint when available — it routes directly to
        // `WC_AJAX::add_to_cart()` without going through `admin-ajax.php`
        // and is what WC's own scripts use. The endpoint URL is exposed in
        // `wc_add_to_cart_params.wc_ajax_url` with `%%endpoint%%` as the
        // placeholder for the action name. Falls back to admin-ajax with the
        // canonical action name `woocommerce_add_to_cart` (registered via
        // `wp_ajax_woocommerce_add_to_cart` + `wp_ajax_nopriv_*` in WC core).
        // The previous attempt sent `action=woocommerce_ajax_add_to_cart` —
        // that name isn't registered, so WP returned 400 Bad Request.
        var p = window.wc_add_to_cart_params || {};
        var endpoint;
        if (p.wc_ajax_url) {
            endpoint = p.wc_ajax_url.replace('%%endpoint%%', 'add_to_cart');
        } else {
            endpoint = (p.ajax_url || (window.aieoSideCart && window.aieoSideCart.ajaxurl) || '/wp-admin/admin-ajax.php');
            fd.append('action', 'woocommerce_add_to_cart');
        }

        $.ajax({
            type:        'POST',
            url:         endpoint,
            data:        fd,
            processData: false,
            contentType: false,
            dataType:    'json'
        }).done(function (resp) {
            $btn.removeClass('loading is-loading').prop('disabled', false);
            $form.removeData('aieoBridgeBound');

            // WC returns { error: true, product_url } on validation failure (e.g. out of stock).
            if (resp && resp.error && resp.product_url) {
                window.location.href = resp.product_url;
                return;
            }

            // Canonical event — same shape WC core fires for archive buttons.
            $(document.body).trigger('added_to_cart', [
                resp && resp.fragments,
                resp && resp.cart_hash,
                $btn
            ]);

            // Pulse the button so the user gets visual feedback even when the side
            // cart auto-open is off.
            $btn.addClass('added');
            setTimeout(function () { $btn.removeClass('added'); }, 1200);
        }).fail(function () {
            // Network/server failure — release the lock and let the next submit run
            // as a normal POST so the user still gets their item added.
            $btn.removeClass('loading is-loading').prop('disabled', false);
            $form.removeData('aieoBridgeBound');
        });
    });
})(window.jQuery);
