PageSourceSearch

https://www.productosdeesteticaypeluqueriaprofesional.com/modules/…/views/js/front/mc-pixel-bridge.js?v=3.1.5

js productosdeesteticaypeluqueriaprofesional.com collected 2026-09-25 19:07:15 UTC 102,964 bytes, 2,068 lines download raw bytes

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.