, which is BEFORE // window.Shopify.currency exists â an eager HUF gate here silently exits on // every page load (live-verified 2026-08-16: the preview param never stuck). // So: arm resolution and the settings flip happen now (they need no // currency), and the HUF gate runs at DOMContentLoaded, reverting the flip // for non-HUF visitors before the component initialises. // ââ Preview plumbing (sticky per session, excluded from bookkeeping) ââââââ var preview = null; try { var m = window.location.search.match(/[?&]consentexp=([a-z]+)/); if (m) { if (m[1] === 'off') sessionStorage.removeItem('kvce_prev'); else sessionStorage.setItem('kvce_prev', m[1] === 'control' ? 'control' : 'treatment'); } preview = sessionStorage.getItem('kvce_prev'); } catch (e) { /* storage blocked: preview simply unavailable */ } if (MODE === 'preview' && !preview) return; // ââ Arm âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ var arm; var bookkeeping = MODE === 'ab' && !preview; if (preview) arm = preview; else if (MODE === 'on') arm = 'treatment'; else { try { arm = localStorage.getItem('kv_consent_arm'); if (arm !== 'control' && arm !== 'treatment') { arm = Math.random() < 0.5 ? 'control' : 'treatment'; localStorage.setItem('kv_consent_arm', arm); } } catch (e) { // Storage blocked: unassignable and unmeasurable, so leave the default // banner and stay out of the books rather than pollute an arm. return; } } function visitorId() { try { var v = localStorage.getItem('otto_visitor_id'); if (!v) { v = window.crypto && crypto.randomUUID ? crypto.randomUUID() : 'v-' + Date.now() + '-' + Math.random().toString(16).slice(2); localStorage.setItem('otto_visitor_id', v); } return v; } catch (e) { return null; } } function track(name, idSuffix, extra) { if (!bookkeeping) return; var vid = visitorId(); if (!vid) return; var data = { experimentKey: KEY, variant: arm }; if (extra) for (var k in extra) data[k] = extra[k]; try { fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true, body: JSON.stringify({ events: [{ id: KEY + ':' + vid + (idSuffix ? ':' + idSuffix : ''), eventName: name, clientId: vid, occurredAt: new Date().toISOString(), data: data }] }) }).catch(function () {}); } catch (e) { /* bookkeeping must never break the page */ } } function dayKey() { return new Date().toISOString().slice(0, 10); } // ââ Treatment step 1: flip Pandectes' own modality settings pre-init ââââââ // Their component snapshots window.pandectesBannerSettings at init; this // snippet renders before , which in practice runs first. If it does // not, step 3's fallback covers the gap. var flipped = false; if (arm === 'treatment') { try { var s = window.pandectesBannerSettings; if (s && s.theme) { s.theme.isModal = false; s.theme.scrollLock = false; flipped = true; } } catch (e) { /* fall through to the CSS fallback */ } } function revertFlip() { if (!flipped) return; try { var s2 = window.pandectesBannerSettings; if (s2 && s2.theme) { s2.theme.isModal = true; s2.theme.scrollLock = true; } } catch (e) {} } var COMPACT_CSS = // Scoped to the FIRST-VISIT view only. Live-verified 2026-08-16 at // 375x812: this store's first-visit view renders `.dialog.notice` // (231px tall by default; the button row is `.actions`, the text is // `.intro`/#message inside `.body`). This CSS measures 182px on the // real banner with both buttons fully visible. `.banner` is kept as an // alias in case a Pandectes config change renames the view. The // preferences dialog (a different view class) keeps its full layout, // so the granular consent choices render exactly as Pandectes designed // them. // // DELIBERATELY NO max-height/overflow cap: a cap that guesses low clips // the consent BUTTONS (caught live at 148px), and a banner whose accept // and decline are unreachable is a compliance bug, not a compact layout. // Height comes from shrinking the parts instead. '.dialog.notice,.dialog.banner{padding:10px 14px 12px;}' + '.dialog.notice .header,.dialog.banner .header{margin:0;padding:0;min-height:0;}' + '.dialog.notice .header .title,.dialog.banner .header .title{font-size:14px;margin:0;}' + '.dialog.notice #message,.dialog.banner #message{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:12px;line-height:1.35;}' + '.dialog.notice .intro ul,.dialog.notice .details,.dialog.banner .intro ul,.dialog.banner .details{display:none;}' + '.dialog.notice .actions,.dialog.banner .actions{display:flex;flex-wrap:nowrap;gap:8px;margin:8px 0 0;padding:0;}' + '.dialog.notice .actions button,.dialog.banner .actions button{flex:1;padding:9px 8px;font-size:13px;min-height:0;margin:0;}' + '.overlay{display:none!important;}'; var seenFired = false; function onBannerAppears(host) { // Treatment step 2: inject the compact stylesheet into the open shadow // root. A separate node, so Pandectes' own #pandectes-cmp-custom-styles // (their dashboard CSS) is never touched. if (arm === 'treatment') { try { var root = host.shadowRoot; if (root && !root.getElementById('kv-consent-compact')) { var st = document.createElement('style'); st.id = 'kv-consent-compact'; st.textContent = COMPACT_CSS; root.appendChild(st); } } catch (e) { /* selectors missed: default banner, fail open */ } // Treatment step 3: if the settings flip lost the init race, the // component may have locked body scroll before we got here. One // restore + one delayed recheck â not an observer war. var unlock = function () { try { if (getComputedStyle(document.body).overflow === 'hidden') document.body.style.overflow = 'visible'; if (getComputedStyle(document.documentElement).overflow === 'hidden') document.documentElement.style.overflow = 'visible'; } catch (e) {} }; unlock(); setTimeout(unlock, 600); } if (!seenFired) { seenFired = true; track('experiment_exposure', '', null); // once per visitor (upsert on stable id) track('consent_seen', 'seen-' + dayKey(), null); // daily denominator } } // The component mounts asynchronously; watch for it rather than racing it. // A visitor with stored consent never mounts a banner â observer sees // nothing â no exposure, which is exactly the right denominator. function watch() { var existing = document.querySelector('pandectes-cmp'); if (existing) { onBannerAppears(existing); return; } var mo = new MutationObserver(function () { var el = document.querySelector('pandectes-cmp'); if (el) { mo.disconnect(); onBannerAppears(el); } }); mo.observe(document.documentElement, { childList: true, subtree: true }); // A banner that has not mounted within a minute is not mounting on this // page view. Stop observing rather than watching forever. setTimeout(function () { mo.disconnect(); }, 60000); } // The deferred HUF gate: non-HU visitors get the flip reverted (before the // component initialises, which also happens after DOMContentLoaded) and are // never bucketed, watched, or stamped. function boot() { if (!(window.Shopify && window.Shopify.currency && window.Shopify.currency.active === 'HUF')) { revertFlip(); return; } watch(); stampCart(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); // ââ Choice â Shopify's documented event, not Pandectes internals ââââââââââ document.addEventListener('visitorConsentCollected', function (e) { var d = (e && e.detail && e.detail.customerPrivacy) || {}; track('consent_choice', 'choice-' + dayKey(), { analytics: d.analyticsProcessingAllowed === true, marketing: d.marketingAllowed === true }); }); // ââ Cart attribute stamp (both arms â a one-armed experiment cannot be read) // Fired once per session when a cart exists. A cart created later on this // same page view gets stamped on the next navigation, which every checkout // path includes. function stampCart() { if (!bookkeeping) return; try { if (/(^|;\s*)cart=/.test(document.cookie) && !sessionStorage.getItem('kvce_stamped')) { sessionStorage.setItem('kvce_stamped', '1'); fetch('/cart/update.js', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ attributes: { _experiment_consent: KEY + ':' + arm } }) }).catch(function () { try { sessionStorage.removeItem('kvce_stamped'); } catch (e) {} }); } } catch (e) { /* never block the page on bookkeeping */ } } })();