(function(){ const zoovuId = 'TmiMhmkMW01FzZLo/lyMVAqIvewAuLTH'; const domainId = '871670b9-4d6d-42d8-a3bc-64d674dfc935'; const variables = [{"id":"cfaf0593-518d-4605-b970-902bc97d73b2","name":"ATC button on PDP was clicked","type":"FUNCTION","valueType":"BOOLEAN","function":function(event){const result =Boolean(event.target.closest(`[data-kjs-click="addToCart"]`)); return result; /* */},"scope":"LOCAL"},{"id":"a46728b9-ea28-4409-93fd-bfdd283446f4","name":"Category was clicked on search popup","type":"FUNCTION","valueType":"BOOLEAN","function":function(event){const isSuggestion = Boolean(event.target.closest(".fc-suggestion")); const isHyperlink = event.target.href?.includes("http"); if (isSuggestion && isHyperlink) { return true; } else { return false; } /* */},"scope":"LOCAL"},{"id":"3b881070-bf08-4869-be93-2b5d94a99c5b","name":"Clickout, category","type":"CONSTANT","valueType":"TEXT","value":"Clickout, category","scope":"GLOBAL"},{"id":"d2d8bad9-06f7-4f78-9184-8c38a9a469a1","name":"Clickout, view all products","type":"CONSTANT","valueType":"TEXT","value":"Clickout, view all products","scope":"GLOBAL"},{"id":"264a80b7-317c-4daf-92b4-0fe31430d2bc","name":"Currency for purchase event","type":"FUNCTION","valueType":"TEXT","function":function(event){return window?.digitalData?.transaction?.total?.currency ?? ''; /* */},"scope":"LOCAL"},{"id":"a26978c9-bfb8-4a3a-8080-39749597da3f","name":"Currency from PDP","type":"FUNCTION","valueType":"TEXT","function":function(event){const element = document.querySelector(`[id*='structureddata_product']`); const data = JSON.parse(element.textContent); return data.offers?.priceCurrency; /* */},"scope":"LOCAL"},{"id":"cca69906-340a-48ef-ab5e-b86af17bb882","name":"Currency (global)","type":"FUNCTION","valueType":"TEXT","function":function(event){return window?.digitalData?.cart?.price?.currency ?? 'AUD'; /* */},"scope":"LOCAL"},{"id":"ad6080ea-719b-4732-952f-ef4f1ce45e02","name":"Event target","type":"FUNCTION","valueType":"EVENT","function":function(event){return event.target /* */},"scope":"GLOBAL"},{"id":"ab1ff227-3439-4ab6-a51b-d4c77177ea7a","name":"Get closest hyperlink","type":"FUNCTION","valueType":"TEXT","function":function(event){return event.target.closest("a").href; /* */},"scope":"LOCAL"},{"id":"9ff883ed-a851-4164-ab1f-ddd0b5844a14","name":"Get label name for SEARCH_RESULT on SERP","type":"FUNCTION","valueType":"TEXT","function":function(event){const product = Boolean(document.querySelector(`[data-kjs-view="entries"] .fg-products-product`)); if (!product) { return "Empty search result popup"; } else { return "Search results"; } /* */},"scope":"LOCAL"},{"id":"ff5fee60-a4fc-4d9e-8174-d38e8ce875eb","name":"Get label name for search result popup","type":"FUNCTION","valueType":"TEXT","function":function(event){const isSuggestionsEmpty = !(document.querySelector(`[data-kjs-view="suggestions"] .fc-products-product`)); if (isSuggestionsEmpty) { return "Empty search result popup"; } else { return "Search results"; } /* */},"scope":"LOCAL"},{"id":"6c724337-e704-4c14-92cb-4dff44c858d5","name":"Get price for global ATC","type":"FUNCTION","valueType":"DECIMAL","function":function(event){const parentElement = event.target.closest(`[data-kjs-view="main"]`); const priceElement = parentElement.querySelector(`[data-kjs-setting="price.price"]`); return priceElement ? Number(priceElement.value) : null; /* */},"scope":"LOCAL"},{"id":"d49c68a7-5cb8-4c54-8e6b-58ee9572155b","name":"Get quantity for REMOVE_FROM_CART (cart page)","type":"FUNCTION","valueType":"INTEGER","function":function(event){const parentElement = event.target.closest(".cart-item"); const quantityDropdown = parentElement.querySelector("#cart-item-quantity-dropdown"); return quantityDropdown ? Number(quantityDropdown.value) : null; /* */},"scope":"LOCAL"},{"id":"2e141436-b43c-40d6-bbed-64cfdca7f222","name":"Go to PDP - search clickout","type":"CONSTANT","valueType":"TEXT","value":"Go to PDP - search clickout","scope":"GLOBAL"},{"id":"01e746dc-d6e4-48b5-8067-c71164c4da39","name":"Locale","type":"FUNCTION","valueType":"TEXT","function":function(event){function extractLocaleFromCartPage() { const url = document.querySelector("form[method='post']").getAttribute("action"); const params = new URLSearchParams(url.split('?')[1]); const c = params.get('c'); return c; } return window?.isocode || extractLocaleFromCartPage(); /* */},"scope":"LOCAL"},{"id":"6c77635c-e95a-4eca-8086-f6be1b1193e8","name":"Named referral: Search","type":"CONSTANT","valueType":"TEXT","value":"Search","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":"e48f77ab-f3a6-4945-be35-7d84286a736f","name":"Price for REMOVE_FROM_CART (cart page)","type":"FUNCTION","valueType":"DECIMAL","function":function(event){const parentElement = event.target.closest(".cart-item"); const hyperlink = parentElement.querySelector(".cart-item a"); const sku = hyperlink.href.match(/(\d{8})(?=\.html)/)?.[1]; const product = digitalData.cart.items.find(product => product.productInfo.sku === sku); const pricePerUnit = product.price.singlePriceWithTax; return pricePerUnit; /* */},"scope":"LOCAL"},{"id":"8d9d990b-309e-4ec6-8402-06970ad3aebd","name":"Price from PDP","type":"FUNCTION","valueType":"DECIMAL","function":function(event){const priceElement = document.querySelector(`.fg-products-details-stage [data-kjs-setting="price.priceFormatted"]`); const price = priceElement ? getNumericPriceFromString(priceElement.value) : null; return price; /* */},"scope":"LOCAL"},{"id":"f118b4c3-58f6-494c-b0fe-14a123016b22","name":"Products for purchase event","type":"FUNCTION","valueType":"LIST","function":function(event){return (window?.digitalData?.transaction?.items ?? []).map((item) => ({ sku: item?.productInfo?.sku, name: item?.productInfo?.productName, pricePerUnit: item?.price?.priceWithTax, quantity: item?.quantity ?? 1 })); /* */},"scope":"LOCAL"},{"id":"f92f2dc1-32b5-4841-9634-48c3200f13ed","name":"Product was clicked on search popup","type":"FUNCTION","valueType":"BOOLEAN","function":function(event){const isProduct = event.target.closest(".fc-products-product"); const isHyperlink = event.target.closest("a"); if (isProduct && isHyperlink) { return true; } else { return false; } /* */},"scope":"LOCAL"},{"id":"155be70d-25f8-44a6-9bb2-41d0adb49b8f","name":"Product was clicked out on SERP","type":"FUNCTION","valueType":"BOOLEAN","function":function(event){const isProductHyperlink = Boolean(event.target.closest(".fg-products-product a")); const isSERP = window.location.href.includes('query'); if (isProductHyperlink && isSERP) { return true; } else { return false; } /* */},"scope":"LOCAL"},{"id":"e221e0ac-091c-410d-8f38-2f977c2b30b1","name":"Remove item from cart button was clicked (cart page)","type":"FUNCTION","valueType":"BOOLEAN","function":function(event){return Boolean(event.target.closest("#cart-item-remove-button")); /* */},"scope":"LOCAL"},{"id":"bbd6e1ba-ae56-4af5-a577-40901cbc91f5","name":"Search popup is empty","type":"FUNCTION","valueType":"BOOLEAN","function":function(event){const result = !(document.querySelector(`[data-kjs-view="suggestions"] .fc-products-product`)); return result; /* */},"scope":"LOCAL"},{"id":"2897729e-ab45-4405-970a-708a359d3c32","name":"Search was used","type":"FUNCTION","valueType":"BOOLEAN","function":function(event){const result = Boolean(event.target.closest(".fc-form-search input[name=query]")); return result; /* */},"scope":"LOCAL"},{"id":"770c5d0e-3a1a-444d-8755-21dad35a9864","name":"SERP is empty","type":"FUNCTION","valueType":"BOOLEAN","function":function(event){const product = Boolean(document.querySelector(`[data-kjs-view="entries"] .fg-products-product`)); if (!product) { return true; } else { return false; } /* */},"scope":"LOCAL"},{"id":"73228592-4716-4934-9595-28115916f923","name":"SKU for REMOVE_FROM_CART (cart page)","type":"FUNCTION","valueType":"TEXT","function":function(event){const parentElement = event.target.closest(".cart-item"); const hyperlink = parentElement.querySelector(".cart-item a"); const sku = hyperlink.href.match(/(\d{8})(?=\.html)/)?.[1]; return sku; /* */},"scope":"LOCAL"},{"id":"e424aeb0-e1da-43d2-ac6d-de6286e10e4c","name":"SKU from global ATC","type":"FUNCTION","valueType":"TEXT","function":function(event){ const parentElement = event.target.closest( '[data-kjs-view="button"], .fg-products-details-stage' ); const partNumberInput = parentElement.querySelector('[data-kjs-setting="partnumber"]'); const sku = partNumberInput.getAttribute("data-kjs-value") || partNumberInput?.value; return sku ? sku.replace(/[.\-]/g, '') : sku; /* */},"scope":"LOCAL"},{"id":"e6ce103d-29ff-4815-bc16-f4311079eeb1","name":"SKU from PDP","type":"FUNCTION","valueType":"TEXT","function":function(event){var sku = document.querySelector("[data-kjs-setting='partnumber']")?.value; return sku ? sku.replace(/[.\-]/g, '') : sku; /* */},"scope":"LOCAL"},{"id":"32deb8ef-b556-482c-977a-f4f098909548","name":"SKU list from search popup","type":"FUNCTION","valueType":"LIST","function":function(event){const skuList = []; const products = document.querySelectorAll(`[data-kjs-view="suggestions"] .fc-products-product`); Array.from(products).forEach(product => { const hyperlink = product.querySelector("a").href; const sku = hyperlink.match(/(\d{8})(?=\.html)/)?.[1]; skuList.push(sku); }) return skuList; /* */},"scope":"LOCAL"},{"id":"aec29bd9-dc75-423f-9b8a-72adcb26b6a8","name":"SKU list from SERP","type":"FUNCTION","valueType":"LIST","function":function(event){const skuList = []; const products = document.querySelectorAll(`[data-kjs-view="entries"] .fg-products-product`); Array.from(products).forEach(product => { const sku = product.getAttribute("data-partnumber"); skuList.push(sku); }) return skuList; /* */},"scope":"LOCAL"},{"id":"0de1cb2b-6703-44c5-a466-c84a1b4d8b2d","name":"Slash","type":"CONSTANT","valueType":"TEXT","value":"/","scope":"LOCAL"},{"id":"7982b4c0-8b2e-4525-b3eb-0e64caf9b38a","name":"TransactionID for purchase event","type":"FUNCTION","valueType":"TEXT","function":function(event){ var isPurchasePayload = function(v){ return v && typeof v==='object' && !Array.isArray(v) && typeof v.currency==='string' && typeof v.transaction_id==='string'; }; var purchaseEvent = (window.dataLayer||[]).map(function(entry){ if(!entry||typeof entry!=='object') return null; for (var k in entry){ if(isPurchasePayload(entry[k])) return entry[k]; } return null; }).find(Boolean); return purchaseEvent ? purchaseEvent.transaction_id : undefined; /* */},"scope":"LOCAL"},{"id":"2c0aab7e-836c-4c39-9f0c-9ee47f9aa839","name":"User is on a SERP page","type":"FUNCTION","valueType":"BOOLEAN","function":function(event){const serpElement = document.querySelector(".fg-search-result"); if (serpElement) { return true; } else { return false; } /* */},"scope":"LOCAL"},{"id":"24f6dffc-ba3f-4c8f-ba70-e3d280b5ca99","name":"User is on PDP page","type":"FUNCTION","valueType":"BOOLEAN","function":function(event){return Boolean(document.querySelector("[class*=products-details-page]")); /* */},"scope":"LOCAL"}]; const script = {"id":"1d131873-bfac-4c29-90f3-339c5c26cc0f","rows":[{"id":"c35b6455-5256-440c-810a-6fd753e611fe","rowType":"EVENT","trigger":{"id":"bf42c647-774a-428e-b268-17c1f7bce40f","name":"On a thank-you page","type":"PAGE_LOAD","groups":[{"id":"62c0ebd8-d526-4857-9baf-f7c16edaa29a","rows":[{"valueType":"TEXT","id":"1be94861-94c2-4ab5-9dd1-64276b90e735","variableId":"75cf8fd1-fafe-44be-9806-23ff3017c9c1","value":"order","operator":"CONTAINS"},{"valueType":"TEXT","id":"772c08e9-ea7c-4177-8d5e-a804b55cf1f2","variableId":"75cf8fd1-fafe-44be-9806-23ff3017c9c1","value":"confirmation","operator":"CONTAINS"}]}]},"action":{"type":"PURCHASED"},"fields":[{"id":"8b18dbec-0f99-42c7-8bfe-72fd13839ea8","variableId":"264a80b7-317c-4daf-92b4-0fe31430d2bc","fieldName":"currencyCode"},{"id":"3d3f3e3b-a2ae-43dd-8980-15c151ea6eb3","variableId":"f118b4c3-58f6-494c-b0fe-14a123016b22","fieldName":"products"},{"id":"b29848c8-e705-44d2-b321-bb9adb43eeb6","variableId":"7982b4c0-8b2e-4525-b3eb-0e64caf9b38a","fieldName":"transactionId"}]},{"id":"5283820e-48fa-499f-abe2-c15a8669e30b","rowType":"EVENT","trigger":{"id":"d4ada2f5-54f4-42ce-abbc-02a34a033448","name":"Show all search results button was clicked","type":"CLICK","groups":[{"id":"b5e7746f-dbdd-4a22-93f4-b9522be76c01","rows":[{"valueType":"EVENT","id":"8611d857-7783-4a99-89e8-99d56ea67c23","variableId":"ad6080ea-719b-4732-952f-ef4f1ce45e02","value":"[id*=search-suggestions] [data-kjs-click=\"submit\"]","operator":"MATCHES_CSS_SELECTOR"}]}]},"action":{"type":"CLICKOUT"},"fields":[{"id":"6251d52f-5a4d-4a79-b54b-4809038f223e","variableId":"01e746dc-d6e4-48b5-8067-c71164c4da39","fieldName":"locale"},{"id":"f7dfdba6-d567-4639-8368-1b58d25931ca","variableId":"0de1cb2b-6703-44c5-a466-c84a1b4d8b2d","fieldName":"targetUrl"},{"id":"c661dd92-b724-4e80-b066-16749cc9f5ed","variableId":"6c77635c-e95a-4eca-8086-f6be1b1193e8","fieldName":"namedReferral"},{"id":"1be2ec4e-f864-44fb-a8b2-0a9092f3ee13","variableId":"d2d8bad9-06f7-4f78-9184-8c38a9a469a1","fieldName":"eventLabel"}]},{"id":"8f3993c1-8fe7-46ad-92b8-bb9a79c6d390","rowType":"EVENT","trigger":{"id":"87766896-0d8c-432e-bd60-8baa286745a7","name":"Product was clicked out on SERP","type":"CLICK","groups":[{"id":"4d2ee39f-fede-43eb-b87a-f785c4201e07","rows":[{"valueType":"BOOLEAN","id":"6ca98e20-eafe-486d-a2f7-ee85b58e1a62","variableId":"155be70d-25f8-44a6-9bb2-41d0adb49b8f","value":"true","operator":"EQUALS"}]}]},"action":{"type":"CLICKOUT"},"fields":[{"id":"d42a3f9e-b85c-4e11-ae95-98b946bdf45b","variableId":"01e746dc-d6e4-48b5-8067-c71164c4da39","fieldName":"locale"},{"id":"1eed1e26-802d-4a0e-ad64-444a2ded3369","variableId":"ab1ff227-3439-4ab6-a51b-d4c77177ea7a","fieldName":"targetUrl"},{"id":"3b6cf99d-eac4-4250-9030-e1b479f6e9fa","variableId":"6c77635c-e95a-4eca-8086-f6be1b1193e8","fieldName":"namedReferral"},{"id":"18b9487d-cae4-4700-bd05-1cbe39321f4f","variableId":"2e141436-b43c-40d6-bbed-64cfdca7f222","fieldName":"eventLabel"}]},{"id":"4105a7dc-ea0e-44d4-b0b1-b4b3c6be9ba7","rowType":"EVENT","trigger":{"id":"b845d392-ef97-4434-8321-8666dc7cc15c","name":"Product was clicked out on search popup","type":"CLICK","groups":[{"id":"2799cb80-30e6-455b-9952-035fd21e4994","rows":[{"valueType":"BOOLEAN","id":"5dcd0491-62d8-4807-8420-492a47278f33","variableId":"f92f2dc1-32b5-4841-9634-48c3200f13ed","value":"true","operator":"EQUALS"}]}]},"action":{"type":"CLICKOUT"},"fields":[{"id":"f0e884b4-0f57-43a2-a082-b6083dd82e35","variableId":"01e746dc-d6e4-48b5-8067-c71164c4da39","fieldName":"locale"},{"id":"69f23d98-586d-4888-bb3f-2ae6c05413f0","variableId":"ab1ff227-3439-4ab6-a51b-d4c77177ea7a","fieldName":"targetUrl"},{"id":"ef90cc30-c3eb-4408-be5a-0d7b27278506","variableId":"6c77635c-e95a-4eca-8086-f6be1b1193e8","fieldName":"namedReferral"},{"id":"f6b1d53b-ce10-4c91-a008-e93ef8852780","variableId":"2e141436-b43c-40d6-bbed-64cfdca7f222","fieldName":"eventLabel"}]},{"id":"e3942dee-d2f3-460d-bec7-e383a022e0af","rowType":"EVENT","trigger":{"id":"8cb0e5d2-37b0-48aa-b82d-9dab6bd2336a","name":"Category was clicked on search popup","type":"CLICK","groups":[{"id":"20001c6c-3fd7-4223-b547-24efca0e996a","rows":[{"valueType":"BOOLEAN","id":"10dc6146-f36e-4999-9d9d-bf65c8922a4c","variableId":"a46728b9-ea28-4409-93fd-bfdd283446f4","value":"true","operator":"EQUALS"}]}]},"action":{"type":"CLICKOUT"},"fields":[{"id":"9baa4ddc-064b-4f18-8cfe-b6b4e3415c82","variableId":"01e746dc-d6e4-48b5-8067-c71164c4da39","fieldName":"locale"},{"id":"3983b15e-b182-4e8c-b0ec-dc7de60a34dc","variableId":"ab1ff227-3439-4ab6-a51b-d4c77177ea7a","fieldName":"targetUrl"},{"id":"05ab307e-e0fc-475c-a21c-0e59afd33142","variableId":"6c77635c-e95a-4eca-8086-f6be1b1193e8","fieldName":"namedReferral"},{"id":"4acf471b-3625-4acc-a962-e945936f3da3","variableId":"3b881070-bf08-4869-be93-2b5d94a99c5b","fieldName":"eventLabel"}]},{"id":"13c315b9-f6a7-41ce-a78e-8eb76040fa93","rowType":"EVENT","trigger":{"id":"4bf2ebae-c152-46ea-b9b7-c1fb30634fca","name":"Cart item quantity was changed (cart page)","type":"INPUT","delayMs":300,"groups":[{"id":"7c81aa57-a27a-441e-8310-1ec4fa4dafd5","rows":[{"valueType":"EVENT","id":"ad4f1918-ed36-4dd2-ab55-00034c4bc578","variableId":"ad6080ea-719b-4732-952f-ef4f1ce45e02","value":"cart-item-quantity-dropdown","operator":"MATCHES_ID"}]}]},"action":{"type":"UPDATE_CART"},"fields":[{"id":"efe7c3b6-e410-45fb-b8d2-395e64dfeccf","variableId":"01e746dc-d6e4-48b5-8067-c71164c4da39","fieldName":"locale"},{"id":"9f675d81-94c9-4378-b76e-69fe20b78225","variableId":"73228592-4716-4934-9595-28115916f923","fieldName":"sku"},{"id":"49b11a90-14a0-4abc-b092-263c878e3362","variableId":"e48f77ab-f3a6-4945-be35-7d84286a736f","fieldName":"price"},{"id":"e3ad2667-d9b9-4886-9c56-da7c6393beda","variableId":"cca69906-340a-48ef-ab5e-b86af17bb882","fieldName":"currencyCode"},{"id":"f1f9fab2-eb58-4073-9f49-15d1e42ecf00","variableId":"d49c68a7-5cb8-4c54-8e6b-58ee9572155b","fieldName":"quantity"}]},{"id":"8eaafc41-f636-41aa-88e5-c7b76f3b584b","rowType":"EVENT","trigger":{"id":"f06f4b32-3562-4854-b5ba-c20c9d1aa155","name":"Cart item remove button was clicked (cart page)","type":"CLICK","groups":[{"id":"719fe0e7-234b-4c40-ae2a-f4bcdae83b87","rows":[{"valueType":"BOOLEAN","id":"8899d9b9-9b5d-48ad-9816-7592bb4032a0","variableId":"e221e0ac-091c-410d-8f38-2f977c2b30b1","value":"true","operator":"EQUALS"}]}]},"action":{"type":"REMOVE_FROM_CART"},"fields":[{"id":"b352410a-b6f6-4ad0-8364-899f2ff9e88a","variableId":"01e746dc-d6e4-48b5-8067-c71164c4da39","fieldName":"locale"},{"id":"78e95e28-86e2-45d7-b434-957f817392fa","variableId":"73228592-4716-4934-9595-28115916f923","fieldName":"sku"},{"id":"9fd0e163-d045-40bb-a3f4-8d4d3fdf813f","variableId":"e48f77ab-f3a6-4945-be35-7d84286a736f","fieldName":"price"},{"id":"5e694f29-386c-4dc4-9691-74f42484b61c","variableId":"cca69906-340a-48ef-ab5e-b86af17bb882","fieldName":"currencyCode"},{"id":"d91dbbb5-261b-44f9-aa23-a72dba945479","variableId":"d49c68a7-5cb8-4c54-8e6b-58ee9572155b","fieldName":"quantity"}]},{"id":"11317015-b5df-4ee1-bb05-840bb4088c66","rowType":"EVENT","trigger":{"id":"af94c1cc-f832-4918-8588-2da799a9011c","name":"ATC button on PDP was clicked","type":"CLICK","groups":[{"id":"4501c172-37f7-430f-9cd6-65bef4469979","rows":[{"valueType":"BOOLEAN","id":"c41ec2ee-49da-4ed1-b15f-d08f7251e6a0","variableId":"cfaf0593-518d-4605-b970-902bc97d73b2","value":"true","operator":"EQUALS"}]}]},"action":{"type":"ADD_TO_CART"},"fields":[{"id":"c1a1189f-7954-4940-a23a-ca3890f66410","variableId":"01e746dc-d6e4-48b5-8067-c71164c4da39","fieldName":"locale"},{"id":"40bbd448-1691-43bc-8060-6b0bc472a544","variableId":"e424aeb0-e1da-43d2-ac6d-de6286e10e4c","fieldName":"sku"},{"id":"74cc2854-e9cb-4a88-90f9-c6e88d11691e","variableId":"6c724337-e704-4c14-92cb-4dff44c858d5","fieldName":"price"},{"id":"9327d81f-cd0a-4696-a3bd-cae508af2d78","variableId":"cca69906-340a-48ef-ab5e-b86af17bb882","fieldName":"currencyCode"}]},{"id":"03664e56-1500-44e6-8ad6-390aba4e35f1","rowType":"EVENT","trigger":{"id":"4d3cb08a-468c-43bf-8436-7bc04d279e96","name":"Search was used","type":"INPUT","delayMs":1500,"groups":[{"id":"7612e060-9766-494b-9df6-2342c4fee5a5","rows":[{"valueType":"BOOLEAN","id":"3fd3a799-1bdd-4f1b-9031-7784f100c35d","variableId":"2897729e-ab45-4405-970a-708a359d3c32","value":"true","operator":"EQUALS"}]}]},"action":{"type":"SEARCH_RESULT"},"fields":[{"id":"ddca3951-de29-4967-8899-5c00801c4cf9","variableId":"ff5fee60-a4fc-4d9e-8174-d38e8ce875eb","fieldName":"eventLabel"},{"id":"82edd7e3-ac1d-4c26-bb35-f552c0e672f5","variableId":"01e746dc-d6e4-48b5-8067-c71164c4da39","fieldName":"locale"},{"id":"615f64ac-732e-42fc-b0ab-5b05d4cfe24a","variableId":"bbd6e1ba-ae56-4af5-a577-40901cbc91f5","fieldName":"isEmpty"},{"id":"cd5994cf-2bee-4158-8096-8c4dd92438bc","variableId":"32deb8ef-b556-482c-977a-f4f098909548","fieldName":"skus"}]},{"id":"0a025ee4-43c4-4dc1-a2ac-01970d1a4e57","rowType":"EVENT","trigger":{"id":"620248ac-9ab2-4c48-bf9b-198870ae23f1","name":"User is on a SERP page","type":"PAGE_LOAD","delayMs":5000,"groups":[{"id":"d5fd74b3-7390-42ad-abf4-abdedc1630cc","rows":[{"valueType":"BOOLEAN","id":"6a675f8b-dc2e-4935-9877-4b8f6860010b","variableId":"2c0aab7e-836c-4c39-9f0c-9ee47f9aa839","value":"true","operator":"EQUALS"}]}]},"action":{"type":"SEARCH_RESULT"},"fields":[{"id":"64e69e7a-3a5d-4204-b647-a3b6b07cf86b","variableId":"01e746dc-d6e4-48b5-8067-c71164c4da39","fieldName":"locale"},{"id":"5ae1c85d-5f97-4c80-8bf9-a80506ecf89b","variableId":"770c5d0e-3a1a-444d-8755-21dad35a9864","fieldName":"isEmpty"},{"id":"4a435fff-e026-4861-aa5c-a8dfea0adeda","variableId":"aec29bd9-dc75-423f-9b8a-72adcb26b6a8","fieldName":"skus"},{"id":"dc970036-6aea-42c3-abc0-e231cb84b016","variableId":"9ff883ed-a851-4164-ab1f-ddd0b5844a14","fieldName":"eventLabel"}]},{"id":"ddec75c6-3135-4d58-8dc5-fdd108a00549","rowType":"EVENT","trigger":{"id":"4d3cb08a-468c-43bf-8436-7bc04d279e96","name":"Search was used","type":"INPUT","delayMs":1500,"groups":[{"id":"7612e060-9766-494b-9df6-2342c4fee5a5","rows":[{"valueType":"BOOLEAN","id":"3fd3a799-1bdd-4f1b-9031-7784f100c35d","variableId":"2897729e-ab45-4405-970a-708a359d3c32","value":"true","operator":"EQUALS"}]}]},"action":{"type":"SEARCH"},"fields":[{"id":"186a74e9-2bd2-4a07-959f-7a62476005de","variableId":"01e746dc-d6e4-48b5-8067-c71164c4da39","fieldName":"locale"}]},{"id":"7935d919-b535-4070-af2a-a31361308308","rowType":"EVENT","trigger":{"id":"4d7d9738-57f5-4270-942b-75fb720cba5f","name":"Page is PDP","type":"PAGE_LOAD","delayMs":3000,"groups":[{"id":"e94c513c-b626-41bd-a05d-d66d2b8a87cf","rows":[{"valueType":"BOOLEAN","id":"37ca0bd8-4d3f-49cc-8045-f8bb2d5727f8","variableId":"24f6dffc-ba3f-4c8f-ba70-e3d280b5ca99","value":"true","operator":"EQUALS"}]}]},"action":{"type":"PDP_VISITED"},"fields":[{"id":"9b9231fa-b1b3-4021-93f9-c7c26eaaa34e","variableId":"01e746dc-d6e4-48b5-8067-c71164c4da39","fieldName":"locale"},{"id":"edd10ff4-3689-4e58-821c-d3e454ae5d38","variableId":"e6ce103d-29ff-4815-bc16-f4311079eeb1","fieldName":"sku"},{"id":"47a7355f-ee17-47e3-9205-c7f834856064","variableId":"8d9d990b-309e-4ec6-8402-06970ad3aebd","fieldName":"price"},{"id":"537648c0-b0b1-4122-97f2-5d349693b335","variableId":"a26978c9-bfb8-4a3a-8080-39749597da3f","fieldName":"currencyCode"}]},{"id":"575e9c5c-6a44-4289-856d-193f159a8f13","rowType":"EVENT","trigger":{"id":"2eeb4cc8-f23b-4374-8084-f716f970b278","name":"Page is not PDP","type":"PAGE_LOAD","groups":[{"id":"6990fa29-3ef9-4add-a680-1b799a635587","rows":[{"valueType":"BOOLEAN","id":"d3be8655-404a-45e4-8585-c2094c64dd0e","variableId":"24f6dffc-ba3f-4c8f-ba70-e3d280b5ca99","value":"false","operator":"EQUALS"}]}]},"action":{"type":"PAGE_VISITED"},"fields":[{"id":"44905bfc-4843-4534-85d5-6fb045ac5bac","variableId":"01e746dc-d6e4-48b5-8067-c71164c4da39","fieldName":"locale"}]}]}; const advancedCode = function(){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 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; } 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 removeZoovuCidCookie() { const hostname = window.location.hostname; const domain = 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) { console.debug("[Zoovu Tracking] Found malformed CID cookies, removing them"); removeZoovuCidCookie(); } } removeMalformedZoovuCidCookies();}; const url = 'https://queue-propagator.zoovu.com'; const currentEnvironment = 'orca'; const currentAccountId = 4606; const currencies = ['FJD', 'STD', 'MXN', 'SCR', 'LVL', 'CDF', 'BBD', 'UGX', 'HNL', 'MXV', 'ZAR', 'STN', 'CUC', 'BSD', 'SDD', 'SDG', 'IQD', 'CUP', 'GMD', 'TWD', 'RSD', 'UYI', 'MYR', 'FKP', 'XOF', 'UYU', 'CVE', 'OMR', 'SEK', 'KES', 'BTN', 'GNF', 'MZN', 'MZM', 'SVC', 'ARS', 'QAR', 'IRR', 'NLG', 'XPD', 'XPF', 'THB', 'UZS', 'BDT', 'LYD', 'KWD', 'XPT', 'RUB', 'ISK', 'BEF', 'MKD', 'RUR', 'DZD', 'PAB', 'SGD', 'KGS', 'XAD', 'XAF', 'XAG', 'ITL', 'CHF', 'HRK', 'ATS', 'DJF', 'CHE', 'TZS', 'VND', 'XAU', 'ADP', 'AUD', 'CHW', 'KHR', 'IDR', 'XBA', 'KYD', 'XBC', 'XBB', 'SHP', 'BWP', 'CYP', 'XBD', 'TJS', 'AED', 'RWF', 'DKK', 'BGL', 'ZWD', 'BGN', 'MMK', 'ZWG', 'SYP', 'NOK', 'ZWL', 'ZWN', 'YUM', 'LKR', 'ZWR', 'CZK', 'IEP', 'GRD', 'XCD', 'HTG', 'XSU', 'AFA', 'XCG', 'SIT', 'BHD', 'PTE', 'SZL', 'KZT', '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', 'AZM', 'TOP', 'AZN', 'PGK', 'UAH', 'ERN', 'TPE', 'MRO', 'CNY', 'MRU', 'BMD', 'XXX', 'PHP', 'PYG', 'JMD', 'GWP', 'ESP', 'COP', 'USD', 'COU', 'USN', 'ETB', 'VEB', 'VED', 'USS', 'VEF', 'SOS', 'VUV', 'LAK', 'ZMK', 'BND', 'LRD', 'ALL', 'GHC', 'MTL', 'ZMW', 'VES', 'TRL', 'ILS', 'GYD', 'GHS', 'KPW', 'BOB', 'MDL', 'AMD', 'TRY', 'LBP', 'JOD', 'HKD', 'EUR', 'LSL', 'CAD', 'BOV', 'EEK', 'MUR', 'ROL', 'GIP', 'RON', 'NGN', 'CRC', 'PKR', 'ANG', 'SRD', 'SAR', 'LTL', 'TTD', 'MVR', 'SRG', 'INR', 'KRW', 'JPY', 'PLN', 'AOA', 'SBD', 'CSD', 'LUF', 'MWK', 'MGA', 'FIM', 'MGF', 'DEM', '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. const probe = '__zoovu_dt'; for (let i = 2; i <= parts.length; i++) { const candidate = parts.slice(-i).join('.'); document.cookie = `${probe}=1; domain=.${candidate}; path=/`; 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(); } })();