1/** 2 * @author Mailchimp 3 * @copyright Mailchimp 4 * @license Proprietary 5 */ 6 7/** 8 * Mailchimp Pixel bridge â storefront-side glue between PrestaShop FO events 9 * and the Mailchimp Pixel SDK (window.$mcSite.pixel.api). 10 * 11 * Responsibilities (this file is the FOUNDATION; the actual event wiring lands 12 * in the next sub-commit): 13 * 1. Wait for window.$mcSite.pixel.api to become available (the chimpstatic 14 * MC.js loader is async â our bridge can only fire calls once the SDK 15 * has booted in the browser). Time out after 8 s. 16 * 2. Report the runtime truth back to the BO via the FO telemetry endpoint: 17 * - PIXEL_AVAILABLE (info) when $mcSite.pixel.api is present 18 * - SDK_TIMEOUT (warning) when 8 s elapse with no SDK 19 * - SDK_LOAD_FAILED (error) reserved for explicit chimpstatic 4xx/5xx 20 * Telemetry is sent via navigator.sendBeacon (fire-and-forget, doesn't 21 * block the page) with a fetch fallback for older browsers. Calls are 22 * no-ops when MC_PIXEL.pixelLogEnabled is false (admin kill-switch). 23 * 3. Expose helpers (mcTrack, mcIdentify) to the rest of the bridge code 24 * that will be added in subsequent commits. 25 * 26 * Defensive invariants â the bridge MUST NOT break the storefront: 27 * - Every $mcSite touch is guarded; bridge silently no-ops if SDK is absent 28 * (ad blocker, CSP block, network failure, account not enabled, etc.) 29 * - All thrown exceptions are caught locally and downgraded to telemetry POSTs 30 * - sendBeacon failures are silent â the worst case is a missed log entry 31 * 32 * Compatibility: written in ES5 + a couple of widely-supported ES2015 idioms 33 * (const/let are fine here because PS BO/FO targets modern browsers, but no 34 * arrow-callback chaining, no async/await, no optional chaining). 35 */ 36(function () { 37 'use strict'; 38 39 // ------------------------------------------------------------------ 40 // Bootstrap 41 // ------------------------------------------------------------------ 42 43 // The hook handler in mailchimppro.php only registers this script when 44 // the pixel script is cached AND the store is synced. So in practice 45 // MC_PIXEL is always defined when we get here. Defense-in-depth check 46 // anyway â the bridge must never break the storefront if the server 47 // forgets to emit it. 48 var cfg = window.MC_PIXEL || null; 49 if (!cfg) { 50 return; 51 } 52 53 // Must match PixelLog::MAX_MESSAGE_LEN on the server. Server still hard- 54 // caps after receiving, but doing it here too saves bandwidth and keeps 55 // the human-readable summary tokens at the front of the message even on 56 // very large payloads (PURCHASED with many lineItems, long product titles). 57 var MAX_MESSAGE_LEN = 4000; 58 59 // ------------------------------------------------------------------ 60 // SDK init-race detection via console.error pattern 61 // ------------------------------------------------------------------ 62 63 /** 64 * The Mailchimp Pixel SDK has a structural quirk: when api.track / 65 * api.identify is called before its internal `c.initialized` flag flips, 66 * the SDK throws "Pixel not initialized" inside its async method body 67 * but catches the throw in its OWN internal try/catch (see logger-service 68 * + error-handling-service inside chimpstatic.com's bundle). The returned 69 * promise resolves with undefined instead of rejecting, so the .catch() 70 * handler wired in callSdkTrack / callSdkIdentify / callSdkIdentifyPhone 71 * (kept as belt-and-suspenders) never fires. The event is silently lost. 72 * 73 * The only observable signal is the SDK's logger calling console.error 74 * with the literal "Pixel not initialized" plus a `{service: 'Pixel'}` 75 * companion object. We hook console.error once here, watch for that 76 * exact pattern, and trigger retry logic for the oldest pending 77 * dispatch. 78 * 79 * FIFO matching is functionally exact, not heuristic: `c.initialized` 80 * is monotonic (set once inside init(), never reset). So if any pending 81 * attempt failed, it's the oldest one â any later attempt would have 82 * seen `c.initialized=true` and succeeded. 83 */ 84 var pendingSdkAttempts = []; 85 86 (function installSdkConsoleErrorHook() { 87 if (typeof console === 'undefined' || typeof console.error !== 'function') { 88 return; 89 } 90 var originalConsoleError = console.error; 91 console.error = function () { 92 // Always delegate FIRST â never break other modules that may al
92so 93 // wrap console.error (Sentry, Datadog RUM, theme JS). 94 originalConsoleError.apply(console, arguments); 95 try { 96 var hasPattern = false; 97 var hasServiceTag = false; 98 for (var i = 0; i < arguments.length; i++) { 99 var a = arguments[i]; 100 if (typeof a === 'string' && /Pixel not initialized/i.test(a)) { 101 hasPattern = true; 102 } 103 if (a && typeof a === 'object' && a.service === 'Pixel') { 104 hasServiceTag = true; 105 } 106 } 107 // Both must match â narrows false-positive risk to near zero. 108 if (!hasPattern || !hasServiceTag) { 109 return; 110 } 111 var now = Date.now(); 112 // Drop expired entries first (success cases that aged out). 113 while (pendingSdkAttempts.length > 0 && pendingSdkAttempts[0].expiresAt < now) { 114 pendingSdkAttempts.shift(); 115 } 116 var entry = pendingSdkAttempts.shift(); 117 if (entry && typeof entry.onFailed === 'function') { 118 entry.onFailed('Pixel not initialized'); 119 } 120 } catch (e) { 121 // Never let a bug in the hook break console.error for other code. 122 } 123 }; 124 })(); 125 126 // ------------------------------------------------------------------ 127 // sendTelemetry â POST to /module/mailchimppro/pixeltelemetry 128 // ------------------------------------------------------------------ 129 130 /** 131 * Fire-and-forget telemetry POST. Used by the bridge to surface runtime 132 * facts (SDK availability, load failures, timeouts) into the BO Pixel-log 133 * viewer. Honors the admin kill-switch â when MC_PIXEL.pixelLogEnabled is 134 * false, the call is a local no-op (saves the round-trip). 135 * 136 * @param {string} eventType one of PixelLog::$foAllowedEventTypes (server-side whitelist) 137 * @param {number} severity 1 = info, 2 = warning, 3 = error 138 * @param {string} [message] free-form detail, capped at MAX_MESSAGE_LEN (matches server) 139 */ 140 function sendTelemetry(eventType, severity, message) { 141 if (!cfg.pixelLogEnabled) { 142 return; 143 } 144 if (!cfg.telemetryUrl) { 145 return; 146 } 147 var payload = { 148 event_type: String(eventType), 149 severity: severity || 1 150 }; 151 if (message) { 152 payload.message = String(message).slice(0, MAX_MESSAGE_LEN); 153 } 154 try { 155 var body = JSON.stringify(payload); 156 // sendBeacon is the right tool for this: queued by the browser, 157 // doesn't block page lifecycle, survives navigation. Falls back 158 // to fetch if not available (very old browsers). 159 if (window.navigator && typeof window.navigator.sendBeacon === 'function') { 160 // sendBeacon with Blob preserves Content-Type â server reads 161 // application/json branch in pixeltelemetry's readPayload(). 162 var blob = new Blob([body], { type: 'application/json' }); 163 window.navigator.sendBeacon(cfg.telemetryUrl, blob); 164 return; 165 } 166 if (typeof window.fetch === 'function') { 167 window.fetch(cfg.telemetryUrl, { 168 method: 'POST', 169 headers: { 'Content-Type': 'application/json' }, 170 body: body, 171 keepalive: true, 172 credentials: 'same-origin' 173 }); 174 } 175 } catch (e) { 176 // sendBeacon / fetch shouldn't throw, but if they do we don't 177 // want one bad telemetry call to break the bridge for the rest 178 // of the session. 179 } 180 } 181 182 // ------------------------------------------------------------------ 183 // Required-fields validator + AJAX-fallback fetcher 184 // ------------------------------------------------------------------ 185 186 /** 187 * Resolve the visitor's display currency, used as a fallback when the 188 * per-product payload didn't include one. Cascade: 189 * 1. cfg.initialProduct.currency â server-emitted on product pages 190 * (always present when MC_PIXEL.initialProduct exists, i.e., when 191 * the visitor is currently on a product page) 192 * 2. window.prestashop.currency.iso_code â PS-native page-wide currency 193 * data, exposed by assignGeneralPurposeVariables() in FrontController. 194 * Present on EVERY FO page across PS 1.7+ â covers cases where 195 * cfg.initialProduct is absent (quickview from a category page, 196 * cart page, checkout, etc.)
197 * 3. '' (empty) â last resort. The required-fields validator rejects 198 * this and triggers the AJAX fallback or PIXEL_DATA_INCOMPLETE. 199 */ 200 function resolveCurrency() { 201 if (cfg.initialProduct && cfg.initialProduct.currency) { 202 return String(cfg.initialProduct.currency); 203 } 204 if (window.prestashop && window.prestashop.currency 205 && typeof window.prestashop.currency === 'object') { 206 var c = window.prestashop.currency; 207 if (c.iso_code) return String(c.iso_code); 208 if (c.code) return String(c.code); 209 } 210 return ''; 211 } 212 213 /** 214 * Mailchimp's 3P Pixel API requires id, productId, title, price for the 215 * product-shaped track payloads (PRODUCT_VIEWED, PRODUCT_ADDED_TO_CART). 216 * We additionally treat currency as required â price without currency 217 * is ambiguous to Mailchimp's analytics (a "9.99" can be $9.99 or â¬9.99). 218 * 219 * Accepts the product node from either shape: 220 * { product: { id, productId, title, price, currency, ... } } (PRODUCT_VIEWED) 221 * { product: { item: { id, productId, title, ... }, quantity } } (PRODUCT_ADDED_TO_CART) 222 * 223 * Returns true when all 5 required fields are present + meaningful. 224 * price === 0 is accepted (genuinely free products exist); the parser 225 * uses `null` to signal extraction failure so we can distinguish. 226 */ 227 function hasRequiredPixelFields(productNode) { 228 if (!productNode) return false; 229 var p = productNode.item || productNode; 230 return !!( 231 p.id 232 && p.productId 233 && p.title 234 && typeof p.price === 'number' 235 && p.currency 236 ); 237 } 238 239 /** 240 * Fetch the server-built pixel payload for (id_product, id_product_attribute) 241 * from /module/mailchimppro/pixelproductinfo â used as a last-resort 242 * fallback when: 243 * (a) the displayProductAdditionalInfo hook isn't rendered by the theme 244 * (b) DOM-parse of PS's AJAX response can't produce all 5 required fields 245 * Resolves with the product payload on success; null on any failure 246 * (network error, server returned ok:false, JSON parse failure). The 247 * caller decides whether to log PIXEL_DATA_INCOMPLETE or skip silently. 248 * 249 * Fire-and-forget from the user's perspective: api.track() doesn't gate 250 * any visible UI, so the ~100ms server round-trip has no UX cost. 251 * 252 * @param {string|number} idProduct 253 * @param {string|number} idProductAttribute 0 for no-combination products 254 * @param {function(object|null)} onDone 255 */ 256 function fetchProductInfo(idProduct, idProductAttribute, onDone) { 257 // Explicit opt-out: don't spend a network round-trip on data we'll 258 // discard at the mcTrack stage. Same short-circuit as mcTrack itself 259 // â see isExplicitlyOptedOut() docblock for why this matters. 260 if (isExplicitlyOptedOut()) { onDone(null); return; } 261 if (!cfg.productInfoUrl) { onDone(null); return; } 262 if (typeof window.fetch !== 'function') { onDone(null); return; } 263 var sep = cfg.productInfoUrl.indexOf('?') >= 0 ? '&' : '?'; 264 var url = cfg.productInfoUrl + sep 265 + 'id_product=' + encodeURIComponent(idProduct) 266 + '&id_product_attribute=' + encodeURIComponent(idProductAttribute || 0) 267 + '&ajax=1'; 268 try { 269 window.fetch(url, { 270 method: 'GET', 271 credentials: 'same-origin', 272 headers: { 'Accept': 'application/json' } 273 }).then(function (r) { 274 if (!r.ok) { onDone(null); return; } 275 return r.json(); 276 }).then(function (data) { 277 if (data && data.ok && data.product) onDone(data.product); 278 else onDone(null); 279 }).catch(function () { onDone(null); }); 280 } catch (e) { 281 onDone(null); 282 } 283 } 284 285 /** 286 * Fetch the visitor's CURRENT cart info from /pixelcartinfo. Used as 287 * fallback when MC_PIXEL.cartId (cached at pageload) is 0, AND as the 288 * Layer 2 recovery path for CART_VIEWED when window.prestashop.cart 289 * is missing or incomplete. 290 * 291 * Resolves with: 292 * - basic mode (`withItems=false`): `{ cartId, currency, productsCount }` 293 * - extended mode (`withItems=true`): also `{ lineItems: [{item,quantity}], totalPrice }` 294 * - null on any failure 295 * 296 * @param {boolean} withItems pass true to request the server-built 297 * lineItems[] + totalPrice (CART_VIEWED Layer 2) 298 * @param {function(object|null)} onDone 299 */ 300 function fetchCartInfo(opts, onDone) { 301 // Back-compat for the original signatures: 302 // fetchCartInfo(cb) â basic mode 303 // fetchCartInfo(true, cb) â withItems=true 304 // fetchCartInfo({withItems:true, withCheckout:true}, cb) â new form 305 // fetchCartInfo({withCustomerEmail:true}, cb) â OPC identify path 306 if (typeof opts === 'function') { 307 onDone = opts; 308 opts = {}; 309 } else if (typeof opts === 'boolean') { 310 opts = { withItems: opts }; 311 } else if (!opts || typeof opts !== 'object') { 312 opts = {}; 313 } 314 // Explicit opt-out: don't spend a network round-trip on data we'll 315 // discard at the mcTrack stage. Exception: when withCustomerEmail=1 316 // (the OPC identify path), the call ALSO returns the customer's 317 // hashed email which mcIdentify uses â but mcIdentify ALSO opt-out 318 // guards itself, so the round-trip is still wasted. Skip it. 319 if (isExplicitlyOptedOut()) { onDone(null); return; } 320 if (!cfg.cartInfoUrl) { onDone(null); return; } 321 if (typeof window.fetch !== 'function') { onDone(null); return; } 322 var sep = cfg.cartInfoUrl.indexOf('?') >= 0 ? '&' : '?'; 323 var url = cfg.cartInfoUrl + sep + 'ajax=1'; 324 if (opts.withItems) url += '&with_items=1'; 325 if (opts.withCheckout) url += '&with_checkout=1'; 326 if (opts.withCustomerEmail) url += '&with_customer_email=1'; 327 try { 328 // POST (not GET) â even though this is a read of per-visitor cart 329 // data. Cache-everything proxy rules (misconfigured Varnish / 330 // Cloudflare "Cache Everything" page rules) treat GET responses 331 // as cacheable even with `Cache-Control: no-store` set. POST is 332 // treated as always-fresh by every cache layer, so per-visitor 333 // responses can't leak across visitors via a shared cache. Same 334 // rationale customerinfo.php uses for its identify endpoint. 335 window.fetch(url, { 336 method: 'POST', 337 credentials: 'same-origin', 338 headers: { 'Accept': 'application/json' } 339 }).then(function (r) { 340 if (!r.ok) { onDone(null); return; } 341 return r.json(); 342 }).then(function (data) { 343 if (data && data.ok && data.cartId) { 344 var out = { 345 cartId: data.cartId, 346 currency: data.currency || '', 347 productsCount: data.productsCount || 0 348 }; 349 if (data.lineItems) out.lineItems = data.lineItems; 350 if (typeof data.totalPrice === 'number') out.totalPrice = data.totalPrice; 351 // Checkout-specific extras (only present when with_checkout=1) 352 if (typeof data.subtotalPrice === 'number') out.subtotalPrice = data.subtotalPrice; 353 if (typeof data.totalTax === 'number') out.totalTax = data.totalTax; 354 if (typeof data.totalShipping === 'number') out.totalShipping = data.totalShipping; 355 if (data.discounts) out.discounts = data.discounts; 356 if (data.customerId) out.customerId = data.customerId; 357 // Identify extra (only present when with_customer_email=1) â 358 // server-built sha256(email) or plain email per the 359 // MAILCHIMP_PIXEL_IDENTIFY_FORMAT config. Used by the 360 // OPC identify watcher to call $mcSite.pixel.api.identify 361 // when window.prestashop.customer.email isn't refreshed 362 // by the active checkout module after the personal-info 363 // AJAX step. 364 if (data.identifyValue) out.identifyValue = data.identifyValue; 365 // Phone enrichment â present only when the cart customer 366 // has a resolvable primary address with a phone. OPC 367 // checkout watcher fires phone identify alongside email. 368 if (data.phoneValue) out.phoneValue = data.phoneValue; 369 onDone(out); 370 } else { 371 onDone(null); 372 } 373 }).catch(function () { onDone(null); }); 374 } catch (e) { 375 onDone(null); 376 } 377 } 378 379 // ------------------------------------------------------------------ 380 // Consent helpers â detect explicit opt-out via mc_user_optin cookie 381 // ------------------------------------------------------------------ 382 383 /** 384 * True ONLY when the visitor has explicitly declined consent (cookie
385 * value === "false"). An unset cookie returns false here (default-opt-in 386 * model â SDK's hasOptedIn behaves the same way). We use this to 387 * short-circuit track/identify/verdict-probe paths so they don't: 388 * - waste 8 s polling for an SDK that won't initialize (it doesn't 389 * pass its own internal consent check when mc_user_optin=false), 390 * - emit misleading SDK_TIMEOUT / PIXEL_NOT_AVAILABLE telemetry that 391 * would make a merchant think their pixel is broken when in fact 392 * the visitor declined. 393 * 394 * Particularly important for the verdict probe: PIXEL_NOT_AVAILABLE 395 * flips MAILCHIMP_PIXEL_AVAILABLE server-side, which means a SINGLE 396 * opted-out visitor could turn the merchant's BO pixel-status panel 397 * from green to red. The consent guard prevents that misattribution. 398 */ 399 function isExplicitlyOptedOut() { 400 try { 401 return /(^|;\s*)mc_user_optin=false(;|$)/.test(document.cookie || ''); 402 } catch (e) { 403 return false; 404 } 405 } 406 407 // ------------------------------------------------------------------ 408 // whenPixelReady â poll for $mcSite.pixel.api with timeout 409 // ------------------------------------------------------------------ 410 411 // Per-pageload "SDK is warm" cache. Once one event has cleared the 412 // settle delay we know `c.initialized` has flipped â every subsequent 413 // event in the same pageload can fire immediately. 414 var sdkSettled = false; 415 416 // Settle delay applied to the FIRST event per pageload only. The SDK's 417 // surface flag `pixel.installed === true` flips one microtask BEFORE 418 // its internal `c.initialized` static does. Calling api.track() in 419 // that microtask window triggers the SDK's logger-service to 420 // console.error "Pixel not initialized" BEFORE rejecting the promise 421 // â and that console.error is impossible to intercept from our side. 422 // The retry safety net would still recover the event, but the visible 423 // console error would remain. A short settle delay before the FIRST 424 // call lets c.initialized catch up, eliminating the console noise 425 // entirely. 426 // 427 // Tunable via the BO Pixel tab "SDK settle delay" field, persisted as 428 // MAILCHIMP_PIXEL_SDK_SETTLE_DELAY_MS. Bump higher (750â1000) on 429 // environments where 500ms isn't enough (slow CPU, cold cache, heavy 430 // pages). Range + default constants are owned by the PHP side and 431 // injected into cfg via JsDef â bridge re-clamps here as defense in 432 // depth against direct ps_configuration tampering. 433 // 434 // No JS-side magic-number fallbacks. If PHP injection fails (the 435 // entire MailchimpProConfig::PIXEL_SDK_SETTLE_DELAY_MS_* family is 436 // missing from cfg), this returns 0 â no settle delay â and the 437 // retry safety net inside callSdkTrack / callSdkIdentify catches 438 // any "Pixel not initialized" race that would otherwise need the 439 // delay. Loud-fail better than silent-wrong-default. 440 function getSdkSettleDelayMs() { 441 var raw = parseInt(cfg.sdkSettleDelayMs, 10); 442 var min = parseInt(cfg.sdkSettleDelayMsMin, 10); 443 var max = parseInt(cfg.sdkSettleDelayMsMax, 10); 444 var fallback = parseInt(cfg.sdkSettleDelayMsDefault, 10); 445 if (isNaN(min) || isNaN(max) || isNaN(fallback)) { 446 return 0; 447 } 448 if (isNaN(raw) || raw < min || raw > max) { 449 return fallback; 450 } 451 return raw; 452 } 453 454 /** 455 * Resolve when window.$mcSite.pixel.api is available, or fire timeout 456 * telemetry after `timeoutMs` (default 8000). Polls every 200 ms. 457 * 458 * @param {function} onReady called with the api object once available 459 * @param {function} [onTimeout] optional â called when timeout is hit 460 * @param {number} [timeoutMs] default 8000 461 */ 462 function whenPixelReady(onReady, onTimeout, timeoutMs) { 463 var start = Date.now(); 464 var deadline = start + (timeoutMs || 8000); 465 466 function tick() { 467 // Readiness requires BOTH the api shape AND `pixel.installed === true`. 468 // 469 // Why both: `pixel.api` is exposed during early script load (object 470 // shape only). The SDK's internal `c.initialized` static flag
471 // doesn't flip until init() has resolved its async services 472 // (logger/storage/window). Calling api.track() before that flip 473 // throws "Pixel not initialized" inside the SDK and the event is 474 // silently discarded â the SDK swallows the throw in its own 475 // async catch, so our caller sees the Promise resolve normally. 476 // 477 // `pixel.installed === true` is the public-surface flag mirroring 478 // that internal state â but in some builds it flips one microtask 479 // BEFORE `c.initialized` does, so a track() call right at the 480 // edge can still throw. The `sdkSettled` first-event-only settle 481 // delay covers the typical case; the retry safety net inside the 482 // low-level callSdkTrack / callSdkIdentify / callSdkIdentifyPhone 483 // wrappers closes any residual window (all three retry on 484 // "Pixel not initialized" rejections). 485 // 486 // Once ready, defer through `$mcSite.runIfOptedIn(fn)` â that's 487 // the SDK's consent gate, which reads the `mc_user_optin` cookie 488 // via hasOptedIn() and only invokes the callback if the visitor 489 // hasn't explicitly opted out (default: opted in when cookie 490 // unset). Fallback to firing immediately on the rare SDK build 491 // that doesn't expose runIfOptedIn. 492 if (window.$mcSite 493 && window.$mcSite.pixel 494 && window.$mcSite.pixel.installed === true 495 && window.$mcSite.pixel.api) { 496 var dispatch = function () { 497 sdkSettled = true; 498 if (typeof window.$mcSite.runIfOptedIn === 'function') { 499 window.$mcSite.runIfOptedIn(function () { 500 onReady(window.$mcSite.pixel.api); 501 }); 502 } else { 503 onReady(window.$mcSite.pixel.api); 504 } 505 }; 506 if (sdkSettled) { 507 dispatch(); 508 } else { 509 setTimeout(dispatch, getSdkSettleDelayMs()); 510 } 511 return; 512 } 513 if (Date.now() >= deadline) { 514 if (typeof onTimeout === 'function') { 515 onTimeout(); 516 } 517 return; 518 } 519 setTimeout(tick, 200); 520 } 521 522 tick(); 523 } 524 525 // ------------------------------------------------------------------ 526 // identifyOrReset â runs every page load, binds or clears identity 527 // ------------------------------------------------------------------ 528 529 /** 530 * Low-level: actually call api.identify({type, value}) or the SDK's 531 * clear method. Wraps the value per Mailchimp's documented identify() 532 * shape â see 533 * https://mailchimp.com/help/mailchimp-site-tracking-pixel-integration-guidance/#identify 534 * Bare-string form (which the SDK accepts client-side without throwing) 535 * causes the SDK's batched POST to /v1/track to return 400 "Invalid 536 * request data" so neither the identify nor the co-batched track 537 * events land â silently breaks all subsequent tracking until next 538 * SDK session. 539 * 540 * The SDK's "clear" convention varies by version; we try `api.reset()` 541 * first (most common), then `api.identify(null)` as a fallback. Failures 542 * are silent â if the SDK has neither, prior identification persists 543 * until the SDK's own cookie expires (acceptable bounded leak). 544 * 545 * @param {object} api the resolved $mcSite.pixel.api 546 * @param {string|null|undefined} value identify value, or falsy â reset 547 */ 548 function callSdkIdentify(api, value, attemptNum) { 549 if (typeof attemptNum !== 'number') attemptNum = 0; 550 551 // Null/reset path â best-effort, no retry, no telemetry on failure. 552 // The SDK has no documented reset API; we try api.reset() if exposed 553 // (older builds). Passing a malformed argument to api.identify 554 // would poison the batched-event context, so we DON'T fall back 555 // to that. 556 if (!value) { 557 try { 558 if (typeof api.reset === 'function') api.reset(); 559 } catch (e) { /* swallow â reset is best-effort */ } 560 return; 561 } 562 563 // Opt-out re-check on every attempt â a visitor can flip consent 564 // mid-retry (decline-button click on the merchant's CMP). The 565 // mcIdentify wrapper's initial guard only covers attempt 0; this 566 // covers retries landing through setTimeout. 567 if (isExplicitlyOptedOut()) { 568 return; 569 } 570 571 // Idempotency flag â see callSdkTrack for the rationale. The 572 // console.error hook (top of IIFE) and the .catch() on the returned 573 // Promise both feed into scheduleRetryOrFail; first signal wins. 574 var attemptResolved = false;
575 var scheduleRetryOrFail = function (errMsg) { 576 if (attemptResolved) { 577 return; 578 } 579 attemptResolved = true; 580 if (/not initialized/i.test(errMsg) && attemptNum < TRACK_RETRY_BACKOFF_MS.length) { 581 setTimeout(function () { 582 callSdkIdentify(api, value, attemptNum + 1); 583 }, TRACK_RETRY_BACKOFF_MS[attemptNum]); 584 return; 585 } 586 sendTelemetry('SDK_LOAD_FAILED', 3, 587 'identify() failed after ' + (attemptNum + 1) + ' attempt(s): ' + errMsg); 588 }; 589 590 // Register in the FIFO queue for the console.error hook to match. 591 pendingSdkAttempts.push({ 592 expiresAt: Date.now() + 500, 593 onFailed: scheduleRetryOrFail 594 }); 595 596 try { 597 if (typeof api.identify !== 'function') return; 598 // Mailchimp's documented identify() signature â {type, value}. 599 // Bare-string would cause /v1/track batched POST to return 400. 600 var p = api.identify({ 601 type: cfg.identifyFormat || 'EMAIL_SHA256', 602 value: value 603 }); 604 if (p && typeof p['catch'] === 'function') { 605 p['catch'](function (e) { 606 scheduleRetryOrFail(e && e.message ? String(e.message) : 'unknown'); 607 }); 608 } 609 } catch (e) { 610 scheduleRetryOrFail(e && e.message ? String(e.message) : 'unknown'); 611 } 612 } 613 614 /** 615 * Phone-identity counterpart of callSdkIdentify. Fires a separate 616 * api.identify({type:'PHONE_SHA256'|'PHONE', value}) so Mailchimp gets 617 * BOTH identifiers on the same anonymousId and can match incoming 618 * pixel events against subscribers by either field. 619 * 620 * Type mapping mirrors the email path: 621 * cfg.identifyFormat === 'EMAIL_SHA256' â 'PHONE_SHA256' (default, privacy-first) 622 * cfg.identifyFormat === 'EMAIL' â 'PHONE' (plain phone) 623 * 624 * Identifier type strings are UPPERCASE per Mailchimp's documented 625 * api.identify() spec â see the Mailchimp Help "Identify" reference. 626 * 627 * Phone identify is independent of email reconciliation â we don't 628 * track its own "last seen" state in localStorage. The SDK dedupes 629 * repeated identify() calls with the same value server-side, so 630 * re-firing on every logged-in pageload is safe + cheap. 631 * 632 * @param {object} api the resolved $mcSite.pixel.api 633 * @param {string|null|undefined} phoneValue raw or hashed phone value, or falsy â no-op 634 */ 635 function callSdkIdentifyPhone(api, phoneValue, attemptNum) { 636 if (!phoneValue) return; 637 if (typeof attemptNum !== 'number') attemptNum = 0; 638 639 if (isExplicitlyOptedOut()) { 640 return; 641 } 642 643 var attemptResolved = false; 644 var scheduleRetryOrFail = function (errMsg) { 645 if (attemptResolved) { 646 return; 647 } 648 attemptResolved = true; 649 if (/not initialized/i.test(errMsg) && attemptNum < TRACK_RETRY_BACKOFF_MS.length) { 650 setTimeout(function () { 651 callSdkIdentifyPhone(api, phoneValue, attemptNum + 1); 652 }, TRACK_RETRY_BACKOFF_MS[attemptNum]); 653 return; 654 } 655 sendTelemetry('SDK_LOAD_FAILED', 3, 656 'phone identify() failed after ' + (attemptNum + 1) + ' attempt(s): ' + errMsg); 657 }; 658 659 // Register in the FIFO queue for the console.error hook to match. 660 pendingSdkAttempts.push({ 661 expiresAt: Date.now() + 500, 662 onFailed: scheduleRetryOrFail 663 }); 664 665 try { 666 if (typeof api.identify !== 'function') return; 667 var emailType = cfg.identifyFormat || 'EMAIL_SHA256'; 668 var phoneType = (emailType === 'EMAIL_SHA256') ? 'PHONE_SHA256' : 'PHONE'; 669 var p = api.identify({ type: phoneType, value: phoneValue }); 670 if (p && typeof p['catch'] === 'function') { 671 p['catch'](function (e) { 672 scheduleRetryOrFail(e && e.message ? String(e.message) : 'unknown'); 673 }); 674 } 675 } catch (e) { 676 scheduleRetryOrFail(e && e.message ? String(e.message) : 'unknown'); 677 } 678 } 679 680 // ------------------------------------------------------------------ 681 // Identity reconciliation â fire identify() on every logged-in page 682 // ------------------------------------------------------------------ 683 684 // Strategy: re-assert identity on EVERY page load when logged in. The
685 // SDK is documented as idempotent on repeated identify() calls (same 686 // value â no-op server-side), and re-asserting protects against the 687 // SDK's identity cookie being lost between pages (cleared, expired, 688 // third-party-blocked). Matches GA4/Klaviyo/Segment conventions where 689 // identify() is a per-pageload contract, not a one-shot transition. 690 // 691 // - Logged-in page load â identify({type, value}) (every time) 692 // - Login transition â identify + log a "(login)" telemetry 693 // - AâB switch transition â identify + log a "(switch)" telemetry 694 // - Logout transition â reset() once + log "(logout)" telemetry 695 // - Logged-in steady â identify (silent, no log row) 696 // - Anonymous steady â no-op 697 // 698 // localStorage tracks the last identified value so we know when a 699 // transition happened (used only for log-line decoration + logout 700 // detection â not as a "skip identify" optimization anymore). 701 // 702 // Note on storage privacy: the value we store is whatever was passed 703 // to identify(), which for EMAIL_SHA256 mode is already a hash. For 704 // EMAIL mode, it's the plain email â but the merchant chose that 705 // format knowing the privacy trade-off (it's also rendered inline in 706 // HTML). 707 708 var STORAGE_KEY_LAST_IDENTIFY = 'mc_pixel_last_identify'; 709 710 function readLastIdentify() { 711 try { return window.localStorage.getItem(STORAGE_KEY_LAST_IDENTIFY); } 712 catch (e) { return null; } 713 } 714 function writeLastIdentify(v) { 715 try { 716 if (v) { 717 window.localStorage.setItem(STORAGE_KEY_LAST_IDENTIFY, v); 718 } else { 719 window.localStorage.removeItem(STORAGE_KEY_LAST_IDENTIFY); 720 } 721 } catch (e) { /* localStorage disabled â non-critical */ } 722 } 723 724 // Phone-transition bookkeeping. The email key alone can't detect 725 // "email unchanged but phone just became available" â typical guest 726 // checkout where email lands at step 1 (personal info) and phone at 727 // step 2 (address). Without this, the phone identify call would fire 728 // with no BO log entry, leaving admins blind to when enrichment landed. 729 var STORAGE_KEY_LAST_PHONE_IDENTIFY = 'mc_pixel_last_phone_identify'; 730 function readLastPhoneIdentify() { 731 try { return window.localStorage.getItem(STORAGE_KEY_LAST_PHONE_IDENTIFY); } 732 catch (e) { return null; } 733 } 734 function writeLastPhoneIdentify(v) { 735 try { 736 if (v) { 737 window.localStorage.setItem(STORAGE_KEY_LAST_PHONE_IDENTIFY, v); 738 } else { 739 window.localStorage.removeItem(STORAGE_KEY_LAST_PHONE_IDENTIFY); 740 } 741 } catch (e) { /* localStorage disabled â non-critical */ } 742 } 743 744 /** 745 * Reconcile current identify value (from server) with what the SDK was 746 * last told. Fires identify() on every logged-in page load (SDK-side 747 * dedupe handles steady state); logs telemetry only on actual login / 748 * switch / logout transitions to keep the BO log readable. 749 * 750 * Phone enrichment: when `currentPhoneValue` is provided AND email 751 * identify fires, also fire a parallel phone identify so Mailchimp's 752 * subscriber matching has both signals. No independent "phone changed" 753 * watch â if phone changes mid-session the SDK dedupes silently. 754 * 755 * @param {object} api 756 * @param {string|null|undefined} currentValue 757 * @param {string|null|undefined} currentPhoneValue optional 758 */ 759 function reconcileIdentity(api, currentValue, currentPhoneValue) { 760 var current = currentValue || ''; 761 var currentPhone = currentPhoneValue || ''; 762 var last = readLastIdentify() || ''; 763 var lastPhone = readLastPhoneIdentify() || ''; 764 var isEmailTransition = (current !== last); 765 var isPhoneTransition = (currentPhone !== lastPhone); 766 767 if (current) { 768 // Logged-in page â re-assert identity every time. SDK dedupes 769 // server-side; gain is robustness against lost SDK identity 770 // state (cookie cleared / new tab / third-party block). 771 callSdkIdentify(api, current); 772 // Phone identify is independent â fires in addition to email 773 // when a phone is available on the customer's primary address 774 // (CustomerAddressSelector picked it server-side). The SDK 775 // accepts multiple identify() calls per visitor and merges the 776 // identifiers against the same anonymousId. 777 if (currentPhone) { 778 callSdkIdentifyPhone(api, currentPhone); 779 } 780 writeLastIdentify(current); 781 writeLastPhoneIdentify(currentPhone); 782 783 // Telemetry routing per transition kind â covers all 4 cases 784 // the guest checkout funnel hits and keeps log volume bounded: 785 // 786 // email-transition (login/switch) â ONE log row, email
787 // prefix + phone prefix if phone was also captured 788 // on the same pageload (e.g., logged-in customer with 789 // an address on file). 790 // 791 // phone-only transition â ONE log row, phone 792 // prefix only. Hit when email already matched the 793 // previous pageload's email (no email transition) but 794 // phone just became available (e.g., guest mid- 795 // checkout transitioning from step 1 personal-info 796 // to step 2 address-step â email captured at step 1, 797 // phone captured at step 2). 798 // 799 // phone-only logout â ONE log row, phone 800 // cleared (rare; phone disappearing from the 801 // customer's primary address while email stays valid). 802 // 803 // no transition â silent. SDK still 804 // dedupes the api.identify calls server-side. 805 if (isEmailTransition) { 806 var kind = last ? 'switch' : 'login'; 807 var prefix = current.substring(0, 8); 808 var msg = 'identify() fired (' + kind + ') â value prefix: ' + prefix + 'â¦'; 809 if (currentPhone) { 810 msg += ', phone prefix: ' + currentPhone.substring(0, 8) + 'â¦'; 811 } 812 sendTelemetry('IDENTIFY', 1, msg); 813 } else if (isPhoneTransition) { 814 if (currentPhone) { 815 sendTelemetry('IDENTIFY', 1, 816 'phone identify() fired â phone prefix: ' + currentPhone.substring(0, 8) + 'â¦'); 817 } else if (lastPhone) { 818 sendTelemetry('IDENTIFY', 1, 819 'phone identify cleared â local bookkeeping reset (SDK cannot disassociate)'); 820 } 821 } 822 } else if (last) { 823 // Logout transition. Clear our bookkeeping (mc_pixel_last_identify) 824 // so the next login transition correctly fires identify(). 825 // 826 // Note on server-side attribution: the Mailchimp Pixel SDK 827 // exposes no documented reset/disassociate API â only init/ 828 // track/identify (verified via SDK source inspection of 829 // pixel-reporting-sdk/1.22.0). The SDK keeps anonymousId in 830 // memory after init and re-persists to localStorage on every 831 // storage interaction, so clearing intuit_pixel_sdk_user_data 832 // here doesn't work â the SDK rewrites it within milliseconds 833 // during the post-logout PAGE_VIEWED flush. Consequence: the 834 // server-side `anonymousId â customer EMAIL_SHA256` binding 835 // persists, so post-logout track events from the same browser 836 // continue to attribute to the prior customer in Mailchimp's 837 // reports until either a new identify() rebinds the visitor 838 // ID, Mailchimp's server-side association ages out, or the 839 // visitor clears their browser data. 840 callSdkIdentify(api, null); // attempts api.reset() if exposed (currently not) 841 writeLastIdentify(null); 842 // Also clear the phone bookkeeping â without this, a future 843 // login of a DIFFERENT customer would not log a phone- 844 // transition entry on first pageload if their phone happens 845 // to match the now-stale lastPhone value (false negative). 846 writeLastPhoneIdentify(null); 847 sendTelemetry('IDENTIFY', 1, 848 'logout transition â local bookkeeping cleared; SDK visitor ID persists (no reset API)'); 849 } 850 // else: anonymous + was anonymous â nothing to do. 851 } 852 853 /** 854 * Top-level identity reconcile. Two paths depending on 855 * `cfg.identifyAjaxMode`: 856 * 857 * (A) Default â INLINE: `cfg.identifyValue` is the truth. Pure local 858 * compare against last stored â zero extra HTTP, zero added 859 * latency unless a transition actually fires. 860 * 861 * (B) Cache-safe â AJAX: POST to `cfg.customerInfoUrl` to learn the 862 * current value (server-side authoritative; cached HTML can't 863 * leak a value across visitors). Used only when the merchant 864 * explicitly opted into cache-safe mode in BO Pixel tab. 865 * 866 * @param {object} api the resolved $mcSite.pixel.api 867 */ 868 function identifyOrReset(api) { 869 if (!cfg.identifyAjaxMode) { 870 reconcileIdentity(api, cfg.identifyValue, cfg.phoneIdentifyValue); 871 return; 872 } 873
874 // AJAX path. POST verb chosen so cache-everything CDNs treat the 875 // response as always-fresh (HTTP semantics: POST changes state). 876 // credentials:'same-origin' carries the PS session cookie so the 877 // server identifies the visitor authoritatively per-request. 878 if (!cfg.customerInfoUrl) { 879 return; 880 } 881 try { 882 window.fetch(cfg.customerInfoUrl, { 883 method: 'POST', 884 credentials: 'same-origin', 885 headers: { 'Content-Type': 'application/json' }, 886 body: '{}', 887 cache: 'no-store' 888 }).then(function (res) { 889 return res.ok ? res.json() : null; 890 }).then(function (data) { 891 reconcileIdentity( 892 api, 893 data && data.value ? data.value : null, 894 data && data.phoneValue ? data.phoneValue : null 895 ); 896 })['catch'](function () { 897 // Network failure / 5xx â don't break the bridge. SDK keeps 898 // any prior identification until its own cookie expires. 899 // Next page load retries. 900 }); 901 } catch (e) { 902 // fetch unavailable â silently skip in cache-safe mode. 903 } 904 } 905 906 // ------------------------------------------------------------------ 907 // mcTrack / mcIdentify â guarded wrappers (events come in next commit) 908 // ------------------------------------------------------------------ 909 910 /** 911 * Compose a context-rich telemetry message line for track() calls. 912 * Includes a leading status verb plus pipe-separated context tokens 913 * summarizing the payload sent to api.track() â so support engineers 914 * can answer "what did we send to Mailchimp?" by reading the log, 915 * without needing to dump full payloads (which would risk PII). 916 * 917 * Per-event-type summarizers: 918 * PRODUCT_VIEWED | source | product=pid/variant=vid | price | sku | "title" 919 * PRODUCT_ADDED_TO_CART | source | product=⦠| qty | price | "title" 920 * CART_VIEWED | source | items=N | total 921 * CHECKOUT_STARTED | source | items=N | total | customer=<hashed-prefix> 922 * PURCHASED | source | order=oid | items=N | total | customer=<hashed-prefix> 923 * 924 * Privacy guardrails: 925 * - Customer email is NEVER included (only the SHA-256 hashed prefix 926 * used for Mailchimp's identify() â 8 chars is non-reversible). 927 * - Cart line-item details are NOT enumerated; only the count. 928 * - Title strings are truncated to 30 chars. 929 * 930 * @param {string} prefix status verb, e.g. "api.track() fired" 931 * @param {string} [source] tag from the caller (see fireOnce) 932 * @param {object} [props] event payload (per Mailchimp 3P SDK schema) 933 * @return {string} composed message, safe within MAX_MESSAGE_LEN (4000) 934 */ 935 function formatTrackTelemetry(prefix, source, via, props) { 936 var parts = [prefix]; 937 if (source) parts.push('source=' + source); 938 if (via) parts.push('via=' + via); 939 if (!props) return parts.join(' | '); 940 941 // PRODUCT_VIEWED, PRODUCT_ADDED_TO_CART (single-product events) 942 if (props.product) { 943 var p = props.product.item || props.product; // PRODUCT_ADDED_TO_CART nests as product.item 944 var pidPart = 'product=' + (p.productId || '?'); 945 if (p.id && String(p.id) !== String(p.productId)) { 946 pidPart += '/variant=' + p.id; 947 } 948 parts.push(pidPart); 949 if (typeof p.price === 'number' && p.price > 0) { 950 parts.push('price=' + p.price + (p.currency ? ' ' + p.currency : '')); 951 } 952 // PRODUCT_ADDED_TO_CART nests quantity, line total + currency inside 953 // the product object (different from PRODUCT_VIEWED's flat shape). 954 if (props.product.quantity) parts.push('qty=' + props.product.quantity); 955 // Show line total (price à qty) only when it differs from unit 956 // price (i.e., qty > 1). Saves a redundant chip on qty=1 events. 957 if (typeof props.product.price === 'number' && props.product.price > 0 958 && props.product.price !== p.price) { 959 parts.push('total=' + props.product.price 960 + (props.product.currency ? ' ' + props.product.currency : '')); 961 } 962 if (p.sku) parts.push('sku=' + p.sku); 963 if (p.title) parts.push('"' + String(p.title).slice(0, 30
963) + '"'); 964 } 965 // CART_VIEWED, CHECKOUT_STARTED, PURCHASED (multi-item events) 966 else if (props.cart || props.checkout || props.order) { 967 var c = props.cart || props.checkout || props.order; 968 if (c.id) parts.push((props.order ? 'order=' : 'cart=') + c.id); 969 if (c.lineItems && c.lineItems.length) parts.push('items=' + c.lineItems.length); 970 if (typeof c.totalPrice === 'number') { 971 parts.push('total=' + c.totalPrice + (c.currency ? ' ' + c.currency : '')); 972 } 973 if (c.customerId) { 974 // Only the first 8 chars of the (hashed) customer id â 975 // enough to correlate with identify() entries but not 976 // enough to reverse-engineer. 977 parts.push('customer=' + String(c.customerId).slice(0, 8)); 978 } 979 } 980 981 // Full payload audit at the tail. Mailchimp's api.track() is fire- 982 // and-forget (no callback, no return value), so the payload we 983 // generated client-side is the ONLY record of "what we sent to 984 // Mailchimp." Without this, a merchant comparing our log to 985 // Mailchimp's reports has no way to verify the data was correct 986 // at our side. The actual api.track() call always uses the FULL 987 // untruncated payload â only the log copy is bounded. 988 // 989 // The naive approach (slice the final JSON string at the byte cap) 990 // leaves the BO viewer with unparseable JSON at the tail, which 991 // breaks "pretty-print payload" and forces support to guess at 992 // what was sent. We instead progressively shrink individual fields 993 // (longest first: imageUrl, productUrl, title, vendor, categories) 994 // until the payload JSON fits the remaining budget â so the stored 995 // JSON is ALWAYS valid and parseable. 996 var prefixMessage = parts.join(' | '); 997 var separator = ' | payload='; 998 // Reserve a small safety margin (16) for separator + any UTF-8 size 999 // mismatch between string length and byte length the server cares about. 1000 var budget = MAX_MESSAGE_LEN - prefixMessage.length - separator.length - 16; 1001 if (budget > 0) { 1002 var jsonStr = buildBoundedPayloadJson(props, budget); 1003 if (jsonStr !== null) { 1004 return prefixMessage + separator + jsonStr; 1005 } 1006 } 1007 return prefixMessage; 1008 } 1009 1010 /** 1011 * Stringify `payload` to JSON guaranteed valid and ⤠`budget` chars. 1012 * Strategy: try full JSON; if it overflows, deep-clone and progressively 1013 * shrink fields in priority order. The final result is always parseable 1014 * (or `null` if even the bare-minimum form overflows the budget â which 1015 * shouldn't happen at our MAX_MESSAGE_LEN, but we degrade gracefully). 1016 * 1017 * Truncation order (longest fields first; suffix `â¦` marks shortening): 1018 * 1. imageUrl â 120 chars (CDN URLs can be very long) 1019 * 2. productUrl â 150 chars 1020 * 3. categories[] â 5 entries 1021 * 4. title â 60 chars 1022 * 5. vendor â 40 chars 1023 * 6. imageUrl â dropped entirely 1024 * 7. productUrl â dropped entirely 1025 * 8. categories[] â dropped entirely 1026 * 1027 * For cart/order shapes with many lineItems, lineItems gets truncated 1028 * separately (lineItems[].slice(0, 10) then [].slice(0, 5) then []). 1029 * 1030 * @param {object} payload 1031 * @param {number} budget max chars for the resulting JSON string 1032 * @return {string|null} 1033 */ 1034 function buildBoundedPayloadJson(payload, budget) { 1035 var full; 1036 try { 1037 full = JSON.stringify(payload); 1038 } catch (e) { 1039 return null; 1040 } 1041 if (full.length <= budget) { 1042 return full; 1043 } 1044 1045 // Need to shrink. Work on a deep clone so the actual api.track() call 1046 // keeps the untruncated payload. 1047 var clone; 1048 try { 1049 clone = JSON.parse(full); 1050 } catch (e) { 1051 return null; // JSON.stringify succeeded but JSON.parse failed â give up 1052 } 1053 1054 // Locate the truncation target (product item for single-product events, 1055 // cart/checkout/order container for multi-item events). 1056 var productTarget = null; 1057 if (clone.product) { 1058 // PRODUCT_ADDED_TO_CART wraps as { product: { item: {...}, quantity } }; 1059 // PRODUCT_VIEWED is { product: {...} } directly. 1060 productTarget = clone.product.item || clone.product; 1061 } 1062 var containerTarget = clone.cart || clone.checkout || clone.order || null; 1063 1064 function trimStringField(obj, field, maxLen) { 1065 if (!obj || typeof obj[field] !== 'string') return; 1066 if (obj[field].length > maxLen) { 1067 obj[field] = obj[field].slice(0, maxLen - 1) + 'â¦'; 1068 } 1069 } 1070 function trimArrayField(obj, field, maxLen) { 1071 if (!obj || !obj[field] || typeof obj[field].length !== 'number') return; 1072 if (obj[field].length > maxLen) { 1073 obj[field] = obj[field].slice(0, maxLen); 1074 } 1075 } 1076 function dropField(obj, field) { 1077 if (obj && field in obj) { 1078 delete obj[field]; 1079 } 1080 } 1081 function tryFit() { 1082 try { 1083 var s = JSON.stringify(clone); 1084 return s.length <= budget ? s : null; 1085 } catch (e) { 1086 return null; 1087 } 1088 } 1089 1090 var steps = [ 1091 function () { trimStringField(productTarget, 'imageUrl', 120); }, 1092 function () { trimStringField(productTarget, 'productUrl', 150); }, 1093 function () { trimArrayField(productTarget, 'categories', 5); }, 1094 function () { trimArrayField(containerTarget, 'lineItems', 10); }, 1095 function () { trimStringField(productTarget, 'title', 60); }, 1096 function () { trimStringField(productTarget, 'vendor', 40); }, 1097 function () { trimArrayField(containerTarget, 'lineItems', 5); }, 1098 function () { dropField(productTarget, 'imageUrl'); }, 1099 function () { dropField(productTarget, 'productUrl'); }, 1100 function () { dropField(productTarget, 'categories'); }, 1101 function () { dropField(productTarget, 'vendor'); }, 1102 function () { trimArrayField(containerTarget, 'lineItems', 0); } 1103 ]; 1104 for (var i = 0; i < steps.length; i++) { 1105 steps[i](); 1106 var fitted = tryFit(); 1107 if (fitted !== null) return fitted; 1108 } 1109 1110 // Last-resort minimal form: just the IDs. 1111 var minimal = {}; 1112 if (productTarget) { 1113 if (productTarget.id) minimal.product = { id: productTarget.id }; 1114 if (productTarget.productId) { 1115 minimal.product = minimal.product || {}; 1116 minimal.product.productId = productTarget.productId; 1117 } 1118 } else if (containerTarget) { 1119 var key = clone.cart ? 'cart' : (clone.checkout ? 'checkout' : 'order'); 1120 minimal[key] = {}; 1121 if (containerTarget.id) minimal[key].id = containerTarget.id; 1122 } 1123 try { 1124 var minimalJson = JSON.stringify(minimal); 1125 return minimalJson.length <= budget ? minimalJson : null; 1126 } catch (e) { 1127 return null; 1128 } 1129 } 1130 1131 /** 1132 * Call $mcSite.pixel.api.track(name, props) when the SDK is ready. 1133 * Silently swallows failures; reports SDK_LOAD_FAILED telemetry once 1134 * if the SDK refused the call. 1135 * 1136 * Success-trail telemetry per log level (three modes â server-side 1137 * PixelLog::insert filter is authoritative either way; this JS-side 1138 * gating skips the HTTP send when PHP would just drop the row): 1139 * 1140 * - 'verbose' : emit full payload (event name + source + via + 1141 * props) so support can see exactly what fired. 1142 * - 'default' : emit a compact one-liner ('Track fired') so 1143 * merchants get tracking-confirmation visibility 1144 * without payload noise in the BO log. 1145 * - 'errors_only' : skip the send entirely. PHP would drop the row 1146 * anyway (track events aren't in the always-log 1147 * allowlist) â saving the FO HTTP round-trip. 1148 * 1149 * `source` is a short tag (e.g. "initial", "combination", "quickview",
1150 * "hook") identifying which caller triggered the track. Surfaced in the 1151 * telemetry message so merchants + support can tell at a glance whether 1152 * an event came from an initial page load vs a combination switch. 1153 * 1154 * @param {string} eventName e.g. PRODUCT_VIEWED, PRODUCT_ADDED_TO_CART 1155 * @param {object} [props] event payload â see Mailchimp 3P guide 1156 * @param {string} [source] short trigger tag â what the customer did 1157 * (initial / combination / quickview) 1158 * @param {string} [via] technical code path that fired this â useful 1159 * for support to know whether the event came 1160 * from the actionFrontControllerSetMedia JsDef 1161 * (`jsdef`), the displayProductAdditionalInfo 1162 * hook (`hook`), or a prestashop.on(...) JS 1163 * listener (`js-update`, `js-quickview`). 1164 */ 1165 // Retry budget for the "Pixel not initialized" race window. The 1166 // whenPixelReady gate (pixel.installed === true) catches the bulk of 1167 // it, but the SDK's internal c.initialized flag can lag by one 1168 // microtask in some builds. Each entry is the delay BEFORE attempt 1169 // n+1; total wall-clock budget ~3.75s. 1170 var TRACK_RETRY_BACKOFF_MS = [250, 500, 1000, 2000]; 1171 1172 function callSdkTrack(api, eventName, props, source, via, attemptNum) { 1173 if (typeof attemptNum !== 'number') attemptNum = 0; 1174 1175 // Opt-out re-check on every attempt. A visitor can flip consent 1176 // mid-retry (decline-button click on the merchant's CMP) â 1177 // without this guard, scheduled retries would fire api.track() 1178 // after the cookie already says `false`. mcTrack's initial guard 1179 // only covers attempt 0; this covers retries via setTimeout. 1180 if (isExplicitlyOptedOut()) { 1181 return; 1182 } 1183 1184 // Idempotency flag â the console.error hook (top of IIFE) and the 1185 // .catch() on the returned Promise can both fire scheduleRetryOrFail 1186 // for the same attempt. Whichever signal arrives first wins; later 1187 // signals are silently dropped to prevent double retry + double 1188 // telemetry. Also gates the deferred success telemetry below: if 1189 // we resolved as "failed" we must not log "Track fired". 1190 var attemptResolved = false; 1191 var scheduleRetryOrFail = function (errMsg) { 1192 if (attemptResolved) { 1193 return; 1194 } 1195 attemptResolved = true; 1196 if (/not initialized/i.test(errMsg) && attemptNum < TRACK_RETRY_BACKOFF_MS.length) { 1197 setTimeout(function () { 1198 callSdkTrack(api, eventName, props, source, via, attemptNum + 1); 1199 }, TRACK_RETRY_BACKOFF_MS[attemptNum]); 1200 return; 1201 } 1202 sendTelemetry('SDK_LOAD_FAILED', 3, 1203 formatTrackTelemetry('track(' + eventName + ') failed after ' 1204 + (attemptNum + 1) + ' attempt(s): ' + errMsg, source, via, props)); 1205 }; 1206 1207 // Register this dispatch in the FIFO queue so the console.error hook 1208 // can match a subsequent "Pixel not initialized" log line to it. 1209 // 500 ms expiry covers the SDK's await chain latency from track() 1210 // entry to logger.error â successful dispatches age out harmlessly. 1211 pendingSdkAttempts.push({ 1212 expiresAt: Date.now() + 500, 1213 onFailed: scheduleRetryOrFail 1214 }); 1215 1216 try { 1217 var p = api.track(eventName, props || {}); 1218 if (p && typeof p['catch'] === 'function') { 1219 p['catch'](function (e) { 1220 scheduleRetryOrFail(e && e.message ? String(e.message) : 'unknown'); 1221 }); 1222 } 1223 // Track-event success telemetry â three modes: 1224 // - 'verbose' : full payload (source + via + props) 1225 // - 'default' : compact one-liner ('Track fired')
1226 // - 'errors_only' : no telemetry sent (avoid FO HTTP overhead) 1227 // PHP-side PixelLog::insert filter is authoritative either way; 1228 // we skip the network call at errors_only since PHP drops the 1229 // row anyway (track events aren't in $alwaysLogEventTypes). 1230 // 1231 // Deferred to ~500 ms after the api.track() return so the 1232 // attemptResolved gate has time to flip if the SDK silently 1233 // catches "Pixel not initialized" (we only log success when 1234 // the call actually went through). 1235 if (cfg.pixelLogLevel === 'verbose' || cfg.pixelLogLevel === 'default') { 1236 setTimeout(function () { 1237 if (attemptResolved) { 1238 return; 1239 } 1240 if (cfg.pixelLogLevel === 'verbose') { 1241 var msg = attemptNum > 0 1242 ? 'api.track() fired (retry ' + attemptNum + ')' 1243 : 'api.track() fired'; 1244 sendTelemetry(eventName, 1, 1245 formatTrackTelemetry(msg, source, via, props)); 1246 } else { 1247 sendTelemetry(eventName, 1, 1248 attemptNum > 0 ? 'Track fired (retry ' + attemptNum + ')' : 'Track fired'); 1249 } 1250 }, 500); 1251 } 1252 } catch (e) { 1253 scheduleRetryOrFail(e && e.message ? String(e.message) : 'unknown'); 1254 } 1255 } 1256 1257 function mcTrack(eventName, props, source, via) { 1258 // Explicit opt-out short-circuit: skip polling AND telemetry. The 1259 // SDK won't init pixel.api when mc_user_optin=false, so whenPixelReady 1260 // would just time out and fire misleading "SDK never appeared". 1261 if (isExplicitlyOptedOut()) { 1262 return; 1263 } 1264 whenPixelReady(function (api) { 1265 callSdkTrack(api, eventName, props, source, via); 1266 }, function () { 1267 sendTelemetry('SDK_TIMEOUT', 2, 1268 formatTrackTelemetry('track(' + eventName + ') aborted â SDK never appeared', source, via, props)); 1269 }); 1270 } 1271 1272 function mcIdentify(emailOrHash) { 1273 // Explicit opt-out short-circuit â see mcTrack note above. 1274 if (isExplicitlyOptedOut()) { 1275 return; 1276 } 1277 whenPixelReady(function (api) { 1278 callSdkIdentify(api, emailOrHash); 1279 }, function () { 1280 sendTelemetry('SDK_TIMEOUT', 2, 'identify() aborted â SDK never appeared'); 1281 }); 1282 } 1283 1284 /** 1285 * Phone-identify counterpart of mcIdentify. Same SDK-ready gate, same 1286 * opt-out short-circuit; the type sent to api.identify({type, value}) 1287 * follows the email format toggle (EMAIL_SHA256 â 'PHONE_SHA256'; 1288 * EMAIL â 'PHONE'). Exposed on window.MC_PIXEL_BRIDGE so the OPC 1289 * checkout watcher (mc-pixel-bridge-checkout.js) can fire phone 1290 * identify alongside email when pixelcartinfo returns phoneValue. 1291 * 1292 * Internally delegates to callSdkIdentifyPhone â same telemetry + 1293 * async-rejection wiring as callSdkIdentify but with the phone 1294 * type mapping. 1295 * 1296 * @param {string} phoneOrHash 1297 */ 1298 function mcIdentifyPhone(phoneOrHash) { 1299 if (isExplicitlyOptedOut()) { 1300 return; 1301 } 1302 whenPixelReady(function (api) { 1303 callSdkIdentifyPhone(api, phoneOrHash); 1304 }, function () { 1305 sendTelemetry('SDK_TIMEOUT', 2, 'phone identify() aborted â SDK never appeared'); 1306 }); 1307 } 1308 1309 // ------------------------------------------------------------------ 1310 // Initial verdict probe â throttled (5 min per browser via localStorage) 1311 // ------------------------------------------------------------------ 1312 1313 // The verdict probe is a self-diagnostic that updates MAILCHIMP_PIXEL_AVAILABLE 1314 // on the server (which the BO Pixel tab reads). It does NOT need to fire on 1315 // every page load â once we know the state, we just need it to refresh 1316 // occasionally to catch a change (Mailchimp enables/disables pixel, 1317 // chimpstatic outage, etc.). 1318 // 1319 // Throttle: 5 minutes per browser via localStorage. A single shopper 1320 // navigating multiple pages fires at most one verdict POST. An admin 1321 // testing in their own browser sees fresh data within 5 min after reload. 1322 // 1323 // Defense-in-depth: the FO endpoint also dedupes verdict events server-side 1324 // (same event_type + IP within 5 min â no log insert). So even if a browser 1325 // has localStorage disabled and we fail open here, the server still bounds 1326 // the write rate. 1327 // 1328 // Three possible verdicts on timeout, each diagnostically distinct: 1329 // - PIXEL_AVAILABLE : SDK ready, pixel.api exposed â AUTHORITATIVE YES 1330 // - PIXEL_NOT_AVAILABLE : $mcSite loaded but pixel.api absent â AUTHORITATIVE NO 1331 // (almost always means the account isn't pixel-enabled) 1332 // - SDK_LOAD_FAILED : $mcSite never appeared (chimpstatic blocked / 1333 // ad blocker / network failure) â INCONCLUSIVE 1334 // (one visitor's network issue, doesn't say 1335 // anything about merchant's account) 1336 // Only the first two update MAILCHIMP_PIXEL_AVAILABLE on the server. The 1337 // third is logged for diagnosis but doesn't move the BO panel's verdict. 1338 1339 var VERDICT_THROTTLE_MS = 5 * 60 * 1000; // 5 minutes 1340 var VERDICT_STORAGE_KEY = 'mc_pixel_verdict_at'; 1341 1342 function shouldRunVerdictProbe() { 1343 try { 1344 var stored = window.localStorage.getItem(VERDICT_STORAGE_KEY); 1345 if (!stored) return true; 1346 var ts = parseInt(stored, 10); 1347 if (isNaN(ts)) return true; 1348 return (Date.now() - ts) >= VERDICT_THROTTLE_MS; 1349 } catch (e) { 1350 // localStorage disabled (private mode, security policy) â fail open. 1351 // Server-side dedupe in pixeltelemetry.php prevents log spam. 1352 return true; 1353 } 1354 } 1355 1356 function markVerdictReported() { 1357 try { 1358 window.localStorage.setItem(VERDICT_STORAGE_KEY, Str
1358ing(Date.now())); 1359 } catch (e) { 1360 // localStorage write failed â silently ignore. Worst case: we 1361 // re-probe next page load. 1362 } 1363 } 1364 1365 // Single polling loop that does TWO things on the same SDK-ready event: 1366 // 1. Reconcile identification state (every page load â never throttled) 1367 // 2. Fire verdict probe + telemetry (throttled to once per 5 min) 1368 // 1369 // Combining into one whenPixelReady call avoids two competing polling 1370 // loops both checking the same $mcSite.pixel.api condition. 1371 var doVerdictProbe = shouldRunVerdictProbe(); 1372 // Explicit opt-out short-circuit: when mc_user_optin=false, the SDK 1373 // refuses to expose pixel.api regardless of whether the merchant's 1374 // account has Pixel enabled. Running the verdict probe here would 1375 // misattribute the visitor's consent state to an account-level 1376 // capability problem (one opted-out visitor would flip the BO panel 1377 // from green to red via the PIXEL_NOT_AVAILABLE telemetry). Skip the 1378 // entire probe + identifyOrReset pipeline; mark reported so we don't 1379 // pile up throttle attempts on every page load. 1380 if (isExplicitlyOptedOut()) { 1381 if (doVerdictProbe) { 1382 markVerdictReported(); 1383 } 1384 } else { 1385 whenPixelReady( 1386 function (api) { 1387 // (1) Always: align SDK identity with current login state. 1388 identifyOrReset(api); 1389 1390 // (2) Throttled: report PIXEL_AVAILABLE verdict. 1391 if (doVerdictProbe) { 1392 sendTelemetry('PIXEL_AVAILABLE', 1, 'pixel.api ready on first page load'); 1393 markVerdictReported(); 1394 } 1395 }, 1396 function () { 1397 // Timeout path: SDK never became ready. identifyOrReset can't run 1398 // (no api), but verdict-probe diagnostics still apply. 1399 if (doVerdictProbe) { 1400 if (!window.$mcSite) { 1401 sendTelemetry('SDK_LOAD_FAILED', 3, 1402 '$mcSite never appeared â chimpstatic blocked or load failed'); 1403 } else if (!window.$mcSite.pixel || !window.$mcSite.pixel.api) { 1404 sendTelemetry('PIXEL_NOT_AVAILABLE', 2, 1405 '$mcSite loaded but pixel.api not exposed â account likely not pixel-enabled'); 1406 } else { 1407 // Theoretically unreachable â the readiness check above 1408 // would have called onReady. Kept for defense. 1409 sendTelemetry('SDK_TIMEOUT', 2, 'unexpected: pixel.api present but timeout fired'); 1410 } 1411 markVerdictReported(); 1412 } 1413 } 1414 ); 1415 } 1416 1417 // ================================================================== 1418 // Shared event-fire infrastructure 1419 // 1420 // Used by all event types (PRODUCT_VIEWED in mc-pixel-bridge-product.js, 1421 // future CART_VIEWED / CHECKOUT_STARTED / PURCHASED / PRODUCT_ADDED_TO_CART 1422 // in their own per-event JS files). Lives in core so every page has the 1423 // queue + dedupe ready regardless of which event-specific file loads. 1424 // 1425 // Component | Purpose 1426 // --------------------------------+----------------------------------- 1427 // firedPixelEvents + fireOnce() | Page-local Set keyed by 1428 // | "<event_type>:<fingerprint>". Same 1429 // | event with same fingerprint fires 1430 // | only once per page-load â lets us 1431 // | safely wire belt-and-suspenders 1432 // | redundant sources for the same 1433 // | logical event. 1434 // MC_PIXEL_EVENTS_QUEUE drain | Drains payloads PHP hooks pushed 1435 // + push interceptor | before the bridge loaded, then 1436 // | replaces .push() with an immediate- 1437 // | fire interceptor so late-arriving 1438 // | inline scripts (e.g., 1439 // | displayProductAdditionalInfo 1440 // | re-injected on combination change 1441 // | via jQuery .replaceWith()) fire 1442 // | through the same path.
1443 // onQuickviewOpened | Hummingbird quickview firing â 1444 // | quickviews open from category / 1445 // | search / listing pages too (not 1446 // | just product pages), so this lives 1447 // | in core, not in the product-page- 1448 // | only file. 1449 // ================================================================== 1450 1451 var firedPixelEvents = {}; 1452 1453 /** 1454 * Fire `eventType` with `payload` through mcTrack, but only if this 1455 * (eventType, fingerprint) pair hasn't fired yet on this page-load. 1456 * Repeated calls with the same fingerprint are silently dropped. 1457 * 1458 * `source` is a short trigger tag (e.g. "initial", "combination", 1459 * "quickview", "hook") that mcTrack surfaces in telemetry messages so 1460 * merchants + support can tell at a glance what caused the fire. The 1461 * FIRST call to fireOnce for a given fingerprint owns the source tag; 1462 * subsequent deduped attempts log their OWN source at verbose so the 1463 * full causal chain is visible if needed. 1464 * 1465 * @param {string} eventType Mailchimp event name (e.g., 'PRODUCT_VIEWED') 1466 * @param {object} payload event payload (passed verbatim to api.track) 1467 * @param {string} fingerprint unique key â typically the product.id 1468 * @param {string} [source] trigger tag â see above 1469 */ 1470 function fireOnce(eventType, payload, fingerprint, source, via) { 1471 var key = eventType + ':' + (fingerprint || ''); 1472 if (firedPixelEvents[key]) { 1473 // Silent dedupe by design â a PIXEL_EVENT_DEDUPED telemetry here 1474 // produces a confusing "logged: false" ping per combination change 1475 // (the event isn't whitelisted server-side). The winning path's 1476 // log entry already shows the source; no redundant signal needed. 1477 return; 1478 } 1479 firedPixelEvents[key] = { ts: Date.now(), source: source || null, via: via || null }; 1480 mcTrack(eventType, payload, source, via); 1481 } 1482 1483 // NOTE: Session-scoped dedupe was considered for CART_VIEWED + CHECKOUT_ 1484 // STARTED (so refresh of /cart or /order within the same tab session 1485 // doesn't refire the event). Deferred â Mailchimp's analytics platform 1486 // dedupes events server-side via their own visitor correlation, so 1487 // client-side dedupe was over-engineering with untested edge cases 1488 // (private browsing, identity transitions, post-purchase reset). 1489 1490 /** 1491 * Queue interceptor â drains any events that PHP hooks already pushed 1492 * before the bridge JS loaded, then replaces the array with an object 1493 * whose .push() fires immediately (so late-arriving inline scripts â 1494 * e.g., displayProductAdditionalInfo re-injected on combination change 1495 * via jQuery .replaceWith() â fire through the same code path). 1496 * 1497 * Same pattern as Google Analytics' dataLayer.push interceptor. 1498 */ 1499 function installEventQueueInterceptor() { 1500 var pre = window.MC_PIXEL_EVENTS_QUEUE; 1501 window.MC_PIXEL_EVENTS_QUEUE = { 1502 push: function (evt) { handleQueuedEvent(evt); } 1503 }; 1504 if (pre && pre.length) { 1505 for (var i = 0; i < pre.length; i++) { 1506 handleQueuedEvent(pre[i]); 1507 } 1508 } 1509 } 1510 1511 function handleQueuedEvent(evt) { 1512 if (!evt || typeof evt !== 'object' || !evt.event_type) return; 1513 var name = evt.event_type; 1514 // Pull the optional `_source` hint the PHP hook sets based on 1515 // request context (quickview AJAX vs combination-refresh AJAX vs 1516 // initial render). Strip it from the payload before forwarding to 1517 // api.track â Mailchimp wouldn't recognize it. 1518 var source = evt._source || 'hook'; 1519 // Anything that reached us via the queue came from a server-side 1520 // hook outputting an inline <script>. Tag the `via` so support can 1521 // tell this apart from the cfg.initialProduct (JsDef) path. 1522 var via = evt._via || 'hook'; 1523 var payload = {}; 1524 for (var k in evt) { 1525 if (k !== 'event_type' 1526 && k !== '_source' 1527 && k !== '_via'
1528 && Object.prototype.hasOwnProperty.call(evt, k)) { 1529 payload[k] = evt[k]; 1530 } 1531 } 1532 // For PRODUCT_VIEWED + PURCHASED + similar events with a wrapped 1533 // object (product / order), use the wrapped object's id as the 1534 // fingerprint. Fallback to JSON for events with no clear key. 1535 var fp = ''; 1536 if (payload.product && payload.product.id) fp = String(payload.product.id); 1537 else if (payload.order && payload.order.id) fp = String(payload.order.id); 1538 else if (payload.cart && payload.cart.id) fp = String(payload.cart.id); 1539 else fp = JSON.stringify(payload).slice(0, 100); 1540 fireOnce(name, payload, fp, source, via); 1541 } 1542 1543 /** 1544 * Handler for `prestashop.on('quickviewOpened')` â hummingbird's 1545 * quickview modal doesn't call the displayProductAdditionalInfo hook 1546 * (its quickview.tpl has no module hooks). DOM scraping picks up 1547 * id_product / id_product_attribute from the modal's data attributes 1548 * and price/title from schema.org microdata. Skips with telemetry if 1549 * any required field is missing. 1550 */ 1551 // Dedupe timestamp â both `prestashop.on('quickviewOpened')` AND the 1552 // jQuery `shown.bs.modal` delegate route here. On hummingbird both fire 1553 // for the same modal open (hummingbird's quickview.ts emits 1554 // quickviewOpened from inside its own shown.bs.modal handler). Without 1555 // this guard, PRODUCT_VIEWED would track twice per quickview on 1556 // hummingbird. 500ms window is well above the inter-listener delay 1557 // (microseconds) but well below any realistic interval between two 1558 // genuinely separate quickview opens by the visitor. 1559 var lastQuickviewOpenedAt = 0; 1560 1561 function onQuickviewOpened() { 1562 if (Date.now() - lastQuickviewOpenedAt < 500) return; 1563 lastQuickviewOpenedAt = Date.now(); 1564 // Same rationale as onCartUpdate: opted-out visitors should never 1565 // produce visitor-activity telemetry (QUICKVIEW_DATA_INCOMPLETE 1566 // mentions the product context). Bail before any work. 1567 if (isExplicitlyOptedOut()) { 1568 return; 1569 } 1570 var modal = document.querySelector('.modal.show[id^="quickview-modal-"]') 1571 || document.querySelector('[id^="quickview-modal-"]'); 1572 if (!modal) { 1573 sendTelemetry('QUICKVIEW_DATA_INCOMPLETE', 1, 'quickview modal element not found'); 1574 return; 1575 } 1576 var pid = modal.dataset ? modal.dataset.idProduct : null; 1577 var paid = modal.dataset ? (modal.dataset.idProductAttribute || '') : ''; 1578 if (!pid) { 1579 // Some themes encode IDs in the modal's id attribute: 1580 // id="quickview-modal-{pid}-{paid}" 1581 var m = String(modal.id || '').match(/^quickview-modal-(\d+)(?:-(\d+))?/); 1582 if (m) { 1583 pid = pid || m[1]; 1584 if (!paid && m[2]) paid = m[2]; 1585 } 1586 } 1587 if (!pid) { 1588 sendTelemetry('QUICKVIEW_DATA_INCOMPLETE', 1, 'product id absent on quickview modal'); 1589 return; 1590 } 1591 var titleEl = modal.querySelector('[itemprop="name"], .product__name, .product-name, h1'); 1592 var priceEl = modal.querySelector('[itemprop="price"]'); 1593 var currencyEl = modal.querySelector('[itemprop="priceCurrency"]'); 1594 var skuEl = modal.querySelector('[itemprop="sku"]'); 1595 // Quickview cover image â try schema.org first, then common selectors. 1596 // Hummingbird modal uses .product__cover, .product__image, .product__left img 1597 // (no [itemprop="image"]). Classic uses .product-cover img. 1598 var imageEl = modal.querySelector('[itemprop="image"]') 1599 || modal.querySelector( 1600 '.product-cover img, ' 1601 + '.product__image-cover img, ' 1602 + '.product-images__cover img, ' 1603 + 'img.js-qv-product-cover, ' 1604 + '.js-qv-product-images img, ' 1605 + '.product__cover img, ' 1606 + '.product__left img.img-fluid'); 1607 // "View full product" link â quickview footer usually has one. Filter 1608 // by href shape matching PS's product URL pattern so we skip stray <a>s. 1609 var fullProductLink = null; 1610 var allLinks = modal.querySelectorAll('a[href]'); 1611 for (var li = 0; li < allLinks.length; li++) { 1612 var href = allLinks[li].getAttribute('href') || ''; 1613 if (/\/\d+-\d+-/.test(href) || /\/\d+-[a-z0-9][a-z0-9-]*\.html/i.test(href)) { 1614 fullProductLink = href; 1615 break; 1616 } 1617 } 1618 1619 var title = titleEl ? String(titleEl.textContent).replace(/\s+/g, ' ').trim() : ''; 1620 // price = null distinguishes "extraction failed" from "genuinely 0" 1621 // (free product); the validator rejects null â triggers AJAX cascade. 1622 var price = null; 1623 if (priceEl) { 1624 var content = priceEl.getAttribute('content'); 1625 var pmicro = content ? parseFloat(content) 1626 : parseFloat(String(priceEl.textContent).replace(/[^0-9.\-]/g, '')); 1627 if (!isNaN(pmicro)) price = pmicro; 1628 } 1629 if (price === null || price <= 0) { 1630 // Hummingbird quickview has no [itemprop="price"] â class-based 1631 // markup only. Try the visible "current price" block first 1632 // (current price after discount); falls back to regular price. 1633 var fallbackPriceEl = modal.querySelector( 1634 '.current-price, ' 1635 + '.product__price, ' 1636 + '.product-price, ' 1637 + '.product__discount-price .product__prices-inline:not(.product__regular-price), ' 1638 + '.product__prices-inline.product__prices-inline--small-gap:not(.product__regular-price)' 1639 ) || modal.querySelector('.product__regular-price, .product__prices'); 1640 if (fallbackPriceEl) { 1641 // Price text often looks like "Price: â¬22.94" or "From â¬22.94". 1642 // Strip everything that isn't a digit / dot / comma / minus, 1643 // then normalize locale separators (1.234,56 â 1234.56). 1644 var raw = String(fallbackPriceEl.textContent || '').replace(/[^0-9.,\-]/g, ''); 1645 var dotIx = raw.lastIndexOf('.'); 1646 var commaIx = raw.lastIndexOf(','); 1647 var decimalSep = dotIx > commaIx ? '.' : (commaIx > dotIx ? ',' : '.'); 1648 if (decimalSep === ',') { 1649 raw = raw.replace(/\./g, '').replace(',', '.'); 1650 } else { 1651 raw = raw.replace(/,/g, ''); 1652 } 1653 var p = parseFloat(raw); 1654 if (!isNaN(p) && p > 0) price = p; 1655 } 1656 } 1657 var currency = ''; 1658 if (currencyEl) { 1659 currency = (currencyEl.getAttribute('content') || String(currencyEl.textContent || '')).trim(); 1660 } 1661 // Cascade through initialProduct â window.prestashop.currency.iso_code 1662 // (the latter is page-wide PS native data â covers quickview-from- 1663 // category-page where MC_PIXEL.initialProduct doesn't exist).
1664 if (!currency) currency = resolveCurrency(); 1665 var sku = skuEl ? String(skuEl.textContent || '').trim() : ''; 1666 var imageUrl = ''; 1667 if (imageEl) { 1668 imageUrl = String(imageEl.getAttribute('content') 1669 || imageEl.getAttribute('data-image-large-src') 1670 || imageEl.getAttribute('src') || ''); 1671 } 1672 1673 var resolvedId = (paid && paid !== '0') ? paid : pid; 1674 // Capture the DOM scrape as DEFENSE â used only if AJAX fails. The 1675 // scrape isn't reliable across themes (hummingbird's modal markup 1676 // has no schema.org microdata; class-based parsing can splice the 1677 // regular-vs-current price texts into a Frankenstein number) â so 1678 // we treat it as a last-resort, not a primary source. 1679 var scrapedProduct = { 1680 id: String(resolvedId), 1681 productId: String(pid), 1682 title: title, 1683 price: (typeof price === 'number' && !isNaN(price)) ? price : null, 1684 currency: currency, 1685 sku: sku, 1686 productUrl: fullProductLink || '', 1687 imageUrl: imageUrl, 1688 vendor: '', 1689 categories: [] 1690 }; 1691 1692 // Cascade for quickview (AJAX-FIRST): 1693 // 1. AJAX /pixelproductinfo with the modal's ids â server-built 1694 // payload with authoritative price, sku, image, vendor, 1695 // categories. The quickview modal already loaded via an AJAX 1696 // call PS made; adding one more (fire-and-forget) doesn't 1697 // block UI. mcTrack is async â visitor sees the modal 1698 // immediately, the pixel event reaches Mailchimp ~100 ms later. 1699 // 2. If AJAX fails (network / server / endpoint blocked), fall 1700 // back to the DOM scrape. May produce inaccurate fields but 1701 // better than skipping entirely. 1702 // 3. If neither works, log PIXEL_DATA_INCOMPLETE and skip mcTrack. 1703 fetchProductInfo(pid, paid || 0, function (serverProduct) { 1704 if (serverProduct && hasRequiredPixelFields(serverProduct)) { 1705 fireOnce('PRODUCT_VIEWED', { product: serverProduct }, 1706 String(serverProduct.id), 'quickview', 1707 'prestashop.on(quickviewOpened) + ajax'); 1708 return; 1709 } 1710 // AJAX failed â try the scrape as defense. 1711 if (hasRequiredPixelFields(scrapedProduct)) { 1712 fireOnce('PRODUCT_VIEWED', { product: scrapedProduct }, 1713 String(resolvedId), 'quickview', 1714 'prestashop.on(quickviewOpened) + css-scrape (ajax failed)'); 1715 return; 1716 } 1717 sendTelemetry('PIXEL_DATA_INCOMPLETE', 2, 1718 'quickview product ' + pid + (paid ? '/' + paid : '') 1719 + ' â required fields missing after AJAX + DOM scrape fallback'); 1720 }); 1721 } 1722 1723 // ------------------------------------------------------------------ 1724 // PRODUCT_ADDED_TO_CART â prestashop.on('updateCart') subscription 1725 // ------------------------------------------------------------------ 1726 1727 /** 1728 * Match a single line in the PS-emitted cart.products[] array by id_product 1729 * + id_product_attribute. The `cart.products` array is what PS ships in 1730 * the updateCart event payload (both `e.reason.cart.products` and 1731 * `e.resp.cart.products` carry the same data); we always read from 1732 * `e.reason.cart` because that's defined at the moment the event fires. 1733 * 1734 * Returns the matching line object or null. 1735 */ 1736 function findCartLineItem(cart, idProduct, idProductAttribute) { 1737 if (!cart || !cart.products || !cart.products.length) return null; 1738 var pidStr = String(idProduct); 1739 var paidStr = String(idProductAttribute || 0); 1740 for (var i = 0; i < cart.products.length; i++) { 1741 var p = cart.products[i]; 1742 if (String(p.id_product) === pidStr 1743 && (String(p.id_product_attribute || 0) === paidStr 1744 || (paidStr === '0' && !p.id_product_attribute))) { 1745 return p; 1746 } 1747 } 1748 return null; 1749 } 1750 1751 /** 1752 * Build a Mailchimp Pixel `product.item` shape from a PS cart line. Field 1753 * mapping is stable across PS 1.7.0.6 â 9.1: every key referenced here is 1754 * present in the cart.products[] line items PS emits in `updateCart`. 1755 * - line.id variant id (or product id when no variant) 1756 * - line.id_product parent product id 1757 * - line.name product name 1758 * - line.price_amount numeric, with-tax unit price (preferred) 1759 * - line.price_wt fallback (same value, older naming) 1760 * - line.reference SKU 1761 * - line.url variant-aware URL 1762 * - line.cover.bySize.* cover image variants 1763 * - line.manufacturer_name vendor 1764 * - line.category single category name (best-effort) 1765 */ 1766 function buildCartLineProductItem(line, idProduct, idProductAttribute) { 1767 if (!line) return null; 1768 var unitPrice = null; 1769 if (typeof line.price_amount === 'number') unitPrice = line.price_amount; 1770 else if (typeof line.price_wt === 'number') unitPrice = line.price_wt; 1771 var imageUrl = ''; 1772 if (line.cover && line.cover.bySize) { 1773 var bs = line.cover.bySize; 1774 imageUrl = (bs.large_default && bs.large_default.url) 1775 || (bs.default_xl && bs.default_xl.url) 1776 || (bs.home_default && bs.home_default.url) 1777 || (bs.medium_default && bs.medium_default.url) || ''; 1778 } 1779 // Categories DISABLED for PRODUCT_ADDED_TO_CART (Mailchimp spec marks 1780 // it optional). The cart presenter (PS Cart::getProducts) only exposes 1781 // `line.category` â the URL slug of the default category, lowercase 1782 // (e.g. "men"). That's data-inconsistent with PRODUCT_VIEWED's 1783 // categories which come from Product::getProductCategoriesFull and 1784 // are proper-case + multi (e.g. ["Home","Clothes","Men"]). Sending 1785 // both shapes would break Mailchimp segments built on category 1786 // strings (case-sensitive, exact match) â same product would match 1787 // a "Men" segment on view but miss the same segment on add-to-cart. 1788 // To re-enable cleanly we'd need either an AJAX category lookup per
1789 // cart line OR a server-side enrichment endpoint. For now, omit. 1790 // var categories = []; 1791 // if (line.category && typeof line.category === 'string') { 1792 // categories.push(line.category); 1793 // } 1794 // productUrl â strip the `#/<attr>-<val>/...` anchor PS appends. The 1795 // hash is internal state for combination restoration; carrying it 1796 // into Mailchimp creates stale URLs (the hash reflects the variant 1797 // active when this line was last presented, not necessarily the 1798 // current selection on a multi-step refresh). Mirrors the same 1799 // hash-strip we do in buildPixelProductPayloadFromProduct / 1800 // buildPixelProductPayloadFromArray on the PHP side. 1801 var rawUrl = String(line.url || ''); 1802 var urlHashAt = rawUrl.indexOf('#'); 1803 var productUrl = urlHashAt >= 0 ? rawUrl.slice(0, urlHashAt) : rawUrl; 1804 return { 1805 id: String(line.id || idProductAttribute || idProduct), 1806 productId: String(line.id_product || idProduct), 1807 title: String(line.name || ''), 1808 price: (unitPrice !== null) ? unitPrice : null, 1809 currency: resolveCurrency(), 1810 sku: String(line.reference || ''), 1811 productUrl: productUrl, 1812 imageUrl: imageUrl, 1813 vendor: String(line.manufacturer_name || '') 1814 // categories: categories // see comment above 1815 }; 1816 } 1817 1818 /** 1819 * Round to 2 decimal places, IEEE-754-noise-safe (`22.94 * 3` â `68.82`, 1820 * not `68.81999999...`). Used for the top-level `price` line-total field 1821 * = unitPrice à quantity, which Mailchimp's PRODUCT_ADDED_TO_CART event 1822 * spec defines as "value of THIS event" â i.e., what the visitor just 1823 * added, NOT the cart's running subtotal. 1824 */ 1825 function round2(n) { return Math.round((n + Number.EPSILON) * 100) / 100; } 1826 1827 /** 1828 * Wrap a Pixel-shaped product item + its quantity into Mailchimp's 3P 1829 * spec `{ item, quantity }` shape. This is the SAME structure both 1830 * cart-shaped events use: 1831 * - PRODUCT_ADDED_TO_CART â `product: { item, quantity, price, currency }` 1832 * (caller adds price/currency siblings after wrapping) 1833 * - CART_VIEWED â `cart.lineItems[N] = { item, quantity }` 1834 * 1835 * Centralized here so both cart-shaped events have a single source of 1836 * truth for the wrapper shape (and any future quantity-coercion logic). 1837 * 1838 * @param {object} item output of buildCartLineProductItem (or pixelproductinfo) 1839 * @param {number|string} quantity 1840 * @return {object} Mailchimp-shaped { item, quantity } 1841 */ 1842 function wrapLineItem(item, quantity) { 1843 var q; 1844 if (typeof quantity === 'number' && !isNaN(quantity)) { 1845 q = quantity; 1846 } else { 1847 q = Number(quantity); 1848 if (isNaN(q) || q < 1) q = 1; 1849 } 1850 return { item: item, quantity: q }; 1851 } 1852 1853 /** 1854 * Hybrid cartId resolver: 1855 * 1. If MC_PIXEL.cartId is > 0 â use it directly (99% case: cart already 1856 * existed at pageload). Zero AJAX. 1857 * 2. If MC_PIXEL.cartId is 0/falsy â AJAX /pixelcartinfo. Covers the 1858 * first-add-of-session edge case where PS lazy-creates the cart row 1859 * AFTER setMedia ran. 1860 * 3. Both fail â onDone(null), caller logs PIXEL_DATA_INCOMPLETE. 1861 */ 1862 function resolveCartId(onDone) { 1863 var cached = cfg.cartId; 1864 if (cached && Number(cached) > 0) { 1865 onDone(Number(cached)); 1866 return; 1867 } 1868 fetchCartInfo(function (info) { 1869 // Cache the freshly-fetched id back into cfg so subsequent 1870 // events in the same pageload skip the AJAX. 1871 if (info && info.cartId) { 1872 cfg.cartId = Number(info.cartId); 1873 onDone(Number(info.cartId)); 1874 } else { 1875 onDone(null); 1876 } 1877 }); 1878 } 1879 1880 /** 1881 * Handler for `prestashop.on('updateCart', e)`. Fires only when 1882 * `e.reason.linkAction === 'add-to-cart'` â PS emits the same event 1883 * for remove/update-quantity actions, which map to other Mailchimp 1884 * events we'll wire up later (REMOVED_FROM_CART isn't in the 3P spec 1885 * but quantity-change MAY be tracked separately if needed). 1886 * 1887 * Cross-PS verified: `linkAction = 'add-to-cart'` exists in PS 1.7.0.6 1888 * core.js line 1873 and continues through PS 9.1 (theme core-prod.js). 1889 * `e.reason.cart.products[]` field names (id_product, id_product_ 1890 * attribute, name, price_amount, reference, url, cover.bySize.*, 1891 * manufacturer_name, category) are stable across the matrix. 1892 */ 1893 function onCartUpdate(e) { 1894 if (!e || !e.reason) return; 1895 if (e.reason.linkAction !== 'add-to-cart') return; 1896 // Visitor opted out: skip the whole event-handling chain. Without this
1897 // guard the handler still runs end-to-end and fires PIXEL_DATA_INCOMPLETE 1898 // telemetry containing visitor activity (product id, "add-to-cart") 1899 // â because fetchCartInfo correctly short-circuits on opt-out, which 1900 // upstream code then mistakes for a failed AJAX. Mirrors the top-level 1901 // opt-out guard already used in mcTrack, callSdkIdentify, etc. 1902 if (isExplicitlyOptedOut()) { 1903 return; 1904 } 1905 1906 var idProduct = e.reason.idProduct; 1907 var idProductAttribute = e.reason.idProductAttribute || 0; 1908 var qty = (e.resp && e.resp.quantity) ? Number(e.resp.quantity) : 1; 1909 1910 // ===== LAYER 1: build item from e.reason.cart.products[] ===== 1911 // PS-emitted line items carry the full server-presented product data. 1912 // 99% path â zero AJAX, cart product line is already in the event. 1913 var line = findCartLineItem(e.reason.cart, idProduct, idProductAttribute); 1914 var item = line ? buildCartLineProductItem(line, idProduct, idProductAttribute) : null; 1915 var cartQuantityForFp = (line && line.cart_quantity) ? line.cart_quantity : qty; 1916 1917 function fireWithItem(itemPayload, viaTag) { 1918 // Line value of this event = unit à just-added qty, NOT cart 1919 // subtotal. line.total_wt would be cart-line value including 1920 // previously-added quantities of the same variant. 1921 var lineTotal = round2(itemPayload.price * qty); 1922 var currency = itemPayload.currency; 1923 resolveCartId(function (cartId) { 1924 if (!cartId) { 1925 sendTelemetry('PIXEL_DATA_INCOMPLETE', 2, 1926 'add-to-cart: cartId unavailable (JsDef=0, AJAX returned ok:false) ' 1927 + 'for product ' + idProduct); 1928 return; 1929 } 1930 // Fingerprint by cartId + variant + new cart_quantity. Two 1931 // rapid clicks for the same logical action (PS sometimes 1932 // emits twice) dedupe; user adding the SAME variant later 1933 // gets a fresh fire because cart_quantity changed. 1934 var fp = 'add:' + cartId + ':' + itemPayload.id 1935 + ':' + cartQuantityForFp; 1936 // Mailchimp 3P Pixel spec for PRODUCT_ADDED_TO_CART nests 1937 // quantity, price, currency INSIDE the product object 1938 // (different from PRODUCT_VIEWED which is flat): 1939 // { cartId, product: { item, quantity, price, currency } } 1940 // The {item, quantity} core is the same shape CART_VIEWED's 1941 // lineItems[] entries use â shared via wrapLineItem(). 1942 // See https://mailchimp.com/help/mailchimp-site-tracking-pixel-integration-guidance/ 1943 var productPayload = wrapLineItem(itemPayload, qty); 1944 productPayload.price = lineTotal; 1945 productPayload.currency = currency; 1946 fireOnce('PRODUCT_ADDED_TO_CART', 1947 { cartId: String(cartId), product: productPayload }, 1948 fp, 'add-to-cart', viaTag); 1949 }); 1950 } 1951 1952 if (item && hasRequiredPixelFields(item)) { 1953 fireWithItem(item, 'prestashop.on(updateCart)'); 1954 return; 1955 } 1956 1957 // ===== LAYER 2: AJAX /pixelproductinfo fallback ===== 1958 // Reached when: 1959 // - the matching product wasn't in e.reason.cart.products[] (stale 1960 // event payload, cache glitch, custom theme intercepting the event) 1961 // - the line was found but required fields are missing (custom 1962 // theme stripping standard cart-line fields) 1963 // We have idProduct + idProductAttribute from e.reason â server can 1964 // build the authoritative item payload identical to what PRODUCT_VIEWED 1965 // uses on Layer 3. 1966 fetchProductInfo(idProduct, idProductAttribute, function (serverProduct) { 1967 if (serverProduct && hasRequiredPixelFields(serverProduct)) { 1968 fireWithItem(serverProduct, 'prestashop.on(updateCart) + ajax-fallback'); 1969 return; 1970 } 1971 // ===== Both layers failed ===== 1972 sendTelemetry('PIXEL_DATA_INCOMPLETE', 2, 1973 'add-to-cart: product ' + idProduct 1974 + (idProductAttribute ? '/' + idProductAttribute : '') 1975 + ' â required fields missing after cart.products[] lookup ' 1976 + '+ /pixelproductinfo AJAX fallback'); 1977 }); 1978 } 1979 1980 // CART_VIEWED lives in mc-pixel-bridge-cart.js â that file is registered 1981 // by setMedia ONLY when php_self === 'cart', so its IIFE only runs on 1982 // actual cart pageloads (zero overhead on category / product / checkout 1983 // pages). All cart-shared helpers it needs (buildCartLineProductItem, 1984 // hasRequiredPixelFields, resolveCurrency, resolveCartId, fetchCartInfo, 1985 // fireOnce, sendTelemetry) are exposed on window.MC_PIXEL_BRIDGE below. 1986 1987 // ------------------------------------------------------------------
1988 // Wire-up â shared infrastructure (always-on) 1989 // ------------------------------------------------------------------ 1990 1991 // (1) Queue interceptor. Must be installed before any per-event JS 1992 // file runs (their inline-script hook pushes need this in place 1993 // to fire-on-push). Done in core so it's ready regardless of 1994 // which per-event files are loaded for this page. 1995 installEventQueueInterceptor(); 1996 1997 // (2) Quickview listeners. Quickview opens from category / search / 1998 // listing pages too â NOT only from product pages. So the 1999 // listener registration MUST live in core (loaded everywhere), 2000 // not in mc-pixel-bridge-product.js (loaded only on product 2001 // pages). 2002 // Two listeners, both routing to onQuickviewOpened (which dedupes 2003 // via lastQuickviewOpenedAt â 500ms window): 2004 // - `prestashop.on('quickviewOpened')` â hummingbird emits this 2005 // from its quickview.ts after shown.bs.modal fires. 2006 // - jQuery delegate on `shown.bs.modal` â classic theme (every 2007 // PS version 1.7.0.6 â 9.1.3) uses Bootstrap modals for 2008 // quickview, verified by reading 2009 // themes/classic/templates/catalog/_partials/quickview.tpl 2010 // on all 6 docker containers; modal ID pattern 2011 // `quickview-modal-{PID}-{PAID}` is identical to hummingbird's. 2012 if (window.prestashop && typeof window.prestashop.on === 'function') { 2013 try { 2014 window.prestashop.on('quickviewOpened', onQuickviewOpened); 2015 } catch (e) { /* hummingbird-only; tolerate absence on classic */ } 2016 try { 2017 // (3) Cart update listener. Fires from anywhere a customer can 2018 // add to cart (product page, category, search, quickview 2019 // modal). e.reason.linkAction discriminates add-to-cart 2020 // from update-quantity / remove-from-cart. 2021 window.prestashop.on('updateCart', onCartUpdate); 2022 } catch (e) { /* tolerate absence */ } 2023 } 2024 2025 // Bootstrap modal lifecycle event â covers classic theme on every PS 2026 // version (which doesn't emit prestashop.quickviewOpened). jQuery is 2027 // guaranteed on the storefront across PS 1.7+ â 9.x. Delegated to 2028 // document so we can listen without the modal existing yet. 2029 if (window.jQuery) { 2030 try { 2031 window.jQuery(document).on( 2032 'shown.bs.modal', 2033 '[id^="quickview-modal-"]', 2034 onQuickviewOpened 2035 ); 2036 } catch (e) { /* tolerate jQuery edge cases */ } 2037 } 2038 2039 // ------------------------------------------------------------------ 2040 // Expose helpers for the per-event JS files (mc-pixel-bridge-product.js, 2041 // mc-pixel-bridge-cart.js) + browser-console debug + standalone test fixture. 2042 // Cart-page-only and product-page-only logic lives in those bundles â 2043 // see their headers for the wire-up. Core only contains: 2044 // - cross-page event-bus subscriptions (quickviewOpened, updateCart) 2045 // - shared helpers used by both core and the per-page bundles 2046 // ------------------------------------------------------------------ 2047 window.MC_PIXEL_BRIDGE = { 2048 track: mcTrack, 2049 identify: mcIdentify, 2050 identifyPhone: mcIdentifyPhone, 2051 whenReady: whenPixelReady, 2052 sendTelemetry: sendTelemetry, 2053 fireOnce: fireOnce, 2054 hasRequiredPixelFields: hasRequiredPixelFields, 2055 fetchProductInfo: fetchProductInfo, 2056 fetchCartInfo: fetchCartInfo,
2057 resolveCurrency: resolveCurrency, 2058 resolveCartId: resolveCartId, 2059 onQuickviewOpened: onQuickviewOpened, 2060 onCartUpdate: onCartUpdate, 2061 findCartLineItem: findCartLineItem, 2062 buildCartLineProductItem: buildCartLineProductItem, 2063 wrapLineItem: wrapLineItem, 2064 firedPixelEvents: firedPixelEvents, 2065 isExplicitlyOptedOut: isExplicitlyOptedOut, 2066 cfg: cfg 2067 }; 2068})();
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.