1/** 2 * @author Mailchimp 3 * @copyright Mailchimp 4 * @license Proprietary 5 */ 6 7/** 8 * MailchimpPro â pixel form-identify watcher. 9 * 10 * Conditionally loaded only when MAILCHIMP_PIXEL_IDENTIFY_ON_FORMS is on 11 * (server-side gate in mailchimppro::hookActionFrontControllerSetMedia). 12 * When loaded, it listens for `submit` events on the document (newsletter, 13 * contact, forgot-password â Steps 1/3/4) AND for `click` events on the 14 * restock-alert button (Step 2 â ps_emailalerts is JS-driven AJAX with no 15 * real <form>). On a match it fires api.identify(...) with the visitor's 16 * email â but only for guests, never overwriting a stronger identity. 17 * 18 * Lifecycle on a typical visit: 19 * 1. Bridge boots â checks for a pending-form-identify localStorage 20 * flag (from a prior form-POST + reload). If found and recent, 21 * fires identify and clears the flag. 22 * 2. Document-level submit listener (capture phase) â newsletter / contact / 23 * forgot-password form submissions. 24 * 3. Document-level click listener (capture phase) â restock-alert button 25 * clicks (no submit event because there's no real <form>). 26 * 4. Each handler: 27 * - DUAL GATE: skip if visitor is logged-in (explicit 28 * window.prestashop.customer.is_logged check â race-free) OR if 29 * mc_pixel_last_identify is already set (covers guest mid-checkout 30 * + prior identify). See bridge file for rationale. 31 * - Read email from the relevant DOM container, validate basic shape. 32 * - Hash via crypto.subtle when format=EMAIL_SHA256, else use plain. 33 * - Write a pending-identify flag to localStorage (defense against 34 * the form-POST race where the browser navigates BEFORE the SDK 35 * persists the identify). 36 * - Call window.MC_PIXEL_BRIDGE.identify(...) â synchronous queue 37 * insert into the SDK (the SDK dedupes against its own stored 38 * anonymousId binding). 39 * - Schedule a 500ms setTimeout to clear the pending flag. The 40 * timeout is the AJAX-vs-form-POST detector: if the page stays 41 * alive (AJAX), the timeout fires + clears the flag (no 42 * duplicate recovery on subsequent refresh). If the page 43 * navigates (form-POST), the timeout is cancelled by tear-down 44 * and the flag survives to drive recovery on the next page. 45 * 46 * Cross-contamination caveat documented in the FAQ: on shared browsers, 47 * a guest who identifies via a form binds the pixel anonymousId to their 48 * email until their browser data is cleared. Admins on multi-user 49 * terminals can disable form-identify via the master toggle in the BO. 50 */ 51(function () { 52 if (!window.MC_PIXEL_BRIDGE || !window.MC_PIXEL || !window.MC_PIXEL.identifyForms) { 53 return; 54 } 55 56 var bridge = window.MC_PIXEL_BRIDGE; 57 var cfg = window.MC_PIXEL; 58 var STORAGE_KEY_LAST_IDENTIFY = 'mc_pixel_last_identify'; 59 var STORAGE_KEY_PENDING_FORM = 'mc_pixel_pending_form_identify'; 60 var PENDING_TTL_MS = 5 * 60 * 1000; // 5 min â survives a normal form-POST + reload, expires anything stale. 61 var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 62 63 // ------------------------------------------------------------------ 64 // Selector resolution â UNION of server-emitted core defaults and the 65 // admin's additive list. Done once at module load; selectors don't 66 // change at runtime. 67 // ------------------------------------------------------------------ 68 function resolveSelectors(formKey) { 69 var formCfg = cfg.identifyForms && cfg.identifyForms[formKey]; 70 if (!formCfg) return []; 71 72 var core = Array.isArray(formCfg.coreDefaults) ? formCfg.coreDefaults : []; 73 var customStr = typeof formCfg.selectorsCustom === 'string' ? formCfg.selectorsCustom : ''; 74 var custom = customStr.trim() !== '' 75 ? customStr.split(',').map(function (s) { return s.trim(); }).filter(Boolean) 76 : []; 77 78 // Union, deduped â admin entries that overlap core are dropped (no harm). 79 var seen = {}; 80 var union = []; 81 core.concat(custom).forEach(function (sel) { 82 if (sel && !seen[sel]) { 83 seen[sel] = true; 84 union.push(sel); 85 } 86 }); 87 return union; 88 } 89 90 var newsletterSelectors = resolveSelectors('newsletter'); 91 var newsletterSelectorJoined = newsletterSelectors.join(','); 92 93 var restockSelectors = resolveSelectors('restock'); 94 var restockSelectorJoined = restockSelectors.join(','); 95 96 // Static wrapper + email-input selector lists used by the restock click 97 // handler. Hardcoded (not admin-configurable) because they're stable 98 // module + theme conventions â exposing them as another knob would be 99 // confusing without adding coverage. 100 // 101 // Wrapper lookup walks UP from the clicked button via closest(): 102 // - .js-mailalert (ps_emailalerts default template, classic theme)
103 // - [data-ps-ref="emailalerts"] (hummingbird theme override, PS 9+) 104 // - [data-url*="ps_emailalerts"] (any custom theme that keeps the module's AJAX URL attr) 105 // 106 // Email-input lookup runs INSIDE that wrapper: 107 // - [data-ps-ref="emailalerts-email"] (hummingbird â authoritative selector) 108 // - input[type="email"] (classic + generic â module template has no name attr) 109 // - input[name="email"] (hummingbird has name="email"; defensive fallback) 110 var RESTOCK_WRAPPER_SELECTOR = '.js-mailalert, [data-ps-ref="emailalerts"], [data-url*="ps_emailalerts"]'; 111 var RESTOCK_EMAIL_INPUT_SELECTOR = '[data-ps-ref="emailalerts-email"], input[type="email"], input[name="email"]'; 112 113 var contactSelectors = resolveSelectors('contact'); 114 // Runtime selector: build form[action="<contact URL>"] from PS's own 115 // exposed URL constant. Catches multi-lingual contact URLs (French 116 // /contactez-nous, German /kontaktieren-sie-uns, etc.) automatically 117 // â admins on i18n shops don't need to paste the translated slug into 118 // the BO selector field. The URL is set by PS core via Media::addJsDef 119 // through json_encode (XSS-safe), but we still gate strictly on the 120 // string having no quote characters before injecting into a selector. 121 try { 122 var contactUrl = (window.prestashop 123 && window.prestashop.urls 124 && window.prestashop.urls.pages 125 && window.prestashop.urls.pages.contact) || ''; 126 if (typeof contactUrl === 'string' 127 && contactUrl !== '' 128 && contactUrl.indexOf('"') === -1 129 && contactUrl.length < 500) { 130 var runtimeContactSel = 'form[action="' + contactUrl + '"]'; 131 if (contactSelectors.indexOf(runtimeContactSel) === -1) { 132 contactSelectors.push(runtimeContactSel); 133 } 134 } 135 } catch (e) { /* prestashop global missing â skip runtime selector */ } 136 var contactSelectorJoined = contactSelectors.join(','); 137 138 var CONTACT_EMAIL_INPUT_SELECTOR = 'input[name="from"], input[type="email"], input[name="email"]'; 139 140 var forgotPasswordSelectors = resolveSelectors('forgotPassword'); 141 // Runtime selector for the password-recovery URL â same i18n-safe 142 // pattern as contact above. PS exposes the URL via 143 // window.prestashop.urls.pages.password; the bridge appends a 144 // matching form[action="..."] selector so multi-lingual deployments 145 // are caught without admin paste. 146 try { 147 var passwordUrl = (window.prestashop 148 && window.prestashop.urls 149 && window.prestashop.urls.pages 150 && window.prestashop.urls.pages.password) || ''; 151 if (typeof passwordUrl === 'string' 152 && passwordUrl !== '' 153 && passwordUrl.indexOf('"') === -1 154 && passwordUrl.length < 500) { 155 var runtimePasswordSel = 'form[action="' + passwordUrl + '"]'; 156 if (forgotPasswordSelectors.indexOf(runtimePasswordSel) === -1) { 157 forgotPasswordSelectors.push(runtimePasswordSel); 158 } 159 } 160 } catch (e) { /* prestashop global missing â skip runtime selector */ } 161 var forgotPasswordSelectorJoined = forgotPasswordSelectors.join(','); 162 163 // ------------------------------------------------------------------ 164 // Email hashing â SHA-256 via crypto.subtle when format=EMAIL_SHA256, 165 // else pass plain email through. crypto.subtle is universal on HTTPS 166 // (which is our deployment floor); silently fail to plain on the rare 167 // browser without it. 168 // ------------------------------------------------------------------ 169 function formatIdentifyValue(email, done) { 170 var normalized = String(email).trim().toLowerCase(); 171 if ((cfg.identifyFormat || 'EMAIL_SHA256') !== 'EMAIL_SHA256') { 172 done(normalized); 173 return; 174 } 175 try { 176 var buf = new TextEncoder().encode(normalized); 177 window.crypto.subtle.digest('SHA-256', buf).then(function (hash
177) { 178 var hex = Array.prototype.map.call(new Uint8Array(hash), function (b) { 179 return b.toString(16).padStart(2, '0'); 180 }).join(''); 181 done(hex); 182 }, function () { done(null); }); 183 } catch (e) { 184 done(null); 185 } 186 } 187 188 // ------------------------------------------------------------------ 189 // Defensive logged-in detection. window.prestashop is set by core PS 190 // on every storefront page, BUT we still guard every access â custom 191 // themes have been seen to defer or fully strip the global, and the 192 // bridge must never throw inside a click/submit handler (PS uses 193 // event-default semantics that we'd otherwise corrupt). Treat the 194 // global as untrusted: any of "absent / non-object / customer absent / 195 // is_logged falsy" => visitor is NOT logged in. 196 // 197 // We accept the boolean true OR the numeric 1 (some PS minor versions 198 // serialize boolâint through json_encode-with-JSON_FORCE_OBJECT-paths; 199 // belt-and-suspenders). 200 // ------------------------------------------------------------------ 201 function isLoggedIn() { 202 try { 203 var ps = window.prestashop; 204 if (!ps || typeof ps !== 'object') return false; 205 var c = ps.customer; 206 if (!c || typeof c !== 'object') return false; 207 var flag = c.is_logged; 208 return (flag === true || flag === 1); 209 } catch (e) { 210 return false; 211 } 212 } 213 214 // ------------------------------------------------------------------ 215 // Storage helpers 216 // ------------------------------------------------------------------ 217 function readLastIdentify() { 218 try { return window.localStorage.getItem(STORAGE_KEY_LAST_IDENTIFY) || ''; } 219 catch (e) { return ''; } 220 } 221 222 function writePendingFormIdentify(value, source) { 223 try { 224 window.localStorage.setItem(STORAGE_KEY_PENDING_FORM, JSON.stringify({ 225 value: value, source: source, ts: Date.now() 226 })); 227 } catch (e) { /* localStorage disabled â non-critical */ } 228 } 229 230 function readAndClearPendingFormIdentify() { 231 try { 232 var raw = window.localStorage.getItem(STORAGE_KEY_PENDING_FORM); 233 if (!raw) return null; 234 window.localStorage.removeItem(STORAGE_KEY_PENDING_FORM); 235 var parsed = JSON.parse(raw); 236 if (!parsed || !parsed.value || (Date.now() - parsed.ts) > PENDING_TTL_MS) { 237 return null; 238 } 239 return parsed; 240 } catch (e) { return null; } 241 } 242 243 // ------------------------------------------------------------------ 244 // Pending-identify pickup â runs once at boot. Handles the form-POST 245 // page-reload race where the SDK's IndexedDB persistence may not have 246 // completed before the browser navigated away. 247 // ------------------------------------------------------------------ 248 var pending = readAndClearPendingFormIdentify(); 249 if (pending && !readLastIdentify()) { 250 bridge.identify(pending.value); 251 bridge.sendTelemetry('IDENTIFY', 1, 252 humanFormLabel(pending.source) + ' identify recovered after page reload â ' 253 + formatLogValue(pending.value)); 254 } 255 256 // ------------------------------------------------------------------ 257 // Shared identify routine â accepts the container (a <form> for submit 258 // watchers; the ps_emailalerts wrapper for the restock click watcher) 259 // plus a source label. Dual-gate is applied here so newsletter + 260 // restock (+ Steps 3/4 contact + forgot-password) share the same skip 261 // logic; ordering: explicit is_logged first (synchronous, no I/O), 262 // then readLastIdentify (covers guest mid-checkout + prior identify). 263 // ------------------------------------------------------------------ 264 function fireIdentifyFromContainer(container, source, emailInputSelector) { 265 // Gate 1 â explicit logged-in check. Race-free, immediate; 266 // doesn't depend on whether the main bridge has booted yet. 267 if (isLoggedIn()) { 268 bridge.sendTelemetry('IDENTIFY', 1, 269 humanFormLabel(source) + ' â identify skipped (visitor is logged in)'); 270 return; 271 } 272 273 // Gate 2 â already-identified bookkeeping (covers guest mid-checkout 274 // post personal-info, AND any prior form-identify this session). 275 if (readLastIdentify()) { 276 bridge.sendTelemetry('IDENTIFY', 1, 277 humanFormLabel(source) + ' â identify skipped (visitor already identified)'); 278 return; 279 } 280 281 if (!container) return; 282 var selector = emailInputSelector || 'input[type="email"], input[name="email"], input[name*="EMAIL"]'; 283 var emailInput = container.querySelector(selector); 284 if (!emailInput || !emailInput.value) return; 285 286 var email = String(emailInput.value).trim(); 287 if (!EMAIL_RE.test(email)) return; 288 289 formatIdentifyValue(email, function (value) { 290 if (!value) return; 291 // Pending-flag handoff defends against the form-POST race: 292 // user submits â browser navigates BEFORE the SDK persists â 293 // the flag survives in localStorage â next page's bridge 294 // re-fires identify. Without it, that race silently drops 295 // the identify on slow connections. 296 writePendingFormIdentify(value, source); 297 // Same-page identify â synchronous queue insert into the SDK. 298 // Covers AJAX-submitted forms (where the page never navigates).
299 // For form-POST forms this still runs but may not persist 300 // before navigation; the pending flag above is the recovery. 301 bridge.identify(value); 302 bridge.sendTelemetry('IDENTIFY', 1, 303 humanFormLabel(source) + ' â identify fired (' + formatLogValue(value) + ')'); 304 // Optimistic LAST_IDENTIFY write â defends against the inverse 305 // form-POST race where bridge.identify above DID reach the SDK 306 // and the SDK persisted the event to IndexedDB before nav. On 307 // the reload page, the SDK would flush the queued event AND 308 // form-bridge's pending-pickup at boot would ALSO fire identify 309 // with the same hash â 2 IDENTITY_ASSOCIATED events to Mailchimp 310 // (bug reported 2026-05-29 on demo911 contact form submit 311 // + reload). Writing LAST_IDENTIFY here makes the pending-pickup 312 // gate `!readLastIdentify()` fail on the reload page, skipping 313 // the duplicate. The write is optimistic (we don't know if 314 // bridge.identify actually persisted), but the rc.5 console.error 315 // hook + retry would catch any "Pixel not initialized" failure 316 // and recover via SDK_LOAD_FAILED telemetry if all retries 317 // exhaust â i.e. failure is never silently lost. 318 try { window.localStorage.setItem(STORAGE_KEY_LAST_IDENTIFY, value); } catch (e) {} 319 // Self-distinguishing AJAX vs form-POST. The setTimeout only 320 // fires if the JS event loop is still alive â i.e. the page 321 // did NOT navigate. So: 322 // AJAX (page stays alive) â clear the flag â no duplicate 323 // recovery on the user's next 324 // refresh. 325 // form-POST (page navigates) â setTimeout is cancelled by 326 // the navigation tear-down â 327 // flag survives â next page's 328 // bridge sees LAST_IDENTIFY set 329 // and skips pickup (no dup). 330 // 331 // 500ms padding: well past typical browser nav (~50-100ms) 332 // and past SDK in-memory persistence (~10-100ms). 333 setTimeout(function () { 334 try { window.localStorage.removeItem(STORAGE_KEY_PENDING_FORM); } catch (e) {} 335 }, 500); 336 }); 337 } 338 339 // Human-friendly label for each form source â used in telemetry so admins 340 // see "Newsletter form" instead of the internal source code "form-newsletter". 341 function humanFormLabel(source) { 342 switch (source) { 343 case 'form-newsletter': return 'Newsletter form'; 344 case 'form-restock': return 'Restock-alert form'; 345 case 'form-contact': return 'Contact form'; 346 case 'form-forgot-password': return 'Forgot-password form'; 347 default: return 'Form (' + source + ')'; 348 } 349 } 350 351 // Format-aware log value â respects the admin's identify-format preference. 352 // EMAIL_SHA256 mode â only first 16 chars of the hash (privacy-conscious; 353 // enough for admin to correlate visitors across log entries) 354 // EMAIL mode â full plain email (admin already chose to ship plain 355 // emails to Mailchimp; showing it in the log is consistent) 356 function formatLogValue(storedValue) { 357 // Default-case (undefined / null / empty / unknown value) falls through 358 // to the hash-prefix branch â the privacy-safe default. Compare to 359 // formatIdentifyValue above which uses `!== 'EMAIL_SHA256'` and needs 360 // a fallback to keep the default on hash mode. 361 if (cfg.identifyFormat === 'EMAIL') { 362 return 'email ' + storedValue; 363 } 364 return 'value prefix ' + String(storedValue).substring(0, 16); 365 } 366 367 // ------------------------------------------------------------------ 368 // Submit watcher â newsletter today; Steps 3-4 will reuse this 369 // listener for contact + forgot-password by adding selector unions. 370 // Capture phase so we read the email BEFORE any onsubmit handler 371 // mutates the form (AJAX) or navigates away (form-POST). 372 // ------------------------------------------------------------------ 373 function handleSubmit(event) { 374 var form = event.target; 375 if (!form || form.nodeName !== 'FORM') return; 376 377 if (newsletterSelectorJoined && form.matches && form.matches(newsletterSelectorJoined)) { 378 fireIdentifyFromContainer(form, 'form-newsletter'); 379 return; 380 } 381 if (contactSelectorJoined && form.matches && form.matches(contactSelectorJoined)) { 382 fireIdentifyFromContainer(form, 'form-contact', CONTACT_EMAIL_INPUT_SELECTOR); 383 return; 384 } 385 if (forgotPasswordSelectorJoined && form.matches && form.matches(forgotPasswordSelectorJoined)) { 386 // Forgot-password form has only one email input (name="email"), 387 // so the default selector chain in fireIdentifyFromContainer 388 // catches it cleanly â no override needed. 389 fireIdentifyFromContainer(form, 'form-forgot-password'); 390 return; 391 } 392 } 393 394 if (newsletterSelectorJoined || contactSelectorJoined || forgotPasswordSelectorJoined) { 395 document.addEventListener('submit', handleSubmit, true); 396 } 397 398 // ------------------------------------------------------------------ 399 // Click watcher â restock-alert button (ps_emailalerts). The module 400 // is JS-driven AJAX with NO real <form>: the user types an email and 401 // clicks "Notify me when available". The module's own click handler 402 // is jQuery $(document).on('click', ...) for classic / native delegation 403 // for hummingbird â both bubble-phase. Our capture-phase listener 404 // fires FIRST so we can read the email value cleanly before either 405 // handler mutates the DOM (e.g., hides the input on success). 406 // ------------------------------------------------------------------ 407 function handleClick(event) { 408 var target = event.target; 409 if (!target || typeof target.closest !== 'function') return; 410 if (!restockSelectorJoined) return; 411 412 var button = target.closest(restockSelectorJoined); 413 if (!button) return; 414 415 // Walk up to the ps_emailalerts wrapper â covers classic 416 // (.js-mailalert) + hummingbird ([data-ps-ref="emailalerts"]) + any 417 // custom theme that preserves the module's data-url attribute. 418 // Fallback to the button itself's parent if no known wrapper found 419 // (lets querySelector still attempt to find an email input nearby 420 // without throwing). 421 var wrapper = (typeof button.closest === 'function') 422 ? button.closest(RESTOCK_WRAPPER_SELECTOR) 423 : null; 424 var container = wrapper || button.parentElement || button; 425 426 fireIdentifyFromContainer(container, 'form-restock', RESTOCK_EMAIL_INPUT_SELECTOR); 427 } 428 429 if (restockSelectorJoined) { 430 document.addEventListener('click', handleClick, true); 431 } 432})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.