(function () { const CONFIG = { groups : [ { "id" : "4ac7f5b2-b737-4a9d-9d4d-cb69ff24ba6a", "operator" : "AND", "rows" : [ { "type" : "BOOLEAN", "subject" : { "type" : "BOOLEAN", "id" : "d387739d-e612-439a-aa49-493b99545233", "ruleType" : "SIGNAL" }, "operator" : "EQUAL", "value" : true } ] }, { "id" : "d6821cc2-b5d3-45c1-8ee4-1fd6a522ac85", "operator" : "AND", "rows" : [ { "type" : "BOOLEAN", "subject" : { "type" : "BOOLEAN", "id" : "e4ada81c-b1d1-433d-bb8b-cba4980cfd2a", "ruleType" : "SIGNAL" }, "operator" : "EQUAL", "value" : true } ] }, { "id" : "f37ef50a-871d-4aa5-bbe1-f3bbbfaaa880", "operator" : "AND", "rows" : [ { "type" : "BOOLEAN", "subject" : { "type" : "BOOLEAN", "id" : "459fe46d-af66-4060-8fc8-c6548e52ce16", "ruleType" : "SIGNAL" }, "operator" : "EQUAL", "value" : true } ] } ], widget : {"type":"FLOATING_COMPONENT","url":"https://prd-www.moen.com/pages/faucet-finder-quiz?ref=zoovuLauncher","displayText":"Find the right faucet for you","customization":{"color-text":"white","color-background":"#1B365D","color-background-hover":"#1B365D","border-radius":"10px","padding":"12px 30px","right":"100px","bottom":"100px","font-family":"inherit"}}, organization : "n0swUGLZmSC/szEA2SGUJdlZq5tRvmphDvuUsdvubn0=", domainId : "91e9bde9-0d16-45e5-a585-3de33ca5d150", launcherId : "973d2175-3244-4848-9038-f852dee9ffd1", locale : "en_US" }; class Tracking { #organization; #domainId; #launcherId; #locale; #eventBody; #startTime; #cachedRegistrableDomain = null; constructor() { this.#organization = CONFIG.organization; this.#domainId = CONFIG.domainId; this.#launcherId = CONFIG.launcherId; this.#locale = CONFIG.locale; this.#eventBody = { organization: this.#organization, domainId: this.#domainId, launcherId: this.#launcherId, locale: this.#locale }; this.#addMissingFieldsToEventObject(); } #generateUuid() { return self.crypto.randomUUID(); } // Must match user-tracking-script.js#getRegistrableDomain so both scripts // scope the zoovu-cid cookie to the same domain. If they disagree (e.g. one // host-only, one domain-wide) the browser keeps them as two distinct cookies. #getRegistrableDomain(hostname) { if (this.#cachedRegistrableDomain) { return this.#cachedRegistrableDomain; } const parts = hostname.split('.'); if (parts.length <= 2) { this.#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 (this.#getCookieValue(probe) !== null) { document.cookie = `${probe}=; domain=.${candidate}; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`; this.#cachedRegistrableDomain = candidate; return candidate; } } this.#cachedRegistrableDomain = parts.slice(-2).join('.'); return this.#cachedRegistrableDomain; } #writeZoovuCidCookie(cid) { const domain = this.#getRegistrableDomain(window.location.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; } #addZoovuCidToCookies() { const cid = `${this.#generateUuid()}_${Date.now()}`; this.#writeZoovuCidCookie(cid); return cid; } #getCookieValue(cookieName) { const cookies = document.cookie.split('; '); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].split('='); if (cookie[0] === cookieName) { return cookie[1]; } } return null; } #addMissingFieldsToEventObject() { const path = window.location.href.replace(window.location.origin, ''); const currentZoovuCid = this.#getCookieValue('zoovu-cid'); let cid; if (currentZoovuCid !== null) { this.#writeZoovuCidCookie(currentZoovuCid); cid = currentZoovuCid; } else { cid = this.#addZoovuCidToCookies(); } this.#eventBody['cid'] = cid; this.#eventBody['path'] = path; this.#eventBody['timezone'] = Intl.DateTimeFormat().resolvedOptions().timeZone; } async #sendEvent(body) { const url = 'https://queue-propagator.zoovu.com/tracking'; await fetch(url, { method: 'POST', mode: 'cors', cache: 'no-cache', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', }, redirect: 'follow', referrerPolicy: 'no-referrer', body: JSON.stringify(body), }); } addFieldToEventObject(fieldName, fieldValue) { this.#eventBody[fieldName] = fieldValue; } startClock() { this.#startTime = new Date(); } sendWidgetOpenedEvent() { this.#addMissingFieldsToEventObject(); this.#sendEvent({ ...this.#eventBody, eventType: 'COMPONENT_APPEARS', }); } sendWidgetClosedEvent(reason) { this.#addMissingFieldsToEventObject(); this.#sendEvent({ ...this.#eventBody, disclaimer: this.#mapDisclaimer(reason), eventType: 'COMPONENT_DECLINED', }); } #mapDisclaimer(reason) { switch (reason) { case 'escape': return 'escape'; case 'cta-close': return 'cta_close'; default: return 'close_button_clicked'; } } sendWidgetClickedEvent(groupUuid) { this.#addMissingFieldsToEventObject(); const stopTime = new Date(); this.#sendEvent({ ...this.#eventBody, timePassed: String(stopTime - this.#startTime), eventType: 'COMPONENT_LINK_CLICKED', groupUUID: groupUuid }); } sendSignalChangedEvent(value) { this.#addMissingFieldsToEventObject(); this.#sendEvent({ ...this.#eventBody, value, eventType: 'SIGNAL_VALUE_CHANGED', }); } } class Widget { #widget; #scriptTag; #tracking; constructor(tracking, groupUuid) { if (tracking instanceof Tracking === false) throw Error('Not a Tracking parameter'); if (CONFIG.widget.type === 'CUSTOM_BLOCK') { this.#initBlockWidget(tracking, groupUuid); return; } this.#scriptTag = document.createElement('script'); this.#widget = document.createElement('zoovu-widget'); this.#appendScriptToHead(); this.setOnClickLink(groupUuid); this.setOnClose(); this.#allocateWidgetIntoBody(); this.#tracking = tracking; this.#tracking.startClock(); this.#tracking.addFieldToEventObject('groupUUID', groupUuid); this.#tracking.sendWidgetOpenedEvent(); this.open(); } setWidgetAttribute(key, value) { this.#widget.setAttribute(key, value); } setOnClose() { this.#widget.onClose = () => { this.#tracking.sendWidgetClosedEvent(); }; } setOnClickLink(groupUuid) { this.#widget.onClick = () => { this.#tracking.sendWidgetClickedEvent(groupUuid); }; } open() { this.#widget.setAttribute('is-open', 'true'); } #appendScriptToHead() { this.#scriptTag.setAttribute( 'src', 'https://orca-cdn.zoovu.com/core-js/zoovu-tag-library/zoovu-tag-library-latest.min.js' ); document.head.appendChild(this.#scriptTag); } #allocateWidgetIntoBody() { this.setWidgetAttribute('link', `${CONFIG.widget.url}`); this.setWidgetAttribute('is-open', 'false'); if (CONFIG.widget.displayText) { this.setWidgetAttribute('display-text', `${CONFIG.widget.displayText}`); } if (CONFIG.widget.customization) { this.setWidgetAttribute('customization', `${JSON.stringify(CONFIG.widget.customization)}`); } document.body.appendChild(this.#widget); } #initBlockWidget(tracking, groupUuid) { this.#tracking = tracking; this.#tracking.startClock(); this.#tracking.addFieldToEventObject('groupUUID', groupUuid); this.#scriptTag = document.createElement('script'); this.#appendScriptToHead(); this.#widget = document.createElement('zoovu-block-widget'); this.#wireBlockWidgetEvents(groupUuid); this.setWidgetAttribute('launcher-id', CONFIG.launcherId); this.setWidgetAttribute('is-open', 'false'); this.#widget.template = CONFIG.widget.template; document.body.appendChild(this.#widget); this.open(); } #wireBlockWidgetEvents(groupUuid) { this.#widget.addEventListener('widget-appeared', () => { this.#tracking.sendWidgetOpenedEvent(); }); this.#widget.addEventListener('widget-link-clicked', () => { this.#tracking.sendWidgetClickedEvent(groupUuid); }); this.#widget.addEventListener('widget-declined', (e) => { const reason = e.detail ? e.detail.reason : undefined; this.#tracking.sendWidgetClosedEvent(reason); }); } } class Engine { #engineTracking; constructor(tracking) { if (tracking instanceof Tracking === false) throw Error('Not a Tracking parameter'); this.#engineTracking = tracking; this.#carryOverCidScopedLatches(); this.#parseRules(); } #hasLoaded() { return document.readyState === "complete" || document.readyState === "interactive"; } #evaluateAllRules() { // we evaluate rules that are defined in the back end CONFIG.groups.forEach((group) => { group.rows.forEach((rule) => { this.#evaluateRule(rule, group); }); }); } #groupFiredKey(group) { // Browser-scoped, like the SIGNAL_* values and rule results it guards, so // all of them are kept or cleared together. A cid-scoped latch re-armed // whenever the zoovu-cid cookie went missing while the stored values // survived, and the boot replay then fired the widget with no trigger. return `BL_GROUP_FIRED_${CONFIG.launcherId}_${group.id}`; } // Latches written before BLS-360 were BL_GROUP_FIRED___. // One under any cid means this browser already saw the group. #carryOverCidScopedLatches() { CONFIG.groups.forEach((group) => { if (this.#isGroupFired(group)) return; const suffix = `_${CONFIG.launcherId}_${group.id}`; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith('BL_GROUP_FIRED_') && key.endsWith(suffix) && localStorage.getItem(key) === 'true') { this.#markGroupFired(group); return; } } }); } #isGroupFired(group) { return localStorage.getItem(this.#groupFiredKey(group)) === 'true'; } #markGroupFired(group) { localStorage.setItem(this.#groupFiredKey(group), 'true'); } #parseRules() { if (this.#hasLoaded()) { this.#evaluateAllRules(); } else { window.addEventListener('load', () => { this.#evaluateAllRules(); }); } } GroupOperatorType = Object.freeze({ OR: (booleanArray) => booleanArray.reduce((acc, a) => acc || a, false /*initial*/), AND: (booleanArray) => booleanArray.reduce((acc, a) => acc && a, true /*initial*/), }); BooleanOperator = Object.freeze({ EQUAL: (a, b) => a === b, NOT_EQUAL: (a, b) => a !== b, }); DurationOperator = Object.freeze({ GREATER_THAN: (a, b) => a > b, LESS_THAN: (a, b) => a < b, EQUAL: (a, b) => a === b, NOT_EQUAL: (a, b) => a !== b, }); NumericOperator = Object.freeze({ EQUAL: (a, b) => a === b, NOT_EQUAL: (a, b) => a !== b, 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, }); StringOperator = Object.freeze({ EQUAL: (a, b) => a === b, NOT_EQUAL: (a, b) => a !== b, CONTAINED_IN: (a, b) => b.indexOf(a) >= 0, CONTAINS: (a, b) => a.indexOf(b) >= 0, NOT_CONTAINED_IN: (a, b) => b.indexOf(a) === -1, NOT_CONTAINEs: (a, b) => a.indexOf(b) === -1, STARTS_WITH: (a, b) => a.startsWith(b), ENDS_WITH: (a, b) => a.endsWith(b), MATCHES: (a, b) => a.match(b).length > 0, NOT_MATCHES: (a, b) => a.match(b).length === 0, }); #cast(type, value) { switch (type) { case 'BOOLEAN': return value === true || value === 'true'; case 'TEXT': return String(value); case 'DECIMAL': return typeof value === 'string' ? parseFloat(value) : value; case 'INTEGER': case 'DURATION': return typeof value === 'string' ? parseInt(value) : value; default: return value; } } #getOperatorByType(type) { switch (type) { case 'BOOLEAN': return this.BooleanOperator; case 'TEXT': return this.StringOperator; case 'DURATION': return this.DurationOperator; case 'DECIMAL': case 'INTEGER': return this.NumericOperator; default: return this.StringOperator; } } #getRuleName(rule) { return rule.subject.id + rule.operator + rule.value.toString(); } #isNull(value) { return typeof value === 'undefined' || value === null; } #checkRule(rule, value, group) { if (this.#isNull(value)) return; if (this.#isGroupFired(group)) return; const subjectType = rule.subject.type; const operatorType = this.#getOperatorByType(subjectType); const rowOperator = rule.operator; const result = operatorType[rowOperator]( this.#cast(subjectType, value), this.#cast(subjectType, rule.value) ); this.#setValueToStorage(rule, value, result); if (result === true) this.#checkGroup(group); } #checkGroup(group) { if (this.#isGroupFired(group)) return; const results = group.rows.map((row) => { const ruleResult = this.#readValueFromStorage(row); if (this.#isNull(ruleResult)) return false; return ruleResult.passed === true; }); const groupResult = this.GroupOperatorType[group.operator](results); if (groupResult !== true) return; // Latch BEFORE constructing the Widget — the Widget ctor sends // COMPONENT_APPEARS, and any concurrent re-entry (same tab or via a // cross-tab storage event) must observe "already fired" and skip. this.#markGroupFired(group); new Widget(this.#engineTracking, group.id); } // closure #returnValue(rule, group) { return (value) => { if (this.#isNull(value)) return; this.#checkRule(rule, value, group); }; } // closure #getValue(rule) { return () => { const storedValue = this.#readValueFromStorage(rule); if (this.#isNull(storedValue)) return null; return storedValue.value; }; } // closure #resetRuleResult(rule) { return () => { const storedValue = this.#readValueFromStorage(rule); this.#setValueToStorage(rule, storedValue.value, false); }; } #parseSignalValue(signalId, setValue) { const tracking = this.#engineTracking window.addEventListener('storage', function (e) { if (e.key === 'SIGNAL_' + signalId) { const parsedBody = JSON.parse( localStorage.getItem('SIGNAL_' + signalId) ); setValue(parsedBody.value || null); tracking.sendSignalChangedEvent(String(parsedBody.value)); } }); // Catch a value that arrived before the listener was registered // (e.g. page reload while SIGNAL_ already sits at threshold). // No sendSignalChangedEvent — nothing changed on boot, we're just // catching up on existing state. const stored = localStorage.getItem('SIGNAL_' + signalId); if (stored !== null) { setValue(JSON.parse(stored).value || null); } } #setValueToStorage(rule, value, passed) { const stringValue = JSON.stringify({ value: value, passed: passed }); localStorage.setItem(this.#getRuleName(rule), stringValue); } #readValueFromStorage(rule) { return JSON.parse(localStorage.getItem(this.#getRuleName(rule))); } #evaluateRule(rule, group) { switch (rule.subject.ruleType) { case 'BEHAVIORAL_COMPONENT': rule.subject.code( this.#getValue(rule), this.#returnValue(rule, group), this.#resetRuleResult(rule) ); break; case 'SIGNAL': this.#parseSignalValue(rule.subject.id, this.#returnValue(rule, group)); break; default: console.warn('Invalid subject type', rule.subject.ruleType); } } } const tracking = new Tracking(); const engine = new Engine(tracking); // Defer the check to allow the rest of the script to be parsed first setTimeout(function() { if (typeof SignalListener === 'undefined') { if (typeof window.ZoovuTrackingManager !== 'undefined' && typeof window.ZoovuTrackingManager.setSignalReady === 'function') { window.ZoovuTrackingManager.setSignalReady(); } } // If SignalListener exists, it will call setSignalReady() when WebSocket connects }, 0); class SignalListener { constructor() { this.#connectToWebsocket(); } #getCookieValue(cookieName) { const cookies = document.cookie.split('; '); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].split('='); if (cookie[0] === cookieName) { return cookie[1]; } } return null; } #connectToWebsocket() { const selfRunning = () => { const zoovuCid = this.#getCookieValue('zoovu-cid'); const signalWebsocket = new WebSocket( `wss://signal-connector.zoovu.com/ws/signal/${zoovuCid}` ); signalWebsocket.onopen = function () { // Notify tracking script that signal connection is ready if (typeof window.ZoovuTrackingManager !== 'undefined' && typeof window.ZoovuTrackingManager.setSignalReady === 'function') { window.ZoovuTrackingManager.setSignalReady(); } }; signalWebsocket.onmessage = function (msg) { const body = JSON.parse(msg.data); if (body.type === 'SIGNAL') { const storageKey = 'SIGNAL_' + body.message.name; const storageValue = JSON.stringify(body.message); localStorage.setItem(storageKey, storageValue); const event = new StorageEvent('storage', { key: storageKey, newValue: storageValue, }); window.dispatchEvent(event); } }; signalWebsocket.onclose = function () { setTimeout(function () { selfRunning(); }, 100); }; }; selfRunning(); } } const signalListener = new SignalListener(); })();