(function(){ const zoovuId = '7LamD1i3ZaQeLVEV5EIu1He2vlzBV55fvgBv/40rzVw='; const domainId = 'abde54a4-fe28-4750-b32d-0d8d70c48d41'; const variables = [{"id":"ad6080ea-719b-4732-952f-ef4f1ce45e02","name":"Event target","type":"FUNCTION","valueType":"EVENT","function":function(event){return event.target /* */},"scope":"GLOBAL"},{"id":"1e327f21-f8ab-4ffc-afbd-61bb59ee3728","name":"Event target - parent element","type":"FUNCTION","valueType":"EVENT","function":function(event){return event.target.parentElement; /* */},"scope":"GLOBAL"},{"id":"fda8e609-5410-4707-b86b-01184d2af597","name":"No permission to track","type":"CONSTANT","valueType":"TEXT","value":"No permission to track","scope":"GLOBAL"},{"id":"75cf8fd1-fafe-44be-9806-23ff3017c9c1","name":"Page URL","type":"FUNCTION","valueType":"TEXT","function":function(event){return window.location.href; /* */},"scope":"GLOBAL"},{"id":"77b40051-f993-4a87-91a3-576138dcf9f1","name":"[Shopify] atcPrice","type":"FUNCTION","valueType":"DECIMAL","function":function(event){const meta = window.ShopifyAnalytics?.meta; const cvs = document.querySelector('cart-variant-selector'); const vid = meta?.selectedVariantId || cvs?.getAttribute(':value') || document.querySelector('form[action*="/cart/add"] [name="id"]')?.value || null; if (vid && meta?.product?.variants) { const v = meta.product.variants.find(x => String(x.id) === String(vid)); if (v && typeof v.price === 'number') { return v.price / 100; } } if (vid && cvs) { try { const opts = JSON.parse(cvs.getAttribute(':options') || '[]'); const o = opts.find(x => String(x.id) === String(vid)); if (o && typeof o.price === 'number') { return o.price / 100; } } catch (e) {} } const addToCart = (window.dataLayer || []).findLast(e => e[0] === 'event' && e[1] === 'add_to_cart' && e[2]?.ecomm_prodid?.[0] ); return addToCart?.[2]?.items?.[0]?.price || null; /* */},"scope":"LOCAL"},{"id":"c7056012-5a9d-42e7-9b5e-f47b6484397a","name":"[Shopify] atcSKU","type":"FUNCTION","valueType":"TEXT","function":function(event){const meta = window.ShopifyAnalytics?.meta; if (meta?.selectedVariantId) { return String(meta.selectedVariantId); } const cvs = document.querySelector('cart-variant-selector'); const cvsVal = cvs?.getAttribute(':value'); if (cvsVal) { return String(cvsVal); } const formId = document.querySelector('form[action*="/cart/add"] [name="id"]')?.value; if (formId) { return String(formId); } const addToCart = (window.dataLayer || []).findLast(e => e[0] === 'event' && e[1] === 'add_to_cart' && e[2]?.ecomm_prodid?.[0] ); const rawId = addToCart?.[2]?.ecomm_prodid?.[0]; return rawId ? rawId.split('_').pop() : null; /* */},"scope":"LOCAL"},{"id":"722c53f4-d0be-4a44-afe5-ba0b7ecd1ce8","name":"[Shopify] Consent saved without analytics","type":"FUNCTION","valueType":"BOOLEAN","function":function(event){var root = document.querySelector('cookie-consent-popup'); if (!root) return false; var a = root.querySelector('.cookie-consent-popup-more-dialog--analytics'); if (!a) return false; return !(a.checked || a.hasAttribute('checked')); /* */},"scope":"LOCAL"},{"id":"7d2580b5-dbd4-4f5b-af42-37aa53e654ea","name":"[Shopify] Dynamic Currency","type":"FUNCTION","valueType":"TEXT","function":function(event){var dl = window.dataLayer || []; // Scan backwards so the latest purchase wins. gtag pushes its `arguments` // object (array-like, NOT a real Array), with currency directly on the // params object; GTM object-style pushes carry it under `ecommerce`. for (var i = dl.length - 1; i >= 0; i--) { var e = dl[i]; if (!e) continue; if (typeof e.length === 'number' && e[0] === 'event' && e[1] === 'purchase' && e[2]) { var c = e[2].currency || (e[2].ecommerce && e[2].ecommerce.currency); if (c) return c; } if (e.event === 'purchase' || e.event === 'custom_purchase') { var oc = (e.ecommerce && e.ecommerce.currency) || e.currency; if (oc) return oc; } } if (window.Shopify && window.Shopify.currency && window.Shopify.currency.active) { return window.Shopify.currency.active; } if (window.ShopifyAnalytics && window.ShopifyAnalytics.meta && window.ShopifyAnalytics.meta.currency) { return window.ShopifyAnalytics.meta.currency; } return null; /* */},"scope":"LOCAL"},{"id":"652079a8-4a1e-4c91-92b1-d37721cf75d1","name":"[Shopify] Dynamic Locale","type":"FUNCTION","valueType":"TEXT","function":function(event){const host = window.location.hostname.toLowerCase().replace(/^www\./, ''); let locale = null; if (host.startsWith('qc.') && host.endsWith('emma-sleep.ca')) { locale = 'fr-CA'; } else if (host.endsWith('emma-sleep.ca')) { locale = 'en-CA'; } else if (host.endsWith('emma-sleep-japan.com')) { locale = 'ja-JP'; } else if (host.endsWith('emma-sleep.co.jp')) { locale = 'ja-JP'; } else if (host.endsWith('emma-sleep.co.kr')) { locale = 'ko-KR'; } else if (host.endsWith('emma-sleep.com.ph')) { locale = 'en-PH'; } else if (host.endsWith('emma-sleep.in')) { locale = 'en-IN'; } if (locale) return locale; // Fallback: derive the locale from Shopify's own globals, which are present on // Shopify pages the host map doesn't cover (e.g. *.myshopify.com checkout / // order-status pages, or stores added later). This is what prevents the // "Required property locale missing" drops on unmapped hosts. try { const sl = window.Shopify && window.Shopify.locale; const sc = window.Shopify && window.Shopify.country; if (sl && sc) { return sl.toLowerCase() + '-' + sc.toUpperCase(); } const htmlLang = document.documentElement.lang; if (htmlLang && htmlLang.indexOf('-') !== -1) { return htmlLang; } } catch (e) {} return null; /* */},"scope":"LOCAL"},{"id":"70d2658d-fbe0-4565-9bf8-b8bd3d5feb4c","name":"[Shopify] PDP SKU","type":"FUNCTION","valueType":"TEXT","function":function(event){const meta = window.ShopifyAnalytics?.meta; if (meta?.selectedVariantId) { return String(meta.selectedVariantId); } const cvs = document.querySelector('cart-variant-selector'); const cvsVal = cvs?.getAttribute(':value'); if (cvsVal) { return String(cvsVal); } // Buy-box starting variant, exposed by the custom theme once its selector hydrates. const startVar = document.querySelector('[data-starting-variant]')?.getAttribute('data-starting-variant'); if (startVar) { return String(startVar); } const formId = document.querySelector('form[action*="/cart/add"] [name="id"]')?.value; if (formId) { return String(formId); } const urlVariant = new URLSearchParams(location.search).get('variant'); if (urlVariant) { return String(urlVariant); } const pdpSKU = (window.dataLayer || []).findLast(e => e[0] === 'event' && e[1] === 'view_item' && e[2]?.ecomm_prodid?.[0] ); const rawId = pdpSKU?.[2]?.ecomm_prodid?.[0]; if (rawId) { return rawId.split('_').pop(); } // Final fallback: the page product's first variant, always present in meta once // the product page has loaded. Prevents "Required property sku missing" drops on // the custom theme, where none of the buy-box sources above are mounted yet at // trigger time (render race). Represents the viewed page's product. const metaVariantId = meta?.product?.variants?.[0]?.id; if (metaVariantId) { return String(metaVariantId); } return null; /* */},"scope":"LOCAL"},{"id":"4b03af8d-c377-4c76-88ed-547d1378bc99","name":"[Shopify] PurchasedArray","type":"FUNCTION","valueType":"LIST","function":function(event){var dl = window.dataLayer || []; function skuFromId(raw) { if (raw == null) return null; const s = String(raw); // GA4 ids look like "shopify_JP_8471741169863_45476910301383" - the SKU we // store in the Data Platform is the trailing variant id. Some markets (e.g. // IN) already send the bare variant id, which has no prefix and passes through. return s.indexOf('shopify_') !== -1 ? s.split('_').pop() : s; } function extractItems(params) { if (!params) return null; if (Array.isArray(params.items)) return params.items; if (params.ecommerce && Array.isArray(params.ecommerce.items)) return params.ecommerce.items; return null; } function mapItems(items) { return items.map(function (item) { return { name: item.name != null ? item.name : item.item_name, sku: skuFromId(item.id != null ? item.id : (item.item_id != null ? item.item_id : item.sku)), quantity: item.quantity, pricePerUnit: item.price, }; }); } // Scan backwards so the latest purchase wins. gtag pushes its `arguments` // object (array-like, NOT a real Array - Array.isArray is false for it), // so tuples are matched by shape. Items may sit directly on the params // object (gtag) or under `ecommerce` (GTM object-style pushes). for (var i = dl.length - 1; i >= 0; i--) { var e = dl[i]; if (!e) continue; if (typeof e.length === 'number' && e[0] === 'event' && e[1] === 'purchase') { var tupleItems = extractItems(e[2]); if (tupleItems) return mapItems(tupleItems); } if (e.event === 'purchase' || e.event === 'custom_purchase') { var objItems = extractItems(e); if (objItems) return mapItems(objItems); } } return []; /* */},"scope":"LOCAL"},{"id":"63e8a33c-16fa-4c34-a2e7-fc954e66a559","name":"[Shopify] TransactionId","type":"FUNCTION","valueType":"TEXT","function":function(event){try { var dl = window.dataLayer || []; // 1. real order id from the dataLayer purchase (both push shapes) for (var i = dl.length - 1; i >= 0; i--) { var e = dl[i]; if (!e) continue; if (typeof e.length === 'number' && e[0] === 'event' && e[1] === 'purchase' && e[2]) { var t = e[2].transaction_id || (e[2].ecommerce && e[2].ecommerce.transaction_id); if (t) return String(t); } if (e.event === 'purchase' || e.event === 'custom_purchase') { var ot = (e.ecommerce && e.ecommerce.transaction_id) || e.transaction_id; if (ot) return String(ot); } } // 2. checkout token from the URL path - order-scoped, stable across revisits var m = window.location.pathname.match(/\/cn\/([A-Za-z0-9]+)/); if (m) return 'ZV-CN-' + m[1]; // 3. order-status query params (e.g. IN order tracking) - checkoutId primary, tokenId fallback var q = window.location.search; var ck = q.match(/[?&]checkoutId=([A-Za-z0-9-]+)/); if (ck) return 'ZV-CK-' + ck[1]; var tk = q.match(/[?&]tokenId=([A-Za-z0-9-]+)/); if (tk) return 'ZV-TK-' + tk[1]; // no reliable order identity available - let the backend backfill as before return; } catch (err) { return; } /* */},"scope":"LOCAL"}]; const script = {"id":"9ee26d78-2964-4d60-bf1d-6d9d790d1b95","rows":[{"id":"0c7c4f76-31f4-4fa7-b31f-e17bf0e2546d","rowType":"EVENT","trigger":{"id":"b1cde98d-95bc-4a97-bd90-e9e5437db87b","name":"[Shopify] Consent Decline Click","type":"CLICK","groups":[{"id":"c483c0fe-db52-4cd5-94a1-f10bd5152809","rows":[{"valueType":"EVENT","id":"c8cf37e2-5316-4809-98f7-52836c22f511","variableId":"ad6080ea-719b-4732-952f-ef4f1ce45e02","value":".cookie-consent-popup-main-dialog--deny,.cookie-consent-popup-main-dialog--deny *","operator":"MATCHES_CSS_SELECTOR"}]},{"id":"8b7a38e0-c2a0-4d29-aeb4-13ded5eebe9e","rows":[{"valueType":"EVENT","id":"e61c9ec6-8e3d-4a79-a02c-953b568ca229","variableId":"ad6080ea-719b-4732-952f-ef4f1ce45e02","value":".cookie-consent-popup-more-dialog--accept,.cookie-consent-popup-more-dialog--accept *","operator":"MATCHES_CSS_SELECTOR"},{"valueType":"BOOLEAN","id":"0dd322ac-bbab-43d0-b2f5-48d41c365c1b","variableId":"722c53f4-d0be-4a44-afe5-ba0b7ecd1ce8","value":"true","operator":"EQUALS"}]}]},"action":{"type":"DECLINE_TRACKING"},"fields":[{"id":"15d1800c-3a5f-46c4-8eda-9d66f23cb6d3","variableId":"652079a8-4a1e-4c91-92b1-d37721cf75d1","fieldName":"locale"},{"id":"01d9f4ee-4957-438b-9152-72aa2617ea00","variableId":"fda8e609-5410-4707-b86b-01184d2af597","fieldName":"eventLabel"}]},{"id":"f44abaa4-861e-464e-81b6-83e3d0b216d9","rowType":"CUSTOM_ACTION","trigger":{"id":"0e980f2b-8103-4ecc-a824-74710c7854f9","name":"[Shopify] Consent Dialog Click","type":"CLICK","groups":[{"id":"ae6eaa72-db79-4324-ba08-a1acae38d3e2","rows":[{"valueType":"EVENT","id":"4fa82e84-9bd5-405d-99d3-2148726f5fdc","variableId":"ad6080ea-719b-4732-952f-ef4f1ce45e02","value":".cookie-consent-popup-main-dialog--accept-all,.cookie-consent-popup-main-dialog--accept-all *","operator":"MATCHES_CSS_SELECTOR"}]},{"id":"00e217a2-520c-41b3-a1b3-be421accbf8b","rows":[{"valueType":"EVENT","id":"d82faec2-9dad-40e1-ba86-5a183fa09e41","variableId":"ad6080ea-719b-4732-952f-ef4f1ce45e02","value":".cookie-consent-popup-main-dialog--deny,.cookie-consent-popup-main-dialog--deny *","operator":"MATCHES_CSS_SELECTOR"}]},{"id":"5f66123e-f53e-497a-a37f-75b4ac2af4c6","rows":[{"valueType":"EVENT","id":"c6e6774a-7923-44a7-adc0-4c8e9fbb47de","variableId":"ad6080ea-719b-4732-952f-ef4f1ce45e02","value":".cookie-consent-popup-more-dialog--accept,.cookie-consent-popup-more-dialog--accept *","operator":"MATCHES_CSS_SELECTOR"}]}]},"action":{"id":"f2734c6d-b985-4ac5-8e1a-3e039e657f2e","type":"CUSTOM","code":function(event){(function () { var t = event && event.target; if (!t || typeof t.closest !== 'function') { return; } // 受け入れる — accept everything if (t.closest('.cookie-consent-popup-main-dialog--accept-all')) { enableTracking(); return; } // 同意しない — decline everything if (t.closest('.cookie-consent-popup-main-dialog--deny')) { disableTracking(); return; } // 同意する — save the preferences dialog: follow the analytics box (2nd of three). // Marketing and comfort/preferences do not govern behavioural tracking. if (t.closest('.cookie-consent-popup-more-dialog--accept')) { var a = document.querySelector('.cookie-consent-popup-more-dialog--analytics'); if (a && (a.checked || a.hasAttribute('checked'))) { enableTracking(); } else { disableTracking(); } } })(); /* */}},"fields":[]},{"id":"5490f1c3-3576-42b1-a09a-5a90f8d1143d","rowType":"EVENT","trigger":{"id":"943d0028-caf6-4f91-bfae-4a1cd38af2d7","name":"[Shopify] isConfirmationPage","type":"PAGE_LOAD","delayMs":6000,"groups":[{"id":"8eb2230e-d629-42a7-a128-95ee3e32ca1b","rows":[{"valueType":"TEXT","id":"1efc2757-9cf7-4656-99bf-83844b657935","variableId":"75cf8fd1-fafe-44be-9806-23ff3017c9c1","value":"/order-status","operator":"CONTAINS"}]},{"id":"eda01314-caac-44d7-b183-68ed903d1209","rows":[{"valueType":"TEXT","id":"52c03f2d-30af-47aa-bcde-33350bbf0525","variableId":"75cf8fd1-fafe-44be-9806-23ff3017c9c1","value":"/thank-you","operator":"CONTAINS"}]},{"id":"1d14e2ca-195c-43d2-9e86-b6ce54bb4794","rows":[{"valueType":"TEXT","id":"a2e56545-ce9a-40d5-b8ca-647fdc861a82","variableId":"75cf8fd1-fafe-44be-9806-23ff3017c9c1","value":"/checkouts/","operator":"CONTAINS"}]}]},"action":{"type":"PURCHASED"},"fields":[{"id":"27d59d96-e957-4f63-8add-7de90b12d1a0","variableId":"7d2580b5-dbd4-4f5b-af42-37aa53e654ea","fieldName":"currencyCode"},{"id":"8900d5a8-195b-46ee-8b58-d2d00f5d01b2","variableId":"4b03af8d-c377-4c76-88ed-547d1378bc99","fieldName":"products"},{"id":"6ae54d6e-1e47-4ad4-9bdd-f981639bdaed","variableId":"63e8a33c-16fa-4c34-a2e7-fc954e66a559","fieldName":"transactionId"}]},{"id":"565512c7-a050-46e4-a470-302e530aacab","rowType":"EVENT","trigger":{"id":"4d0e8de0-4965-4a6f-8afd-9972b127d0f1","name":"[Shopify] atcClick","type":"CLICK","delayMs":4000,"groups":[{"id":"869b872c-e97d-4648-85d7-e61c03602c82","rows":[{"valueType":"EVENT","id":"22ec7224-e220-4cc8-b689-1a4afe255159","variableId":"1e327f21-f8ab-4ffc-afbd-61bb59ee3728","value":"myaddtobtn","operator":"CONTAINS_CLASS"}]},{"id":"62c0a5fb-8296-4dcc-b02f-dd0434b1e555","rows":[{"valueType":"EVENT","id":"2b34fec4-80c0-49b3-9809-4648ff9b265f","variableId":"ad6080ea-719b-4732-952f-ef4f1ce45e02","value":"myaddtobtn","operator":"CONTAINS_CLASS"}]},{"id":"c21dbf0c-e9e9-444a-aace-c99b3ef9bc61","rows":[{"valueType":"EVENT","id":"50413d44-b300-4f02-a468-9a447129d1f4","variableId":"1e327f21-f8ab-4ffc-afbd-61bb59ee3728","value":"add-to-cart-btn","operator":"CONTAINS_CLASS"}]},{"id":"7070cef8-1bbc-4dfd-829e-01e31ef07a06","rows":[{"valueType":"EVENT","id":"1348aedd-6758-41d2-8bce-83c6e3687da0","variableId":"ad6080ea-719b-4732-952f-ef4f1ce45e02","value":"add-to-cart-btn","operator":"CONTAINS_CLASS"}]},{"id":"cac4ea62-17f5-4413-a888-5e9ed8c93bee","rows":[{"valueType":"EVENT","id":"1ef9097d-dffb-43df-b171-a74538838b05","variableId":"ad6080ea-719b-4732-952f-ef4f1ce45e02","value":"product-form__submit","operator":"CONTAINS_CLASS"}]},{"id":"8f1a6934-aa27-404d-a43a-3144026662ee","rows":[{"valueType":"EVENT","id":"51a34cc2-81f4-485b-aa6c-36aa47131edf","variableId":"1e327f21-f8ab-4ffc-afbd-61bb59ee3728","value":"product-form__submit","operator":"CONTAINS_CLASS"}]},{"id":"79570909-b08a-491e-ab33-e18d3f29ed7c","rows":[{"valueType":"EVENT","id":"6d1e51bd-9ed5-4dbd-ab9c-efacd95adcf3","variableId":"ad6080ea-719b-4732-952f-ef4f1ce45e02","value":"button[name=\"add\"], button[name=\"add\"] *","operator":"MATCHES_CSS_SELECTOR"}]}]},"action":{"type":"ADD_TO_CART"},"fields":[{"id":"2ce9771d-bb7d-48dc-960a-c07b9af267cb","variableId":"652079a8-4a1e-4c91-92b1-d37721cf75d1","fieldName":"locale"},{"id":"20fa67f1-2dfa-49de-a3bb-c40598b32d2d","variableId":"c7056012-5a9d-42e7-9b5e-f47b6484397a","fieldName":"sku"},{"id":"2d044e0c-8356-4599-a18e-a8dce6a2be00","variableId":"77b40051-f993-4a87-91a3-576138dcf9f1","fieldName":"price"},{"id":"3b49431d-529b-493c-9d8f-67f072fe0de9","variableId":"7d2580b5-dbd4-4f5b-af42-37aa53e654ea","fieldName":"currencyCode"}]},{"id":"0c40ce7b-837f-419b-b80d-1768abcfafd3","rowType":"EVENT","trigger":{"id":"8910e8b6-4cb9-4bf9-b51e-690ce0835260","name":"[Shopify] PDP Load","type":"PAGE_LOAD","delayMs":2000,"groups":[{"id":"3e1c1a4f-d8ba-425c-b33c-798fb23eb424","rows":[{"valueType":"TEXT","id":"ebf2343f-7e32-42fb-8666-6c44e64a6711","variableId":"75cf8fd1-fafe-44be-9806-23ff3017c9c1","value":"/products/","operator":"CONTAINS"}]}]},"action":{"type":"PDP_VISITED"},"fields":[{"id":"2ae43c51-b2a9-4a94-8d1e-8fc3dd5476cf","variableId":"652079a8-4a1e-4c91-92b1-d37721cf75d1","fieldName":"locale"},{"id":"a864df5c-8b78-49df-8b26-36570f00bad2","variableId":"70d2658d-fbe0-4565-9bf8-b8bd3d5feb4c","fieldName":"sku"}]},{"id":"1a518f60-ed2b-48c7-9603-c1ac61389a60","rowType":"EVENT","trigger":{"id":"c324713f-a423-4e06-bd79-59de72dd0431","name":"[Shopify] Page Load","type":"PAGE_LOAD","groups":[{"id":"b4190d77-f667-41a6-a2da-c3a4e19c1f93","rows":[{"valueType":"TEXT","id":"33eedb3f-250e-4492-957a-6710b00ffbe2","variableId":"75cf8fd1-fafe-44be-9806-23ff3017c9c1","value":"/products/","operator":"DOES_NOT_CONTAIN"}]}]},"action":{"type":"PAGE_VISITED"},"fields":[{"id":"e861e517-add0-48ff-8f3f-e491dc9b2921","variableId":"652079a8-4a1e-4c91-92b1-d37721cf75d1","fieldName":"locale"}]}]}; const advancedCode = function(){enableShopifyDataLayerPurchaseTrigger({ timeoutMs: 1800000 }); /* zoovu-consent-gate:start */ // Consent gate. On the JP shop Shopify keeps its analytics cookies only while analytics // consent is granted: it deletes _shopify_y/_shopify_s the moment the visitor declines // and does not re-set them, so a missing cookie means "not allowed to track". // Runs before any trigger is evaluated — executeAdvancedCode() precedes reactOnLoad(). // // Host-scoped on purpose (all six storefronts share this script, measured 2026-07-27): // ca/qc/kr/in always have the cookie, so gating them would be a no-op; emma-sleep.com.ph // gates the cookie like JP but its consent bar never writes Shopify consent, so the // cookie is never set there and gating it would disable PH tracking permanently. if (/(^|\.)emma-sleep-japan\.com$/.test(window.location.hostname) && !/(?:^|;\s*)_shopify_y=/.test(document.cookie)) { disableTracking(); } /* zoovu-consent-gate:end */}; const url = 'https://queue-propagator.zoovu.com'; const currentEnvironment = 'orca'; const currentAccountId = 250000890; const currencies = ['FJD', 'STD', 'MXN', 'LVL', 'SCR', 'CDF', 'BBD', 'UGX', 'HNL', 'ZAR', 'MXV', 'STN', 'CUC', 'BSD', 'SDD', 'SDG', 'IQD', 'CUP', 'GMD', 'TWD', 'RSD', 'UYI', 'MYR', 'FKP', 'XOF', 'UYU', 'CVE', 'OMR', 'KES', 'SEK', 'BTN', 'GNF', 'MZN', 'MZM', 'SVC', 'ARS', 'QAR', 'NLG', 'IRR', 'XPD', 'XPF', 'UZS', 'THB', 'BDT', 'LYD', 'KWD', 'XPT', 'RUB', 'ISK', 'BEF', 'MKD', 'RUR', 'DZD', 'PAB', 'SGD', 'KGS', 'XAD', 'XAF', 'XAG', 'HRK', 'ATS', 'ITL', 'CHF', 'DJF', 'CHE', 'TZS', 'VND', 'XAU', 'ADP', 'AUD', 'CHW', 'KHR', 'XBA', 'IDR', 'KYD', 'XBC', 'XBB', 'SHP', 'BWP', 'XBD', 'CYP', 'TJS', 'AED', 'RWF', 'DKK', 'ZWD', 'BGL', 'BGN', 'MMK', 'SYP', 'ZWG', 'NOK', 'ZWL', 'YUM', 'ZWN', 'LKR', 'ZWR', 'IEP', 'CZK', 'XCD', 'GRD', 'HTG', 'XSU', 'AFA', 'XCG', 'SIT', 'BHD', 'PTE', 'KZT', 'SZL', 'YER', 'AFN', 'BYB', 'AWG', 'NPR', 'MNT', 'GBP', 'XTS', 'BYN', 'HUF', 'BYR', 'BIF', 'XUA', 'XDR', 'BZD', 'MOP', 'NAD', 'SKK', 'TMM', 'PEN', 'WST', 'TMT', 'FRF', 'CLF', 'GTQ', 'CLP', 'TND', 'SLE', 'SLL', 'AYM', 'XFO', 'DOP', 'KMF', 'XFU', 'GEL', 'MAD', 'TOP', 'AZM', 'PGK', 'AZN', 'UAH', 'ERN', 'TPE', 'MRO', 'CNY', 'MRU', 'BMD', 'PHP', 'XXX', 'PYG', 'JMD', 'GWP', 'ESP', 'COP', 'USD', 'COU', 'USN', 'ETB', 'VEB', 'VED', 'USS', 'VEF', 'SOS', 'VUV', 'LAK', 'BND', 'ZMK', 'LRD', 'ALL', 'GHC', 'MTL', 'VES', 'ZMW', 'TRL', 'ILS', 'KPW', 'GYD', 'GHS', 'BOB', 'MDL', 'AMD', 'TRY', 'LBP', 'JOD', 'HKD', 'EUR', 'LSL', 'CAD', 'BOV', 'EEK', 'MUR', 'ROL', 'GIP', 'RON', 'NGN', 'CRC', 'PKR', 'ANG', 'SRD', 'SAR', 'TTD', 'LTL', 'MVR', 'SRG', 'INR', 'KRW', 'JPY', 'PLN', 'AOA', 'SBD', 'CSD', 'LUF', 'MWK', 'MGA', 'FIM', 'DEM', 'MGF', 'BAM', 'EGP', 'SSP', 'NIO', 'NZD', 'BRL'] // ------------------------- API ------------------------- let trackingEnabled = true; let hasLauncher = typeof advancedCode === 'function' && advancedCode.toString().includes('/behavioral-launchers-script'); let signalReady = false; let signalReadyPromise = null; let signalCheckAttempts = 0; const MAX_SIGNAL_CHECK_ATTEMPTS = 20; // 20 attempts const SIGNAL_CHECK_INTERVAL_MS = 200; // Check every 200ms (total 4 seconds max wait) let eventQueue = []; function logDebug(...args) { if (typeof console !== 'undefined' && typeof console.debug === 'function') { console.debug(...args); } } /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function disableTracking() { trackingEnabled = false; } /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function enableTracking() { trackingEnabled = true; } /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function getNumericPriceFromString(stringPrice) { const parsedPrice = stringPrice .replace(/\.$/, '') .replace(/([^.',\s\d])*/g, '') .replace(/([.',\s](?=\d{3}))/g, '') .replace(/([.',](?=\d{2}))/g, '.') .trim() .replace(/\s+/g, '') .replace(/\.+(?=\.)/g, '') .replace(/\.$/, ''); if (parsedPrice === '') { throw new Error(`getNumericPriceFromString: "${stringPrice}" is not a valid price (no numeric value found).`); } const numericPrice = Number(parsedPrice); if (Number.isNaN(numericPrice)) { throw new Error(`getNumericPriceFromString: "${stringPrice}" is not a valid price (parsed to NaN).`); } return numericPrice; } // helper for loading launcher from advancedmode /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function loadLauncher(launcherUrl, callback, id) { if (launcherUrl) { const script = document.createElement('script'); script.type = 'text/javascript'; script.src = launcherUrl; script.id = id || 'zv-launcher'; script.onload = callback; document.head.appendChild(script); } } /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function setSignalReady() { signalReady = true; if (eventQueue.length > 0) { logDebug(`Signal ready - flushing ${eventQueue.length} queued events`); const eventsToFlush = [...eventQueue]; eventQueue = []; eventsToFlush.forEach(queuedEvent => { sendEventImmediately(queuedEvent); }); } } if (typeof window !== 'undefined') { if (typeof window.ZoovuTrackingManager === 'undefined') { window.ZoovuTrackingManager = {}; } window.ZoovuTrackingManager.setSignalReady = setSignalReady; } class TrackingExecutionError extends Error { constructor(message, trackingEntityType, variableId, triggerId, scriptId, trackingErrorType) { super(message); this.trackingEntityType = trackingEntityType; this.variableId = variableId; this.triggerId = triggerId; this.scriptId = scriptId; this.trackingErrorType = trackingErrorType; } } // ------------------------- helpers ------------------------- const eventTypes = Object.freeze({ PAGE_VISITED: "PAGE_VISITED", ADD_TO_CART: "ADD_TO_CART", REMOVE_FROM_CART: "REMOVE_FROM_CART", PDP_VISITED: "PDP_VISITED", PURCHASED: "PURCHASED", UPDATE_CART: "UPDATE_CART", DECLINE_TRACKING: "DECLINE_TRACKING", SEARCH: "SEARCH", SEARCH_RESULT: "SEARCH_RESULT", CLICKOUT: "CLICKOUT", LEAD_GEN: "LEAD_GEN" }); const trackingEventTypes = Object.freeze({ TRACKING_SUCCESSFUL_EXECUTION: 'TRACKING_SUCCESSFUL_EXECUTION', TRACKING_EXECUTION_FAILURE: 'TRACKING_EXECUTION_FAILURE' }) const actionTypes = Object.freeze({ ...eventTypes, CUSTOM: 'CUSTOM' }) const triggerTypes = Object.freeze({ CLICK: 'CLICK', INPUT: 'INPUT', PAGE_LOAD: 'PAGE_LOAD' }); const variableTypes = Object.freeze({ FUNCTION: 'FUNCTION', CONSTANT: 'CONSTANT' }); const trackingEntityTypes = Object.freeze({ VARIABLE: 'VARIABLE', TRIGGER: 'TRIGGER', CUSTOM_ACTION: 'CUSTOM_ACTION' }); const trackingErrorTypes = Object.freeze({ MISSING_VALUE: 'MISSING_VALUE', TYPE_MISMATCH: 'TYPE_MISMATCH', UNSUPPORTED_VARIABLE: 'UNSUPPORTED_VARIABLE', MISSING_VARIABLE: 'MISSING_VARIABLE', OTHER: 'OTHER' }); const trackingVariableScopes = Object.freeze({ GLOBAL: 'GLOBAL', LOCAL: 'LOCAL' }); const namedReferrals = Object.freeze({ SEARCH: 'SEARCH' }) const domainRestrictedFieldNames = Object.freeze({ CURRENCY_CODE: 'currencyCode', NAMED_REFERRAL: 'namedReferral' }); // Fields whose value must belong to a fixed domain. `optional: true` means nullish is accepted; // otherwise nullish is itself an incompatible value. Add new entries here to extend. const domainRestrictedFields = Object.freeze({ [domainRestrictedFieldNames.CURRENCY_CODE]: { optional: true, isValid: value => currencies.includes(value) }, [domainRestrictedFieldNames.NAMED_REFERRAL]: { optional: false, isValid: value => value != null && !!namedReferrals[String(value).toUpperCase()] } }); const getElementAttribute = (target, attributeName) => { const attribute = target[attributeName]; if (typeof attribute === 'string') { return attribute; } if (attribute && attribute.baseVal !== undefined) { return attribute.baseVal; } return target.getAttribute(attributeName === 'className' ? 'class' : attributeName) || ''; }; const getElementId = (target) => { return getElementAttribute(target, 'id'); }; const getElementClassName = (target) => { return getElementAttribute(target, 'className'); }; const matchers = Object.freeze({ EQUALS: (a, b) => a === b, DOES_NOT_EQUAL: (a, b) => a !== b, CONTAINS: (a, b) => a.indexOf(b) >= 0, DOES_NOT_CONTAIN: (a, b) => a.indexOf(b) === -1, GREATER_THAN: (a, b) => a > b, LESS_THAN: (a, b) => a < b, GREATER_THAN_OR_EQUAL: (a, b) => a >= b, LESS_THAN_OR_EQUAL: (a, b) => a <= b, MATCHES_CSS_SELECTOR: (target, test) => target.matches(test), MATCHES_ID: (target, test) => getElementId(target) === test, CONTAINS_ID: (target, test) => getElementId(target).includes(test), DOES_NOT_CONTAIN_ID: (target, test) => !getElementId(target).includes(test), MATCHES_CLASS: (target, test) => getElementClassName(target) === test, CONTAINS_CLASS: (target, test) => getElementClassName(target).includes(test), DOES_NOT_CONTAIN_CLASS: (target, test) => !getElementClassName(target).includes(test), MATCHES_REGEX: (a, b) => parseRegexString(b).test(a), }); const trackingFieldName = `${domainId}_${zoovuId}_trackingExecutions` const MAX_RECORDS = 100 // helper to retrieve variableId later let eventFields = [] if (!JSON.parse(localStorage.getItem(trackingFieldName))) { localStorage.setItem(trackingFieldName, JSON.stringify([])) } function parseRegexString(input) { const trimmed = input.trim(); // check if it starts and ends with slashes (e.g., /^abc$/gi) const match = trimmed.match(/^\/(.+)\/([a-z]*)$/i); if (match) { const pattern = match[1]; const flags = match[2]; return new RegExp(pattern, flags); } // no flags return new RegExp(trimmed); } function checkValue(type, value) { switch (type) { case 'BOOLEAN': return value === true || value === 'true'; case 'TEXT': return value; case 'DECIMAL': return typeof value === 'string' ? parseFloat(value) : value; case 'INTEGER': return typeof value === 'string' ? parseInt(value) : value; default: return value; } } function cast(type, value) { switch (type) { case 'BOOLEAN': return value === true || value === 'true'; case 'TEXT': return value; case 'DECIMAL': return typeof value === 'string' ? parseFloat(value) : value; case 'INTEGER': return typeof value === 'string' ? parseInt(value) : value; default: return value; } } function debounce(fn, delay, useTrailing = false) { if (useTrailing) { // Trailing debounce for INPUT triggers - only fires once after delay using requestAnimationFrame let lastCallTime = 0; let animationFrameId = null; let hasScheduledExecution = false; function checkAndExecute(context, args) { const currentTime = performance.now(); const timeSinceLastCall = currentTime - lastCallTime; if (timeSinceLastCall >= delay) { // Delay has passed, execute the function fn.apply(context, args); hasScheduledExecution = false; } else { // Keep checking until delay has passed animationFrameId = requestAnimationFrame(() => { checkAndExecute(context, args); }); } } return function () { const context = this, args = arguments; lastCallTime = performance.now(); if (!hasScheduledExecution) { hasScheduledExecution = true; animationFrameId = requestAnimationFrame(() => { checkAndExecute(context, args); }); } }; } // Original requestAnimationFrame-based debounce for other triggers let lastCallTime = 0; let animationFrameId = null; let isScheduled = false; function execute(context, args, currentTime) { if (currentTime - lastCallTime >= delay) { fn.apply(context, args); isScheduled = false; } else { animationFrameId = requestAnimationFrame((newTime) => { execute(context, args, newTime); }); } } return function () { const context = this, args = arguments; lastCallTime = performance.now(); if (!isScheduled) { isScheduled = true; animationFrameId = requestAnimationFrame((currentTime) => { execute(context, args, currentTime); }); } }; } function getOrganization() { return zoovuId; } function getDomainId() { return domainId; } function getTimezone() { return Intl.DateTimeFormat().resolvedOptions().timeZone; } function getReferrer() { const referrer = document.referrer; if (/^(https?|android-app):\/\//i.test(referrer)) { return referrer; } else { return undefined; } } function getPath() { return window.location.href.replace(window.location.origin, ''); } function getCookieValue(cookieName) { const cookies = document.cookie.split('; '); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].split('='); // Skip malformed cookies with multiple '=' signs if (cookie.length !== 2) { continue; } if (cookie[0] === cookieName) { return cookie[1]; } } return null; } function getAllCookieValues(cookieName) { const cookies = document.cookie.split('; '); const values = []; for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].split('='); // Skip malformed cookies with multiple '=' signs if (cookie.length !== 2) { continue; } if (cookie[0] === cookieName) { values.push(cookie[1]); } } return values; } function generateUuid() { return self.crypto.randomUUID(); } function isValidCidFormat(cid) { // UUID (36 chars) or UUID_timestamp (36 + 1 + 13 = 50 chars) // Max 60 chars to allow some buffer return cid && cid.length <= 60; } let cachedRegistrableDomain = null; function getRegistrableDomain(hostname) { if (cachedRegistrableDomain) { return cachedRegistrableDomain; } const parts = hostname.split('.'); if (parts.length <= 2) { cachedRegistrableDomain = hostname; return hostname; } // Probe from broadest to narrowest domain level. // Browsers refuse to set cookies on public suffixes (e.g. "co.uk"), // so the first level that accepts a cookie is the registrable domain. for (let i = 2; i <= parts.length; i++) { const candidate = parts.slice(-i).join('.'); const probe = `__zoovu_probe_${Math.random().toString(36).slice(2, 10)}`; document.cookie = `${probe}=1; domain=.${candidate}; path=/; max-age=10`; if (getCookieValue(probe) !== null) { // Clean up the probe cookie document.cookie = `${probe}=; domain=.${candidate}; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`; cachedRegistrableDomain = candidate; return candidate; } } // Fallback – should rarely be reached cachedRegistrableDomain = parts.slice(-2).join('.'); return cachedRegistrableDomain; } function removeZoovuCidCookie() { const hostname = window.location.hostname; const domain = getRegistrableDomain(hostname); // Set expiration to past date to remove cookie document.cookie = `zoovu-cid=; path=/; domain=.${domain}; expires=Thu, 01 Jan 1970 00:00:00 GMT`; document.cookie = `zoovu-cid=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`; } function removeMalformedZoovuCidCookies() { const cookies = document.cookie.split('; '); let hasMalformed = false; // Check for structurally malformed cookies (multiple '=' signs) for (let i = 0; i < cookies.length; i++) { const parts = cookies[i].split('='); if (parts[0] === 'zoovu-cid' && parts.length !== 2) { hasMalformed = true; break; } } // Check for invalid CID formats in properly structured cookies if (!hasMalformed) { const allCids = getAllCookieValues('zoovu-cid'); hasMalformed = allCids.some(cid => !isValidCidFormat(cid)); } if (hasMalformed) { logDebug('[Zoovu Tracking] Found malformed CID cookies, removing them'); removeZoovuCidCookie(); } } function writeZoovuCidCookie(cid) { const hostname = window.location.hostname; const domain = getRegistrableDomain(hostname); const oneYearInSeconds = 365 * 24 * 60 * 60; let zoovuCid = `zoovu-cid=${cid}; path=/; domain=.${domain}; max-age=${oneYearInSeconds}`; if (window.isSecureContext) { zoovuCid += `; Secure`; } document.cookie = zoovuCid; } function addZoovuCidToCookies() { const cid = `${generateUuid()}_${Date.now()}`; writeZoovuCidCookie(cid); return cid; } /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function getCID() { removeMalformedZoovuCidCookies(); // Check if we have a valid cookie const existingCid = getCookieValue(`zoovu-cid`); if (existingCid !== null && isValidCidFormat(existingCid)) { writeZoovuCidCookie(existingCid); return existingCid; } // No valid cookie exists, create a new one return addZoovuCidToCookies(); } function getEnvironment() { return currentEnvironment; } function getAccountId() { return currentAccountId; } function getPropertyTypeError(propertyName, expectedType, currentType) { return `TYPE MISMATCH: ${propertyName}: should be ${expectedType} but is ${currentType}` } function typeCheckValue(value, variableType, propertyName, variableId, triggerId) { // Missing values are allowed here — required-field enforcement lives in ensureRequiredFieldsPresent. // Optional fields may legitimately return null/undefined (e.g. category, price). if (value === undefined || value === null) { return; } let errorMessage = ''; switch (variableType) { case "TEXT": if (typeof value !== 'string') errorMessage = getPropertyTypeError(propertyName, 'string', typeof value); break; case "INTEGER": case "DECIMAL": if (typeof value !== 'number') errorMessage = getPropertyTypeError(propertyName, 'number', typeof value); break; case "BOOLEAN": if (typeof value !== 'boolean') errorMessage = getPropertyTypeError(propertyName, 'boolean', typeof value); break; case 'LIST': if (!Array.isArray(value)) errorMessage = getPropertyTypeError(propertyName, 'list', typeof value); break; case 'EVENT': break; default: throw new TrackingExecutionError(`Not supported variable type: ${variableType}`, trackingEntityTypes.VARIABLE, variableId, triggerId, script.id, trackingErrorTypes.UNSUPPORTED_VARIABLE) } if (errorMessage) { throw new TrackingExecutionError(errorMessage, trackingEntityTypes.VARIABLE, variableId, triggerId, script.id, trackingErrorTypes.TYPE_MISMATCH) } } function ensureRequiredFieldsPresent(fields, eventType) { return Object.entries(fields).every(([key, value]) => { if (!value) { // eventFields is only populated on the rule-driven path; direct public-API calls // (e.g. ZoovuTrackingManager.sendPurchaseEvent) reach this with eventFields empty, // where the unguarded lookup used to throw a raw TypeError instead of the intended // TrackingExecutionError. const matchedField = eventFields.find(field => field.fieldName === key); const variableId = matchedField ? matchedField.variableId : null; // empty eventFields eventFields = []; throw new TrackingExecutionError( `Required property ${key} missing value for ${eventType}.`, trackingEntityTypes.VARIABLE, variableId, null, script.id, trackingErrorTypes.MISSING_VALUE ) } return true; }); } function getBaseEventBody(eventType) { return { origin: 'CLIENT', queryParams: {}, organization: getOrganization(), domainId: getDomainId(), path: getPath(), referrer: getReferrer(), cid: getCID(), timezone: getTimezone(), // dynamic eventType: eventType ?? '', eventLabel: '', }; } function getEventExecutable(actionType) { switch (actionType) { case actionTypes.PAGE_VISITED: return sendPageVisitedEvent; case actionTypes.ADD_TO_CART: return sendAddToCartEvent; case actionTypes.CLICKOUT: return sendClickoutEvent; case actionTypes.REMOVE_FROM_CART: return sendRemoveFromCartEvent; case actionTypes.PDP_VISITED: return sendPdpVisitedEvent; case actionTypes.PURCHASED: return sendPurchaseEvent; case actionTypes.DECLINE_TRACKING: return sendDeclineTrackingEvent; case actionTypes.UPDATE_CART: return sendUpdateCartEvent; case actionTypes.SEARCH: return sendSearchEvent; case actionTypes.SEARCH_RESULT: return sendSearchResultEvent; case actionTypes.LEAD_GEN: return sendLeadGenEvent; } } function checkSpecificFieldValues(fieldName, value, variableId) { const validator = domainRestrictedFields[fieldName]; if (!validator) { return; } // Optional fields accept nullish — ensureRequiredFieldsPresent enforces presence where needed // (e.g. currencyCode on PURCHASED). For non-optional fields, nullish is itself incompatible. if (value == null && validator.optional) { return; } if (!validator.isValid(value)) { throw new TrackingExecutionError( `Value ${value} is not compatible with ${fieldName}.`, trackingEntityTypes.VARIABLE, variableId, null, script.id, trackingErrorTypes.OTHER ) } } function getVariableValueById(variableId, event, fieldName, triggerId) { const foundVariable = variables.find(v => v.id === variableId); let variableValue = null; if (foundVariable) { if (foundVariable.type === variableTypes.CONSTANT) { variableValue = cast(foundVariable.valueType, foundVariable.value); } else if (foundVariable.type === variableTypes.FUNCTION && typeof foundVariable.function === 'function') { try { variableValue = foundVariable.function(event); } catch (e) { throw new TrackingExecutionError( e.message, trackingEntityTypes.VARIABLE, variableId, triggerId, script.id, trackingErrorTypes.OTHER ) } } else { throw new TrackingExecutionError( `${foundVariable.type} is not supported.`, trackingEntityTypes.VARIABLE, variableId, triggerId, script.id, trackingErrorTypes.UNSUPPORTED_VARIABLE ) } typeCheckValue(variableValue, foundVariable.valueType, fieldName ? fieldName : foundVariable.name, variableId, triggerId); if (domainRestrictedFields[fieldName]) { checkSpecificFieldValues(fieldName, variableValue, variableId) } return variableValue; } else { throw new TrackingExecutionError( `No variable with ID ${variableId}`, trackingEntityTypes.VARIABLE, variableId, triggerId, script.id, trackingErrorTypes.MISSING_VARIABLE ) } } function evaluateSingleTriggerRow(row, event, triggerId) { const target = getVariableValueById(row.variableId, event, undefined, triggerId); // Trigger variable returned no value — treat as "condition not met" rather than crashing the matcher. if (target === undefined || target === null) { return false; } const matcher = matchers[row.operator]; checkValue(row.valueType, row.value); const value = cast(row.valueType, row.value); if (matcher && typeof matcher === 'function') { return matcher(target, value); } return false; } function preparePayloadFromFields(fields, event) { const payload = {}; fields.forEach(f => { payload[f.fieldName] = getVariableValueById(f.variableId, event, f.fieldName) }) return payload; } function evaluateTriggerConditions(trigger, event) { // OR between groups return trigger.groups.some(group => // AND between rows group.rows.every(row => evaluateSingleTriggerRow(row, event, trigger.id))) } function runAction(action, fields, event, trigger) { const trackingExecutions = jsonParser(trackingFieldName) if (action.type === actionTypes.CUSTOM && typeof action.code === 'function') { // add script custom action trackingExecutions.push(createTrackingSuccessfulExecutionRecord(trackingEntityTypes.CUSTOM_ACTION, action.id)) try { action.code(event); } catch (e) { throw new TrackingExecutionError( e.message, trackingEntityTypes.CUSTOM_ACTION, action.id, null, script.id, trackingErrorTypes.OTHER ) } localStorage.setItem(trackingFieldName, JSON.stringify(trackingExecutions)); } else { const sendEventFunction = getEventExecutable(action.type); eventFields = [...fields]; if (sendEventFunction && typeof sendEventFunction === 'function') { const payload = preparePayloadFromFields(fields, event) sendEventFunction(payload); addSuccessfulExecutions(trigger, fields) } } } // Store debounced functions per rule to reuse them const debouncedRuleActions = new Map(); let ruleCounter = 0; function recordTrackingError(error) { try { const trackingExecutions = jsonParser(trackingFieldName); const isKnown = error instanceof TrackingExecutionError; const failedExecution = createTrackingFailedExecutionRecord( isKnown ? error.trackingEntityType : null, isKnown ? error.variableId : null, isKnown ? error.triggerId : null, isKnown ? error.scriptId : script.id, isKnown ? error.trackingErrorType : trackingErrorTypes.OTHER, (error && error.message) || String(error) ); trackingExecutions.push(failedExecution); localStorage.setItem(trackingFieldName, JSON.stringify(trackingExecutions)); } catch (recordingError) { logDebug('Failed to record tracking error', recordingError); } logDebug(error); } // ------------------------- eager purchase trigger (opt-in, see enableShopifyDataLayerPurchaseTrigger) ------------------------- let eagerPurchaseTriggerOptions = null; const eagerPendingRules = new Map(); // once fired in this document, never again - SPA URL changes re-arm rules in tight loops const eagerFiredRules = new Set(); function isPixelSandboxContext() { return typeof window !== 'undefined' && window.location.pathname.startsWith('/web-pixels@'); } function isEagerEligibleRule(rule) { return eagerPurchaseTriggerOptions !== null && rule.trigger.type === triggerTypes.PAGE_LOAD && rule.action && rule.action.type === actionTypes.PURCHASED; } function isPurchasePayloadReady(payload) { // [] would pass ensureRequiredFieldsPresent but is dropped silently by the backend return Array.isArray(payload.products) && payload.products.length > 0 && !!payload.currencyCode; } function attemptEagerRule(entry) { // .resolving blocks re-entrancy: payload resolution can re-trigger the dataLayer hooks if (entry.done || entry.resolving) { return; } entry.resolving = true; let payload; try { payload = preparePayloadFromFields(entry.rule.fields, entry.event); } catch (e) { return; // variables not resolvable yet - keep waiting } finally { entry.resolving = false; } // !payload is unreachable for today's preparePayloadFromFields, but this function runs // inside merchant dataLayer.push calls and must never throw there (test-pinned contract) if (!payload || !isPurchasePayloadReady(payload)) { return; } if (entry.done) { return; // a nested pass may have fired this entry during resolution } entry.done = true; clearTimeout(entry.timeoutId); eagerPendingRules.delete(entry.rule._uniqueId); eagerFiredRules.add(entry.rule._uniqueId); try { runAction(entry.rule.action, entry.rule.fields, entry.event, entry.rule.trigger); } catch (error) { recordTrackingError(error); } } let attemptingEagerRules = false; function attemptAllEagerRules() { if (attemptingEagerRules) { return; } attemptingEagerRules = true; try { eagerPendingRules.forEach(entry => attemptEagerRule(entry)); } finally { attemptingEagerRules = false; } } function scheduleEagerRule(rule, event) { if (eagerFiredRules.has(rule._uniqueId) || eagerPendingRules.has(rule._uniqueId)) { return; } const timeoutMs = eagerPurchaseTriggerOptions.timeoutMs || rule.trigger.delayMs; const entry = {rule, event, done: false, timeoutId: null}; // setTimeout is safe here: only pixel-sandbox documents reach this path, no merchant code runs there entry.timeoutId = setTimeout(() => { if (entry.done) { return; } entry.done = true; eagerPendingRules.delete(rule._uniqueId); let payload = null; try { payload = preparePayloadFromFields(rule.fields, event); } catch (e) { // recorded below } if (payload && isPurchasePayloadReady(payload)) { // covers pushes the hook could not observe eagerFiredRules.add(rule._uniqueId); try { runAction(rule.action, rule.fields, event, rule.trigger); } catch (error) { recordTrackingError(error); } } else { // never send an empty purchase - skip and make the failure visible recordTrackingError(new TrackingExecutionError( `Eager purchase trigger timed out after ${timeoutMs}ms without usable purchase data - skipping send.`, trackingEntityTypes.TRIGGER, null, rule.trigger.id, script.id, trackingErrorTypes.MISSING_VALUE )); } }, timeoutMs); eagerPendingRules.set(rule._uniqueId, entry); // The purchase data may already be present when the rule arms. attemptEagerRule(entry); } function hookDataLayerForEagerRules() { const wrapDataLayer = (arr) => { if (!arr || arr.__zoovuEagerHooked) { return arr; } const originalPush = arr.push.bind(arr); arr.push = function () { const result = originalPush.apply(undefined, arguments); attemptAllEagerRules(); return result; }; arr.__zoovuEagerHooked = true; return arr; }; let currentDataLayer = wrapDataLayer(window.dataLayer); try { // catch late (re)assignment of window.dataLayer Object.defineProperty(window, 'dataLayer', { configurable: true, get() { return currentDataLayer; }, set(value) { // `dataLayer = dataLayer || []` style self-assignment must be a no-op if (value === currentDataLayer) { return; } currentDataLayer = wrapDataLayer(value); attemptAllEagerRules(); }, }); } catch (e) { logDebug('Eager purchase trigger: could not intercept dataLayer reassignment', e); } } /** * @public PUBLIC API — externally consumed (enabled per script via advanced mode). * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. * * Opt-in for Shopify web-pixel sandboxes, where the rAF-based delayMs wait never * completes in Blink (hidden 0x0 iframe -> no frames). Fires delayed PAGE_LOAD * PURCHASED rules as soon as the payload is resolvable (checked on every * dataLayer.push), with delayMs/options.timeoutMs as the upper bound, and never * sends an empty-products purchase. No-op outside the sandbox context. */ function enableShopifyDataLayerPurchaseTrigger(options) { if (!isPixelSandboxContext()) { logDebug('Eager purchase trigger: not a pixel-sandbox context, keeping default behavior'); return false; } if (eagerPurchaseTriggerOptions !== null) { return true; } eagerPurchaseTriggerOptions = {timeoutMs: (options && options.timeoutMs) || null}; hookDataLayerForEagerRules(); return true; } function evaluateSingleRule(rule, event) { // Assign unique ID to rule if it doesn't have one if (!rule._uniqueId) { rule._uniqueId = ruleCounter++; } if (evaluateTriggerConditions(rule.trigger, event)) { // Set default delay for INPUT triggers if delayMs is undefined const delay = rule.trigger.type === triggerTypes.INPUT && rule.trigger.delayMs === undefined ? 1500 : rule.trigger.delayMs; if (delay) { if (isEagerEligibleRule(rule)) { scheduleEagerRule(rule, event); return; } // Get or create debounced function for this rule using unique rule ID const ruleKey = rule._uniqueId; if (!debouncedRuleActions.has(ruleKey)) { const useTrailing = rule.trigger.type === triggerTypes.INPUT; const debounced = debounce(function (event) { try { runAction(rule.action, rule.fields, event, rule.trigger); } catch (error) { recordTrackingError(error); } }, delay, useTrailing); debouncedRuleActions.set(ruleKey, debounced); } debouncedRuleActions.get(ruleKey)(event); } else { runAction(rule.action, rule.fields, event, rule.trigger); } } } function evaluateRules(rules, event) { try { rules.forEach(rule => { evaluateSingleRule(rule, event) }); } catch (error) { recordTrackingError(error); } } function observeAndReactOnPageChange(callback) { let oldHref; if (oldHref === undefined) { callback(); oldHref = document.location.href; } const body = document.querySelector('body'); const observer = new MutationObserver(() => { if (oldHref !== document.location.href) { oldHref = document.location.href; callback(); } }); observer.observe(body, {childList: true, subtree: true}); } function executeAdvancedCode() { if (typeof advancedCode === 'function') { advancedCode(); } } function jsonParser(key) { const rawValue = localStorage.getItem(key) if (rawValue === null) { return [] } const foundValue = JSON.parse(rawValue) if (!Array.isArray(foundValue)) { throw Error('Invalid value in localStorage for key ' + key) } return foundValue } function createTrackingSuccessfulExecutionRecord(entityType, entityId) { const executionTime = Date.now(); return { organization: getOrganization(), domainId: getDomainId(), eventType: trackingEventTypes.TRACKING_SUCCESSFUL_EXECUTION, trackingEntityType: entityType, trackingEntityId: entityId, executionTime } } function createTrackingFailedExecutionRecord(entityType, entityId, triggerId, scriptId, errorType, message) { const executionTime = Date.now(); return { organization: getOrganization(), domainId: getDomainId(), eventType: trackingEventTypes.TRACKING_EXECUTION_FAILURE, trackingEntityType: entityType, trackingEntityId: entityId, executionTime, triggerId: triggerId || null, scriptId: scriptId || null, error: { type: errorType, message } } } function addSuccessfulExecutions(trigger, fields) { const successfulExecutions = jsonParser(trackingFieldName) // add script trigger successfulExecutions.push(createTrackingSuccessfulExecutionRecord(trackingEntityTypes.TRIGGER, trigger.id)) // add script function variables fields.forEach(field => { const foundVariableInField = variables.find(variable => variable.id === field.variableId) if (foundVariableInField.type === variableTypes.FUNCTION && foundVariableInField.scope === trackingVariableScopes.LOCAL) { successfulExecutions.push(createTrackingSuccessfulExecutionRecord(trackingEntityTypes.VARIABLE, foundVariableInField.id)) } }) // add trigger function variables trigger.groups.forEach(groups => { groups.rows.forEach(row => { const foundVariable = variables.find(variable => variable.id === row.variableId) if (foundVariable.type === variableTypes.FUNCTION && foundVariable.scope === trackingVariableScopes.LOCAL) { successfulExecutions.push(createTrackingSuccessfulExecutionRecord(trackingEntityTypes.VARIABLE, foundVariable.id)) } }) }) localStorage.setItem(trackingFieldName, JSON.stringify(successfulExecutions)); } // ------------------------- events ------------------------- async function sendEventImmediately(body) { if (trackingEnabled) { await fetch(`${url}/fact`, { method: 'POST', mode: 'cors', cache: 'no-cache', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', }, redirect: 'follow', referrerPolicy: 'no-referrer', keepalive: true, body: JSON.stringify(body), }); } else { logDebug("Tracking disabled - no permission to track"); } } function getSignalReadyPromise() { // Return existing promise if already polling if (signalReadyPromise) { return signalReadyPromise; } // Signal already ready, return resolved promise if (signalReady) { return Promise.resolve(); } // Create new polling promise signalReadyPromise = new Promise((resolve) => { signalCheckAttempts = 0; const checkInterval = setInterval(() => { signalCheckAttempts++; if (signalReady) { clearInterval(checkInterval); signalReadyPromise = null; resolve(); } else if (signalCheckAttempts >= MAX_SIGNAL_CHECK_ATTEMPTS) { clearInterval(checkInterval); signalReadyPromise = null; logDebug('Signal connection timeout - proceeding without signal'); resolve(); } }, SIGNAL_CHECK_INTERVAL_MS); }); return signalReadyPromise; } async function sendEvent(body) { if (trackingEnabled) { // Check if we should wait for signal connection // Only wait if launcher exists (hasLauncher) and signal is not ready yet if (hasLauncher && !signalReady && signalCheckAttempts < MAX_SIGNAL_CHECK_ATTEMPTS) { // Queue the event - don't block, just queue it eventQueue.push(body); // Trigger polling in background (fire and forget) getSignalReadyPromise().then(() => { // Flush happens in setSignalReady, but handle timeout case if (!signalReady && eventQueue.length > 0) { const eventsToFlush = [...eventQueue]; eventQueue = []; eventsToFlush.forEach(queuedEvent => { sendEventImmediately(queuedEvent); }); } }); } else { // Send immediately if no launcher or signal already ready await sendEventImmediately(body); } } else { logDebug("Tracking disabled - no permission to track"); } } async function sendSSTRPurchaseEvent(body) { if (trackingEnabled) { const purchaseUrl = 'https://ev-co.zoovu.com/v1/CollectSalesEvent'; const response = await fetch(purchaseUrl, { method: 'POST', mode: 'cors', cache: 'no-cache', headers: { 'Content-Type': 'application/json', }, redirect: 'follow', referrerPolicy: 'no-referrer', keepalive: true, body: JSON.stringify(body), }); if (!response.ok) { // fetch resolves on HTTP errors - reject so the caller can forget the dedup key throw new Error(`CollectSalesEvent failed: ${response.status}`); } } else { logDebug("Tracking disabled - no permission to track"); } } async function sendTrackingSuccessfulExecution(body) { if (trackingEnabled) { await fetch(`${url}/tracking/executions`, { method: 'POST', mode: 'cors', cache: 'no-cache', headers: { 'Content-Type': 'application/json', }, redirect: 'follow', referrerPolicy: 'no-referrer', keepalive: true, body: JSON.stringify(body), }); } else { logDebug("Tracking disabled - no permission to track"); } } function sendFactEventBase(eventType, eventSpecificBody) { if (Number.isNaN(eventSpecificBody.price)) { throw new Error(`Invalid price for ${eventType} event: price resolved to NaN.`); } const body = { ...getBaseEventBody(eventType), // event label + custom per-event fields ...eventSpecificBody, }; sendEvent(body); }; /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function sendPageVisitedEvent({locale, category, eventLabel = 'Page visit'}) { const body = { locale, eventLabel, // optional category, }; if (ensureRequiredFieldsPresent({locale}, eventTypes.PAGE_VISITED)) { sendFactEventBase(eventTypes.PAGE_VISITED, body); } }; /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function sendPdpVisitedEvent({ locale, sku, quantity = 1, currencyCode, eventLabel = "Product details page visit", price, category }) { const body = { locale, sku, eventLabel, // optional category, price, currencyCode, quantity, }; if (ensureRequiredFieldsPresent({locale, sku}, eventTypes.PDP_VISITED)) { sendFactEventBase(eventTypes.PDP_VISITED, body); } }; /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function sendAddToCartEvent({ locale, sku, quantity = 1, currencyCode, eventLabel = "Add to cart", price, category }) { const body = { locale, sku, eventLabel, // optional category, price, currencyCode, quantity, }; if (ensureRequiredFieldsPresent({locale, sku}, eventTypes.ADD_TO_CART)) { sendFactEventBase(eventTypes.ADD_TO_CART, body); } }; /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function sendRemoveFromCartEvent({ locale, sku, quantity = 1, currencyCode, eventLabel = "Remove from cart", price, category }) { const body = { locale, sku, eventLabel, // optional category, price, currencyCode, quantity, }; if (ensureRequiredFieldsPresent({locale, sku}, eventTypes.REMOVE_FROM_CART)) { sendFactEventBase(eventTypes.REMOVE_FROM_CART, body); } }; /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function sendUpdateCartEvent({ locale, sku, quantity = 1, currencyCode, eventLabel = "Update cart", price, category }) { const body = { locale, sku, eventLabel, // optional category, price, currencyCode, quantity, }; if (ensureRequiredFieldsPresent({locale, sku}, eventTypes.UPDATE_CART)) { sendFactEventBase(eventTypes.UPDATE_CART, body); } }; /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function sendDeclineTrackingEvent({ locale, eventLabel = "No permission to track", category }) { const body = { locale, eventLabel, // optional category }; if (ensureRequiredFieldsPresent({locale}, eventTypes.DECLINE_TRACKING)) { sendFactEventBase(eventTypes.DECLINE_TRACKING, body); disableTracking(); } }; /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function sendSearchEvent({ locale, eventLabel = "Search phrase typed", category }) { const body = { locale, eventLabel, // optional category, }; if (ensureRequiredFieldsPresent({locale}, eventTypes.SEARCH)) { sendFactEventBase(eventTypes.SEARCH, body); } }; /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function sendSearchResultEvent({ locale, isEmpty = false, eventLabel = "Search results", skus, category }) { const body = { locale, isEmpty, skus, eventLabel, // optional category }; if (ensureRequiredFieldsPresent({locale, skus}, eventTypes.SEARCH_RESULT)) { sendFactEventBase(eventTypes.SEARCH_RESULT, body); } }; /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function sendClickoutEvent({ locale, targetUrl, namedReferral, eventLabel = "Clickout", category }) { const body = { locale, targetUrl, namedReferral, eventLabel, // optional category }; if (ensureRequiredFieldsPresent({locale, targetUrl, namedReferral}, eventTypes.CLICKOUT)) { sendFactEventBase(eventTypes.CLICKOUT, body); } }; // ------------------------- purchase dedup guard (tab-scoped) ------------------------- // Suppresses same-tab re-sends of an identical purchase; fails open on storage errors. const PURCHASE_DEDUP_WINDOW_MS = 60 * 60 * 1000; // short window for id-less purchases: their identity is only inferred const PURCHASE_DEDUP_NOID_WINDOW_MS = 10 * 60 * 1000; const PURCHASE_DEDUP_MAX_KEYS = 20; const purchaseDedupStorageKey = `${domainId}_zvSentPurchases`; let purchaseDedupGuardEnabled = true; /** * @public PUBLIC API — externally consumed. * Escape hatch for integrations that intentionally re-send purchase events. */ function disablePurchaseDedupGuard() { purchaseDedupGuardEnabled = false; } function purchaseDedupHash(str) { let h = 5381; for (let i = 0; i < str.length; i++) { h = ((h << 5) + h + str.charCodeAt(i)) >>> 0; } return h.toString(36); } // returns null when the purchase carries no provable identity - the guard is skipped function buildPurchaseDedupKey(transactionId, products, currencyCode) { const items = (products || []) .filter(Boolean) .map(p => { const raw = getPurchaseProductPrice(p); const num = Number(raw); // unparseable prices stay verbatim - collapsing them all to one value would merge different carts const price = isFinite(num) ? num.toFixed(2) : String(raw); return `${p.sku}:${Number(p.quantity) || 0}:${price}`; }) .sort() // cart order must not change the key .join(','); if (transactionId) { return `tid|${transactionId}|${purchaseDedupHash(`${currencyCode || ''}|${items}`)}`; } if (isPixelSandboxContext()) { // the sandbox pathname is per-shop, not per-order - identity is not provable there return null; } // no transaction id: the confirmation-page pathname is the order-identity proxy const pathname = (typeof window !== 'undefined' && window.location) ? window.location.pathname : ''; return `noid|${purchaseDedupHash(`${pathname}|${currencyCode || ''}|${items}`)}`; } function readSentPurchaseMap() { let map = null; try { map = JSON.parse(sessionStorage.getItem(purchaseDedupStorageKey)); } catch (e) { // corrupted value - replaced on next write } return (map && typeof map === 'object') ? map : {}; } function isDuplicatePurchase(dedupKey) { if (!purchaseDedupGuardEnabled) { return false; } try { const map = readSentPurchaseMap(); const sentAt = map[dedupKey]; const windowMs = dedupKey.startsWith('noid|') ? PURCHASE_DEDUP_NOID_WINDOW_MS : PURCHASE_DEDUP_WINDOW_MS; return typeof sentAt === 'number' && (Date.now() - sentAt) < windowMs; } catch (e) { return false; // fail open } } function rememberSentPurchase(dedupKey) { try { const map = readSentPurchaseMap(); map[dedupKey] = Date.now(); const keys = Object.keys(map); if (keys.length > PURCHASE_DEDUP_MAX_KEYS) { keys.sort((a, b) => map[a] - map[b]); for (let i = 0; i < keys.length - PURCHASE_DEDUP_MAX_KEYS; i++) { delete map[keys[i]]; } } sessionStorage.setItem(purchaseDedupStorageKey, JSON.stringify(map)); } catch (e) { // fail open } } function forgetSentPurchase(dedupKey) { try { const map = readSentPurchaseMap(); delete map[dedupKey]; sessionStorage.setItem(purchaseDedupStorageKey, JSON.stringify(map)); } catch (e) { // fails closed: the key stays remembered and suppression persists for the window } } // ------------------------- purchase payload validation ------------------------- // Caught here or nowhere: SSTR 200s any product shape. An empty list blocks the send (worth // nothing); a missing price only forfeits that line (SSTR reads it as 0) so it reports and sends. function getPurchaseProductPrice(product) { if (!product) { return undefined; } return product.price != null ? product.price : product.pricePerUnit; } function purchaseProductsError(message) { const productsField = eventFields.find(field => field.fieldName === 'products'); return new TrackingExecutionError( message, trackingEntityTypes.VARIABLE, productsField ? productsField.variableId : null, null, script.id, trackingErrorTypes.MISSING_VALUE ); } function ensurePurchaseProductsValid(products) { if (!Array.isArray(products) || products.length === 0) { const error = purchaseProductsError(`Invalid products for ${eventTypes.PURCHASED}: products must be a non-empty array.`); eventFields = []; // as ensureRequiredFieldsPresent throw error; } const unpriced = []; for (let i = 0; i < products.length; i++) { const price = getPurchaseProductPrice(products[i]); if (price === null || price === undefined || price === '') { unpriced.push(`products[${i}] (sku: ${(products[i] && products[i].sku) || 'unknown'})`); } } if (unpriced.length > 0) { recordTrackingError(purchaseProductsError( `Missing price for ${eventTypes.PURCHASED}, counted as 0: ${unpriced.join(', ')} - set price or pricePerUnit.` )); } return true; } const KEPT_SEGMENTS = ['checkouts', 'checkout', 'orders', 'order-received', 'cart', 'thank_you', 'thank-you', 'information', 'payment', 'shipping', 'success', 'products', 'collections', 'account', 'search', 'sandbox', 'modern', 'custom']; function redactPathSegment(segment) { if (segment.indexOf('web-pixels') === 0) { return 'web-pixels'; } if (segment === '' || KEPT_SEGMENTS.indexOf(segment) !== -1 || /^[a-z]{2}-[a-z]{2}$/.test(segment)) { return segment; } return '_'; } function redactPathname(pathname) { const segments = pathname.split('/'); for (let i = 0; i < segments.length; i++) { segments[i] = redactPathSegment(segments[i]); } return segments.join('/'); } function getRedactedDocumentLocation() { if (typeof window === 'undefined' || !window.location) { return null; } return window.location.origin + redactPathname(window.location.pathname); } /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function sendPurchaseEvent({ currencyCode, transactionId, products }) { const sstrBody = { transactionId, products, currency: currencyCode, browserTimestamp: Date.now(), env: getEnvironment(), accountId: getAccountId(), clientId: getCID(), documentLocation: getRedactedDocumentLocation(), }; if (ensureRequiredFieldsPresent({products, currencyCode,}, eventTypes.PURCHASED) && ensurePurchaseProductsValid(products)) { const dedupKey = buildPurchaseDedupKey(transactionId, products, currencyCode); if (dedupKey && isDuplicatePurchase(dedupKey)) { logDebug(`Duplicate purchase suppressed (same tab): ${dedupKey}`); return; } if (dedupKey && trackingEnabled) { // remembered before the fetch (the duplicate can fire before the response), and // only when a send can actually happen - a purchase swallowed by disabled // tracking must stay re-sendable after consent rememberSentPurchase(dedupKey); } // a failed send must not block the refresh-retry sendSSTRPurchaseEvent(sstrBody).catch(() => { if (dedupKey) { forgetSentPurchase(dedupKey); } }); } }; /** * @public PUBLIC API — externally consumed. * Renaming, changing the signature, or changing the return shape * will break callers. Do not modify without a deprecation plan. */ function sendLeadGenEvent({ locale, currencyCode, products, leadId, leadType, category, eventLabel = "Lead gen sent" }) { const body = { locale, eventLabel, // optional currencyCode, products, leadId, leadType, category }; if (ensureRequiredFieldsPresent({locale}, eventTypes.LEAD_GEN)) { sendFactEventBase(eventTypes.LEAD_GEN, body); } }; function sendSuccessfulExecutionEvent(forceSend) { const successfulExecutions = jsonParser(trackingFieldName) if (successfulExecutions.length === MAX_RECORDS || (forceSend && successfulExecutions.length > 0)) { sendTrackingSuccessfulExecution(successfulExecutions) localStorage.setItem(trackingFieldName, JSON.stringify([])) } } function exposeTrackingApi() { if (typeof window !== 'undefined') { if (typeof window.ZoovuTrackingManager === 'undefined') { window.ZoovuTrackingManager = {}; } // Public API with stable contract - these signatures won't change const sendPageVisitedEventPublic = (event) => sendPageVisitedEvent(event); const sendPdpVisitedEventPublic = (event) => sendPdpVisitedEvent(event); const sendAddToCartEventPublic = (event) => sendAddToCartEvent(event); const sendRemoveFromCartEventPublic = (event) => sendRemoveFromCartEvent(event); const sendUpdateCartEventPublic = (event) => sendUpdateCartEvent(event); const sendDeclineTrackingEventPublic = (event) => sendDeclineTrackingEvent(event); const sendSearchEventPublic = (event) => sendSearchEvent(event); const sendSearchResultEventPublic = (event) => sendSearchResultEvent(event); const sendClickoutEventPublic = (event) => sendClickoutEvent(event); const sendPurchaseEventPublic = (event) => sendPurchaseEvent(event); const sendLeadGenEventPublic = (event) => sendLeadGenEvent(event); window.ZoovuTrackingManager = { // Public API - stable contract sendPageVisitedEvent: sendPageVisitedEventPublic, sendPdpVisitedEvent: sendPdpVisitedEventPublic, sendAddToCartEvent: sendAddToCartEventPublic, sendRemoveFromCartEvent: sendRemoveFromCartEventPublic, sendUpdateCartEvent: sendUpdateCartEventPublic, sendDeclineTrackingEvent: sendDeclineTrackingEventPublic, sendSearchEvent: sendSearchEventPublic, sendSearchResultEvent: sendSearchResultEventPublic, sendClickoutEvent: sendClickoutEventPublic, sendPurchaseEvent: sendPurchaseEventPublic, sendLeadGenEvent: sendLeadGenEventPublic, // Utility methods disableTracking, enableTracking, getNumericPriceFromString, enableShopifyDataLayerPurchaseTrigger, disablePurchaseDedupGuard, setSignalReady, }; } } // ------------------------- core ------------------------- function reactOnLoad() { const pageLoadRules = script.rows.filter(row => row.trigger.type === triggerTypes.PAGE_LOAD); const handler = (event) => { observeAndReactOnPageChange(() => { evaluateRules(pageLoadRules, event); sendSuccessfulExecutionEvent(); }); }; // Check if page already loaded if (document.readyState === "complete") { handler(new Event('load')); } else { window.addEventListener("load", handler, {capture: true}); } } function reactOnClick() { const clickRules = script.rows.filter(row => row.trigger.type === triggerTypes.CLICK); // click via mouse document.querySelector('body').addEventListener('mousedown', (event) => { evaluateRules(clickRules, event); sendSuccessfulExecutionEvent() }, {capture: true}); // click via enter button document.querySelector('body').addEventListener('keydown', (event) => { if (event.code === "Enter" || event.code === "NumpadEnter") { evaluateRules(clickRules, event); sendSuccessfulExecutionEvent() } }, {capture: true}); } function reactOnInput() { const inputRules = script.rows.filter(row => row.trigger.type === triggerTypes.INPUT); document.querySelector('body').addEventListener('input', function (event) { evaluateRules(inputRules, event); sendSuccessfulExecutionEvent() }, {capture: true}); } function reactOnMouseLeave() { document.addEventListener("mouseleave", function (event) { if (event.clientY <= 0 || event.clientX <= 0 || (event.clientX >= window.innerWidth || event.clientY >= window.innerHeight)) { sendSuccessfulExecutionEvent(true) } }); } // Expose API immediately - doesn't require DOM or event listeners exposeTrackingApi(); function track() { try { executeAdvancedCode(); reactOnLoad(); reactOnClick(); reactOnInput(); reactOnMouseLeave(); } catch (error) { logDebug("Tracking disabled", error); } } // RUN SCRIPT AFTER PAGE LOAD if (document.readyState === "loading" || document.readyState === "interactive") { // Loading hasn't finished yet document.addEventListener("readystatechange", (event) => { if (event.target.readyState === "complete") { track(); } }); } else { track(); } })();