PageSourceSearch

https://www.frappe.com.ar/modules/mailchimppro/views/js/front/mc-pixel-bridge-forms.js?v=3.1.2

js frappe.com.ar collected 2026-10-04 14:15:13 UTC 22,202 bytes, 432 lines download raw bytes

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

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.